Remove chat rooms and agent loops before storage migration (#3053)

* refactor(storage): remove chat rooms and agent loops

This is the prerequisite cleanup for the storage backend migration. Legacy wire schemas remain temporarily so mixed-version peers can still parse old messages, while daemon callers now use query-shaped AgentStorage methods that can map directly to SQL later.

* test(cli): remove retired chat and loop coverage
This commit is contained in:
Mohamed Boudra
2026-08-10 21:06:47 +02:00
committed by GitHub
parent c5d5441c9c
commit 94bda1f925
73 changed files with 503 additions and 6801 deletions
-6
View File
@@ -1,8 +1,6 @@
import { Command, Option } from "commander";
import { createAgentCommand } from "./commands/agent/index.js";
import { createDaemonCommand } from "./commands/daemon/index.js";
import { createChatCommand } from "./commands/chat/index.js";
import { createLoopCommand } from "./commands/loop/index.js";
import { createPermitCommand } from "./commands/permit/index.js";
import { createProviderCommand } from "./commands/provider/index.js";
import { createScheduleCommand } from "./commands/schedule/index.js";
@@ -168,7 +166,6 @@ export function createCli(): Command {
program.addCommand(createHubCommand());
// Chat commands
program.addCommand(createChatCommand());
// Terminal commands
program.addCommand(createTerminalCommand());
@@ -176,9 +173,6 @@ export function createCli(): Command {
// Workspace script commands
program.addCommand(createScriptCommand());
// Loop commands
program.addCommand(createLoopCommand());
// Schedule commands
program.addCommand(createScheduleCommand());
program.addCommand(createHeartbeatCommand());
-31
View File
@@ -1,31 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export interface ChatCreateOptions extends ChatCommandOptions {
purpose?: string;
}
export async function runCreateCommand(
name: string,
options: ChatCreateOptions,
_command: Command,
): Promise<SingleResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.createChatRoom({
name,
purpose: options.purpose,
});
return {
type: "single",
data: toChatRoomRow(payload.room!),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_CREATE_FAILED", "create chat room", err);
} finally {
await client.close().catch(() => {});
}
}
-24
View File
@@ -1,24 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export async function runDeleteCommand(
room: string,
options: ChatCommandOptions,
_command: Command,
): Promise<SingleResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.deleteChatRoom({ room });
return {
type: "single",
data: toChatRoomRow(payload.room!),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_DELETE_FAILED", "delete chat room", err);
} finally {
await client.close().catch(() => {});
}
}
-69
View File
@@ -1,69 +0,0 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
import { runCreateCommand } from "./create.js";
import { runLsCommand } from "./ls.js";
import { runInspectCommand } from "./inspect.js";
import { runDeleteCommand } from "./delete.js";
import { runPostCommand } from "./post.js";
import { runReadCommand } from "./read.js";
import { runWaitCommand } from "./wait.js";
export function createChatCommand(): Command {
const chat = new Command("chat").description("Manage chat rooms for agent coordination");
addJsonAndDaemonHostOptions(
chat
.command("create")
.description("Create a chat room")
.argument("<name>", "Room name (must be unique)")
.option("--purpose <text>", "Room purpose/description"),
).action(withOutput(runCreateCommand));
addJsonAndDaemonHostOptions(chat.command("ls").description("List chat rooms")).action(
withOutput(runLsCommand),
);
addJsonAndDaemonHostOptions(
chat
.command("inspect")
.description("Inspect a chat room")
.argument("<name-or-id>", "Room name or ID"),
).action(withOutput(runInspectCommand));
addJsonAndDaemonHostOptions(
chat
.command("delete")
.description("Delete a chat room")
.argument("<name-or-id>", "Room name or ID"),
).action(withOutput(runDeleteCommand));
addJsonAndDaemonHostOptions(
chat
.command("post")
.description("Post a chat message")
.argument("<name-or-id>", "Room name or ID")
.argument("<message>", "Message body")
.option("--reply-to <msg-id>", "Reply to a specific message ID"),
).action(withOutput(runPostCommand));
addJsonAndDaemonHostOptions(
chat
.command("read")
.description("Read chat messages")
.argument("<name-or-id>", "Room name or ID")
.option("--limit <n>", "Maximum number of messages to return")
.option("--since <duration-or-timestamp>", "Filter by relative duration or ISO timestamp")
.option("--agent <agent-id>", "Filter by author agent ID"),
).action(withOutput(runReadCommand));
addJsonAndDaemonHostOptions(
chat
.command("wait")
.description("Wait for new chat messages")
.argument("<name-or-id>", "Room name or ID")
.option("--timeout <duration>", "Maximum wait time"),
).action(withOutput(runWaitCommand));
return chat;
}
-24
View File
@@ -1,24 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export async function runInspectCommand(
room: string,
options: ChatCommandOptions,
_command: Command,
): Promise<SingleResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.inspectChatRoom({ room });
return {
type: "single",
data: toChatRoomRow(payload.room!),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_INSPECT_FAILED", "inspect chat room", err);
} finally {
await client.close().catch(() => {});
}
}
-23
View File
@@ -1,23 +0,0 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import { connectChatClient, toChatCommandError, type ChatCommandOptions } from "./shared.js";
import { chatRoomSchema, type ChatRoomRow, toChatRoomRow } from "./schema.js";
export async function runLsCommand(
options: ChatCommandOptions,
_command: Command,
): Promise<ListResult<ChatRoomRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.listChatRooms();
return {
type: "list",
data: payload.rooms.map(toChatRoomRow),
schema: chatRoomSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_LIST_FAILED", "list chat rooms", err);
} finally {
await client.close().catch(() => {});
}
}
-43
View File
@@ -1,43 +0,0 @@
import type { Command } from "commander";
import type { SingleResult } from "../../output/index.js";
import {
attachAgentNamesToMessages,
connectChatClient,
resolveChatAuthorAgentId,
toChatCommandError,
type ChatCommandOptions,
} from "./shared.js";
import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js";
export interface ChatPostOptions extends ChatCommandOptions {
replyTo?: string;
}
export async function runPostCommand(
room: string,
body: string,
options: ChatPostOptions,
_command: Command,
): Promise<SingleResult<ChatMessageRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.postChatMessage({
room,
body,
authorAgentId: resolveChatAuthorAgentId(),
replyToMessageId: options.replyTo,
});
const [message] = await attachAgentNamesToMessages(client, [
toChatMessageRow(payload.message!),
]);
return {
type: "single",
data: message,
schema: chatMessageSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_POST_FAILED", "post chat message", err);
} finally {
await client.close().catch(() => {});
}
}
-60
View File
@@ -1,60 +0,0 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import {
attachAgentNamesToMessages,
connectChatClient,
parseSinceValue,
toChatCommandError,
type ChatCommandOptions,
} from "./shared.js";
import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js";
export interface ChatReadOptions extends ChatCommandOptions {
limit?: string;
since?: string;
agent?: string;
}
function parseLimit(value?: string): number | undefined {
if (!value) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed < 0) {
throw {
code: "INVALID_LIMIT",
message: "Invalid --limit value",
details: "Use a non-negative integer.",
};
}
return parsed;
}
export async function runReadCommand(
room: string,
options: ChatReadOptions,
_command: Command,
): Promise<ListResult<ChatMessageRow>> {
const { client } = await connectChatClient(options.host);
try {
const payload = await client.readChatMessages({
room,
limit: parseLimit(options.limit),
since: parseSinceValue(options.since),
authorAgentId: options.agent,
});
const messages = await attachAgentNamesToMessages(
client,
payload.messages.map(toChatMessageRow),
);
return {
type: "list",
data: messages,
schema: chatMessageSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_READ_FAILED", "read chat messages", err);
} finally {
await client.close().catch(() => {});
}
}
-128
View File
@@ -1,128 +0,0 @@
import type { OutputSchema } from "../../output/index.js";
import type { AnyCommandResult, OutputOptions } from "../../output/index.js";
export interface ChatRoomRow {
name: string;
id: string;
purpose: string;
messages: number;
lastMessageAt: string;
}
export interface ChatMessageRow {
id: string;
author: string;
authorName: string | null;
createdAt: string;
replyTo: string;
mentionAgentIds: string[];
mentionLabels: string[];
body: string;
}
export const chatRoomSchema: OutputSchema<ChatRoomRow> = {
idField: "id",
columns: [
{ header: "NAME", field: "name", width: 22 },
{ header: "ID", field: "id", width: 36 },
{ header: "PURPOSE", field: "purpose", width: 30 },
{ header: "MESSAGES", field: "messages", width: 10, align: "right" },
{ header: "LAST MESSAGE", field: "lastMessageAt", width: 24 },
],
};
export const chatMessageSchema: OutputSchema<ChatMessageRow> = {
idField: "id",
columns: [
{ header: "ID", field: "id", width: 36 },
{ header: "AUTHOR", field: "author", width: 16 },
{ header: "AUTHOR NAME", field: (row) => row.authorName ?? "-", width: 20 },
{ header: "CREATED", field: "createdAt", width: 24 },
{ header: "REPLY TO", field: "replyTo", width: 36 },
{ header: "MENTIONS", field: (row) => row.mentionLabels.join(", ") || "-", width: 24 },
{ header: "MESSAGE", field: "body", width: 60 },
],
renderHuman: renderChatTranscript,
};
function renderChatTranscript(
result: AnyCommandResult<ChatMessageRow>,
_options: OutputOptions,
): string {
const data = result.type === "list" ? result.data : [result.data];
if (data.length === 0) {
return "";
}
return data.map(renderChatMessageBlock).join("\n\n");
}
function renderChatMessageBlock(message: ChatMessageRow): string {
const authorLabel = message.authorName
? `${message.authorName} (${message.author})`
: message.author;
const lines = [
`┌─ ${authorLabel} ── ${formatTimestamp(message.createdAt)} ── [msg ${message.id}]`,
];
if (message.replyTo !== "-") {
lines.push(`│ reply-to: msg ${message.replyTo}`);
}
if (message.mentionLabels.length > 0) {
lines.push(`│ mentions: ${message.mentionLabels.join(", ")}`);
}
lines.push("│");
const bodyLines = message.body.split(/\r?\n/);
for (const line of bodyLines) {
lines.push(`│ ${line}`);
}
lines.push("│");
lines.push("└─");
return lines.join("\n");
}
function formatTimestamp(input: string): string {
const date = new Date(input);
if (Number.isNaN(date.getTime())) {
return input;
}
return date.toISOString().replace("T", " ").replace(".000Z", "Z");
}
export function toChatRoomRow(room: {
id: string;
name: string;
purpose: string | null;
messageCount: number;
lastMessageAt: string | null;
}): ChatRoomRow {
return {
id: room.id,
name: room.name,
purpose: room.purpose ?? "-",
messages: room.messageCount,
lastMessageAt: room.lastMessageAt ?? "-",
};
}
export function toChatMessageRow(message: {
id: string;
authorAgentId: string;
createdAt: string;
replyToMessageId: string | null;
mentionAgentIds: string[];
body: string;
}): ChatMessageRow {
return {
id: message.id,
author: message.authorAgentId,
authorName: null,
createdAt: message.createdAt,
replyTo: message.replyToMessageId ?? "-",
mentionAgentIds: message.mentionAgentIds,
mentionLabels: message.mentionAgentIds,
body: message.body,
};
}
-137
View File
@@ -1,137 +0,0 @@
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import { parseDuration } from "../../utils/duration.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
import type { ChatMessageRow } from "./schema.js";
export interface ChatCommandOptions extends CommandOptions {
host?: string;
}
export async function connectChatClient(host?: string) {
const daemonHost = getDaemonHost({ host });
try {
const client = await connectToDaemon({ host });
return { client, daemonHost };
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${daemonHost}: ${message}`,
details: "Start the daemon with: paseo daemon start",
};
throw error;
}
}
export async function attachAgentNamesToMessages(
client: Awaited<ReturnType<typeof connectToDaemon>>,
messages: ChatMessageRow[],
options: { timeout?: number; bestEffort?: boolean } = {},
): Promise<ChatMessageRow[]> {
const agentIds = new Set<string>();
for (const message of messages) {
agentIds.add(message.author);
for (const mentionId of message.mentionAgentIds) {
agentIds.add(mentionId);
}
}
if (agentIds.size === 0) {
return messages;
}
let payload: Awaited<ReturnType<typeof client.fetchAgents>>;
try {
payload = await client.fetchAgents({
filter: { includeArchived: true },
...(typeof options.timeout === "number" ? { timeout: options.timeout } : {}),
});
} catch (error) {
if (options.bestEffort) {
return messages;
}
throw error;
}
const agentNames = new Map<string, string>();
for (const entry of payload.entries) {
const title = entry.agent.title?.trim();
if (title) {
agentNames.set(entry.agent.id, title);
}
}
return messages.map((message) => ({
...message,
authorName: agentNames.get(message.author) ?? null,
mentionLabels: message.mentionAgentIds.map((agentId) => {
const name = agentNames.get(agentId);
return name ? `${name} (${agentId})` : agentId;
}),
}));
}
export function toChatCommandError(code: string, action: string, err: unknown): CommandError {
if (err && typeof err === "object" && "code" in err && "message" in err) {
return err as CommandError;
}
const message = err instanceof Error ? err.message : String(err);
const rpcCode =
typeof err === "object" && err !== null && "code" in err && typeof err.code === "string"
? err.code
: undefined;
return {
code: rpcCode ?? code,
message: `Failed to ${action}: ${message}`,
};
}
export function parseSinceValue(input?: string): string | undefined {
if (!input) {
return undefined;
}
try {
const durationMs = parseDuration(input);
return new Date(Date.now() - durationMs).toISOString();
} catch {
const timestamp = new Date(input);
if (Number.isNaN(timestamp.getTime())) {
const error: CommandError = {
code: "INVALID_SINCE",
message: "Invalid --since value",
details: "Use a duration like 10m or an ISO timestamp.",
};
throw error;
}
return timestamp.toISOString();
}
}
export function parseTimeoutMs(input?: string): number | undefined {
if (!input) {
return undefined;
}
try {
const timeoutMs = parseDuration(input);
if (timeoutMs <= 0) {
throw new Error("Timeout must be positive");
}
return timeoutMs;
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
const error: CommandError = {
code: "INVALID_TIMEOUT",
message: "Invalid timeout value",
details: message,
};
throw error;
}
}
export function resolveChatAuthorAgentId(): string {
const agentId = process.env.PASEO_AGENT_ID?.trim();
return agentId && agentId.length > 0 ? agentId : "manual";
}
-65
View File
@@ -1,65 +0,0 @@
import type { Command } from "commander";
import type { ListResult } from "../../output/index.js";
import {
attachAgentNamesToMessages,
connectChatClient,
parseTimeoutMs,
toChatCommandError,
type ChatCommandOptions,
} from "./shared.js";
import { chatMessageSchema, type ChatMessageRow, toChatMessageRow } from "./schema.js";
export interface ChatWaitOptions extends ChatCommandOptions {
timeout?: string;
}
const CHAT_WAIT_PREFLIGHT_TIMEOUT_MS = 2000;
export async function runWaitCommand(
room: string,
options: ChatWaitOptions,
_command: Command,
): Promise<ListResult<ChatMessageRow>> {
const timeoutMs = parseTimeoutMs(options.timeout);
const { client } = await connectChatClient(options.host);
const deadline = typeof timeoutMs === "number" ? Date.now() + timeoutMs : null;
const hasExplicitTimeout = deadline !== null;
const remainingTimeoutMs = () =>
deadline === null ? undefined : Math.max(1, deadline - Date.now());
try {
const latest = await client.readChatMessages({
room,
limit: 1,
...(hasExplicitTimeout
? {
timeout: Math.min(remainingTimeoutMs() ?? 1, CHAT_WAIT_PREFLIGHT_TIMEOUT_MS),
}
: {}),
});
const afterMessageId = latest.messages[0]?.id;
const payload = await client.waitForChatMessages({
room,
afterMessageId,
timeoutMs: remainingTimeoutMs() ?? timeoutMs,
});
const messages = await attachAgentNamesToMessages(
client,
payload.messages.map(toChatMessageRow),
hasExplicitTimeout
? {
timeout: remainingTimeoutMs(),
bestEffort: true,
}
: {},
);
return {
type: "list",
data: messages,
schema: chatMessageSchema,
};
} catch (err) {
throw toChatCommandError("CHAT_WAIT_FAILED", "wait for chat messages", err);
} finally {
await client.close().catch(() => {});
}
}
-32
View File
@@ -1,32 +0,0 @@
import { Command } from "commander";
import { withOutput } from "../../output/index.js";
import { addJsonAndDaemonHostOptions, addDaemonHostOption } from "../../utils/command-options.js";
import { addLoopRunOptions, runLoopRunCommand } from "./run.js";
import { addLoopLsOptions, runLoopLsCommand } from "./ls.js";
import { addLoopInspectOptions, runLoopInspectCommand } from "./inspect.js";
import { addLoopLogsOptions, runLoopLogsCommand } from "./logs.js";
import { addLoopStopOptions, runLoopStopCommand } from "./stop.js";
export function createLoopCommand(): Command {
const loop = new Command("loop").description("Run iterative worker loops");
addJsonAndDaemonHostOptions(addLoopRunOptions(loop.command("run"))).action(
withOutput(runLoopRunCommand),
);
addJsonAndDaemonHostOptions(addLoopLsOptions(loop.command("ls"))).action(
withOutput(runLoopLsCommand),
);
addJsonAndDaemonHostOptions(addLoopInspectOptions(loop.command("inspect"))).action(
withOutput(runLoopInspectCommand),
);
addDaemonHostOption(addLoopLogsOptions(loop.command("logs"))).action(runLoopLogsCommand);
addJsonAndDaemonHostOptions(addLoopStopOptions(loop.command("stop"))).action(
withOutput(runLoopStopCommand),
);
return loop;
}
-119
View File
@@ -1,119 +0,0 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, CommandError, OutputSchema, ListResult } from "../../output/index.js";
import type { LoopDaemonClient, LoopRecord } from "./types.js";
interface InspectRow {
key: string;
value: string;
}
export interface LoopInspectOptions extends CommandOptions {}
export function addLoopInspectOptions(command: Command): Command {
return command.description("Show loop details and iteration history").argument("<id>", "Loop ID");
}
function createInspectSchema(loop: LoopRecord): OutputSchema<InspectRow> {
return {
idField: "key",
columns: [
{ header: "KEY", field: "key", width: 18 },
{ header: "VALUE", field: "value", width: 80 },
],
serialize: () => loop,
};
}
function toRows(loop: LoopRecord): InspectRow[] {
return [
{ key: "Id", value: loop.id },
{ key: "Name", value: loop.name ?? "null" },
{ key: "Status", value: loop.status },
{ key: "Cwd", value: loop.cwd },
{ key: "Provider", value: loop.provider },
{ key: "Model", value: loop.model ?? "null" },
{ key: "WorkerProvider", value: loop.workerProvider ?? "null" },
{ key: "WorkerModel", value: loop.workerModel ?? "null" },
{ key: "VerifierProvider", value: loop.verifierProvider ?? "null" },
{ key: "VerifierModel", value: loop.verifierModel ?? "null" },
{ key: "Prompt", value: loop.prompt },
{ key: "VerifyPrompt", value: loop.verifyPrompt ?? "null" },
{
key: "VerifyChecks",
value: loop.verifyChecks.length > 0 ? loop.verifyChecks.join(" | ") : "[]",
},
{ key: "Archive", value: String(loop.archive) },
{ key: "SleepMs", value: String(loop.sleepMs) },
{
key: "MaxIterations",
value: loop.maxIterations === null ? "null" : String(loop.maxIterations),
},
{ key: "MaxTimeMs", value: loop.maxTimeMs === null ? "null" : String(loop.maxTimeMs) },
{ key: "CreatedAt", value: loop.createdAt },
{ key: "UpdatedAt", value: loop.updatedAt },
{ key: "CompletedAt", value: loop.completedAt ?? "null" },
{
key: "Iterations",
value:
loop.iterations.length === 0
? "[]"
: loop.iterations
.map((iteration) => {
const summary = [
`#${iteration.index}`,
iteration.status,
iteration.workerAgentId ? `worker=${iteration.workerAgentId}` : null,
iteration.verifierAgentId ? `verifier=${iteration.verifierAgentId}` : null,
iteration.failureReason ? `reason=${iteration.failureReason}` : null,
]
.filter(Boolean)
.join(" ");
return summary;
})
.join(" | "),
},
];
}
export type LoopInspectResult = ListResult<InspectRow>;
export async function runLoopInspectCommand(
id: string,
options: LoopInspectOptions,
_command: Command,
): Promise<LoopInspectResult> {
const host = getDaemonHost({ host: options.host });
let client;
try {
client = (await connectToDaemon({
host: options.host,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
try {
const payload = await client.loopInspect(id);
await client.close();
if (payload.error || !payload.loop) {
throw new Error(payload.error ?? `Loop not found: ${id}`);
}
return {
type: "list",
data: toRows(payload.loop),
schema: createInspectSchema(payload.loop),
};
} catch (error) {
await client.close().catch(() => {});
throw {
code: "LOOP_INSPECT_FAILED",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}
-83
View File
@@ -1,83 +0,0 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
import type { LoopDaemonClient, LoopLogEntry } from "./types.js";
export interface LoopLogsOptions extends CommandOptions {
pollInterval?: string;
}
export function addLoopLogsOptions(command: Command): Command {
return command
.description("Stream loop logs")
.argument("<id>", "Loop ID")
.option("--poll-interval <ms>", "Polling interval in milliseconds", "1000");
}
function parsePollInterval(value: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw {
code: "INVALID_POLL_INTERVAL",
message: "--poll-interval must be a positive integer",
} satisfies CommandError;
}
return parsed;
}
function renderLogEntry(entry: LoopLogEntry): string {
const prefix = [
entry.timestamp,
entry.source,
entry.iteration === null ? null : `iteration=${entry.iteration}`,
entry.level === "error" ? "ERROR" : null,
]
.filter(Boolean)
.join(" ");
return `${prefix}\n${entry.text}`;
}
export async function runLoopLogsCommand(
id: string,
options: LoopLogsOptions,
_command: Command,
): Promise<void> {
const host = getDaemonHost({ host: options.host });
const pollInterval = parsePollInterval(options.pollInterval ?? "1000");
let client: LoopDaemonClient;
try {
client = (await connectToDaemon({
host: options.host,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Error: Cannot connect to daemon at ${host}: ${message}`);
console.error("Start the daemon with: paseo daemon start");
process.exit(1);
}
async function streamLogs(cursor: number): Promise<void> {
const payload = await client.loopLogs(id, cursor);
if (payload.error || !payload.loop) {
throw new Error(payload.error ?? `Loop not found: ${id}`);
}
for (const entry of payload.entries) {
console.log(renderLogEntry(entry));
}
if (payload.loop.status !== "running") {
await client.close();
return;
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
return streamLogs(payload.nextCursor);
}
try {
await streamLogs(0);
} catch (error) {
await client.close().catch(() => {});
const message = error instanceof Error ? error.message : String(error);
console.error(`Error: Failed to stream loop logs: ${message}`);
process.exit(1);
}
}
-83
View File
@@ -1,83 +0,0 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandOptions, CommandError, OutputSchema, ListResult } from "../../output/index.js";
import type { LoopDaemonClient, LoopListItem } from "./types.js";
interface LoopListRow {
id: string;
name: string | null;
status: string;
cwd: string;
updated: string;
activeIteration: string;
}
export interface LoopLsOptions extends CommandOptions {}
export const loopLsSchema: OutputSchema<LoopListRow> = {
idField: "id",
columns: [
{ header: "LOOP ID", field: "id", width: 10 },
{ header: "NAME", field: "name", width: 20 },
{ header: "STATUS", field: "status", width: 10 },
{ header: "ITER", field: "activeIteration", width: 8 },
{ header: "CWD", field: "cwd", width: 40 },
{ header: "UPDATED", field: "updated", width: 24 },
],
};
export function addLoopLsOptions(command: Command): Command {
return command.description("List loops");
}
function toRow(loop: LoopListItem): LoopListRow {
return {
id: loop.id,
name: loop.name,
status: loop.status,
cwd: loop.cwd,
updated: loop.updatedAt,
activeIteration: loop.activeIteration === null ? "-" : String(loop.activeIteration),
};
}
export type LoopLsResult = ListResult<LoopListRow>;
export async function runLoopLsCommand(
options: LoopLsOptions,
_command: Command,
): Promise<LoopLsResult> {
const host = getDaemonHost({ host: options.host });
let client;
try {
client = (await connectToDaemon({
host: options.host,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
try {
const payload = await client.loopList();
await client.close();
if (payload.error) {
throw new Error(payload.error);
}
return {
type: "list",
data: payload.loops.map(toRow),
schema: loopLsSchema,
};
} catch (error) {
await client.close().catch(() => {});
throw {
code: "LOOP_LIST_FAILED",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}
@@ -1,89 +0,0 @@
import { describe, it, expect } from "vitest";
import { buildLoopRunInput } from "./run.js";
import type { LoopRunOptions } from "./types.js";
describe("buildLoopRunInput", () => {
it("should parse provider/model from provider string", () => {
const options: LoopRunOptions = {
provider: "opencode/hy3-preview-free",
model: undefined,
verifyProvider: undefined,
verifyModel: undefined,
verify: undefined,
verifyCheck: [],
archive: false,
name: undefined,
sleep: undefined,
maxIterations: undefined,
maxTime: undefined,
} as LoopRunOptions;
const result = buildLoopRunInput("test prompt", options);
expect(result.provider).toBe("opencode");
expect(result.model).toBe("hy3-preview-free");
});
it("should use provider only when no model in string", () => {
const options = {
provider: "opencode",
model: undefined,
verifyProvider: undefined,
verifyModel: undefined,
verify: undefined,
verifyCheck: [],
archive: false,
name: undefined,
sleep: undefined,
maxIterations: undefined,
maxTime: undefined,
} as LoopRunOptions;
const result = buildLoopRunInput("test prompt", options);
expect(result.provider).toBe("opencode");
expect(result.model).toBeUndefined();
});
it("should pass verifierProvider with model parsing", () => {
const options = {
provider: undefined,
model: undefined,
verifyProvider: "codex/gpt-5.4",
verifyModel: undefined,
verify: undefined,
verifyCheck: [],
archive: false,
name: undefined,
sleep: undefined,
maxIterations: undefined,
maxTime: undefined,
} as LoopRunOptions;
const result = buildLoopRunInput("test prompt", options);
expect(result.verifierProvider).toBe("codex");
expect(result.verifierModel).toBe("gpt-5.4");
});
it("should prefer explicit model over parsed model", () => {
const options = {
provider: "opencode/hy3-preview-free",
model: "other-model",
verifyProvider: undefined,
verifyModel: undefined,
verify: undefined,
verifyCheck: [],
archive: false,
name: undefined,
sleep: undefined,
maxIterations: undefined,
maxTime: undefined,
} as LoopRunOptions;
const result = buildLoopRunInput("test prompt", options);
expect(result.provider).toBe("opencode");
expect(result.model).toBe("other-model");
});
});
-208
View File
@@ -1,208 +0,0 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
CommandError,
OutputSchema,
SingleResult,
} from "../../output/index.js";
import { collectMultiple } from "../../utils/command-options.js";
import { parseDuration } from "../../utils/duration.js";
import { resolveProviderAndModel } from "../../utils/provider-model.js";
import type { LoopDaemonClient, LoopRecord, LoopRunInput } from "./types.js";
export interface LoopRunRow {
id: string;
status: string;
name: string | null;
cwd: string;
}
export interface LoopRunOptions extends CommandOptions {
provider?: string;
model?: string;
mode?: string;
verifyProvider?: string;
verifyModel?: string;
verifyMode?: string;
verify?: string;
verifyCheck?: string[];
archive?: boolean;
name?: string;
sleep?: string;
maxIterations?: string;
maxTime?: string;
}
export const loopRunSchema: OutputSchema<LoopRunRow> = {
idField: "id",
columns: [
{ header: "LOOP ID", field: "id", width: 10 },
{ header: "STATUS", field: "status", width: 10 },
{ header: "NAME", field: "name", width: 20 },
{ header: "CWD", field: "cwd", width: 40 },
],
};
export function addLoopRunOptions(command: Command): Command {
return command
.description("Start a loop")
.argument("<prompt>", "Prompt for each fresh worker iteration")
.option("--provider <provider>", "Default provider for worker and verifier agents")
.option("--model <model>", "Default model for worker and verifier agents")
.option(
"--mode <mode>",
"Provider-specific mode for the worker agent (e.g. claude bypassPermissions, opencode build)",
)
.option("--verify-provider <provider>", "Provider for the verifier agent")
.option("--verify-model <model>", "Model for the verifier agent")
.option("--verify-mode <mode>", "Provider-specific mode for the verifier agent")
.option("--verify <prompt>", "Verifier agent prompt")
.option(
"--verify-check <command>",
"Shell command that must exit 0 (repeatable)",
collectMultiple,
[],
)
.option("--archive", "Archive worker and verifier agents after each iteration")
.option("--name <name>", "Optional loop name")
.option("--sleep <duration>", "Delay between iterations (for example: 30s, 5m)")
.option("--max-iterations <n>", "Maximum number of iterations")
.option("--max-time <duration>", "Maximum total runtime (for example: 1h, 30m)");
}
function toRow(loop: LoopRecord): LoopRunRow {
return {
id: loop.id,
status: loop.status,
name: loop.name,
cwd: loop.cwd,
};
}
function parseMaxIterations(value: string | undefined): number | undefined {
if (value === undefined) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw {
code: "INVALID_MAX_ITERATIONS",
message: "--max-iterations must be a positive integer",
} satisfies CommandError;
}
return parsed;
}
// oxlint-disable complexity
export function buildLoopRunInput(prompt: string, options: LoopRunOptions): LoopRunInput {
const verifyPrompt = options.verify?.trim();
if (options.verify !== undefined && !verifyPrompt) {
throw {
code: "INVALID_VERIFY_PROMPT",
message: "--verify cannot be empty",
} satisfies CommandError;
}
const result: LoopRunInput = {
prompt,
cwd: process.cwd(),
};
// Resolve provider/model
if (options.provider) {
const { provider, model } = resolveProviderAndModel({ provider: options.provider });
if (provider) result.provider = provider;
// Explicit --model takes precedence over parsed model
if (options.model?.trim()) {
result.model = options.model.trim();
} else if (model) {
result.model = model;
}
} else if (options.model?.trim()) {
result.model = options.model.trim();
}
if (options.mode?.trim()) {
result.modeId = options.mode.trim();
}
// Resolve verifier provider/model
if (options.verifyProvider) {
const { provider, model } = resolveProviderAndModel({ provider: options.verifyProvider });
if (provider) result.verifierProvider = provider;
// Explicit --verify-model takes precedence over parsed model
if (options.verifyModel?.trim()) {
result.verifierModel = options.verifyModel.trim();
} else if (model) {
result.verifierModel = model;
}
} else if (options.verifyModel?.trim()) {
result.verifierModel = options.verifyModel.trim();
}
if (options.verifyMode?.trim()) {
result.verifierModeId = options.verifyMode.trim();
}
if (verifyPrompt) result.verifyPrompt = verifyPrompt;
if (options.verifyCheck && options.verifyCheck.length > 0) {
result.verifyChecks = options.verifyCheck;
}
if (options.archive) result.archive = true;
if (options.name?.trim()) result.name = options.name.trim();
if (options.sleep) result.sleepMs = parseDuration(options.sleep);
if (options.maxIterations) {
result.maxIterations = parseMaxIterations(options.maxIterations);
}
if (options.maxTime) result.maxTimeMs = parseDuration(options.maxTime);
return result;
}
// oxlint-enable complexity
export type LoopRunResult = SingleResult<LoopRunRow>;
export async function runLoopRunCommand(
prompt: string,
options: LoopRunOptions,
_command: Command,
): Promise<LoopRunResult> {
const host = getDaemonHost({ host: options.host });
const input = buildLoopRunInput(prompt, options);
let client;
try {
client = (await connectToDaemon({
host: options.host,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
try {
const payload = await client.loopRun(input);
await client.close();
if (payload.error || !payload.loop) {
throw new Error(payload.error ?? "Loop creation failed");
}
return {
type: "single",
data: toRow(payload.loop),
schema: loopRunSchema,
};
} catch (error) {
await client.close().catch(() => {});
if (error && typeof error === "object" && "code" in error) {
throw error;
}
throw {
code: "LOOP_RUN_FAILED",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}
-80
View File
@@ -1,80 +0,0 @@
import { Command } from "commander";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type {
CommandOptions,
CommandError,
OutputSchema,
SingleResult,
} from "../../output/index.js";
import type { LoopDaemonClient, LoopRecord } from "./types.js";
interface LoopStopRow {
id: string;
status: string;
activeIteration: string;
}
export interface LoopStopOptions extends CommandOptions {}
export const loopStopSchema: OutputSchema<LoopStopRow> = {
idField: "id",
columns: [
{ header: "LOOP ID", field: "id", width: 10 },
{ header: "STATUS", field: "status", width: 10 },
{ header: "ITER", field: "activeIteration", width: 8 },
],
};
export function addLoopStopOptions(command: Command): Command {
return command.description("Stop a running loop").argument("<id>", "Loop ID");
}
function toRow(loop: LoopRecord): LoopStopRow {
return {
id: loop.id,
status: loop.status,
activeIteration: loop.activeIteration === null ? "-" : String(loop.activeIteration),
};
}
export type LoopStopResult = SingleResult<LoopStopRow>;
export async function runLoopStopCommand(
id: string,
options: LoopStopOptions,
_command: Command,
): Promise<LoopStopResult> {
const host = getDaemonHost({ host: options.host });
let client;
try {
client = (await connectToDaemon({
host: options.host,
})) as unknown as LoopDaemonClient;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw {
code: "DAEMON_NOT_RUNNING",
message: `Cannot connect to daemon at ${host}: ${message}`,
details: "Start the daemon with: paseo daemon start",
} satisfies CommandError;
}
try {
const payload = await client.loopStop(id);
await client.close();
if (payload.error || !payload.loop) {
throw new Error(payload.error ?? `Loop not found: ${id}`);
}
return {
type: "single",
data: toRow(payload.loop),
schema: loopStopSchema,
};
} catch (error) {
await client.close().catch(() => {});
throw {
code: "LOOP_STOP_FAILED",
message: error instanceof Error ? error.message : String(error),
} satisfies CommandError;
}
}
-151
View File
@@ -1,151 +0,0 @@
export type LoopStatus = "running" | "succeeded" | "failed" | "stopped";
export interface LoopLogEntry {
seq: number;
timestamp: string;
iteration: number | null;
source: "loop" | "worker" | "verifier" | "verify-check";
level: "info" | "error";
text: string;
}
export interface LoopVerifyCheckResult {
command: string;
exitCode: number;
passed: boolean;
stdout: string;
stderr: string;
startedAt: string;
completedAt: string;
}
export interface LoopVerifyPromptResult {
passed: boolean;
reason: string;
verifierAgentId: string | null;
startedAt: string;
completedAt: string;
}
export interface LoopIterationRecord {
index: number;
workerAgentId: string | null;
workerStartedAt: string;
workerCompletedAt: string | null;
verifierAgentId: string | null;
status: "running" | "succeeded" | "failed" | "stopped";
workerOutcome: "completed" | "failed" | "canceled" | null;
failureReason: string | null;
verifyChecks: LoopVerifyCheckResult[];
verifyPrompt: LoopVerifyPromptResult | null;
}
export interface LoopRecord {
id: string;
name: string | null;
prompt: string;
cwd: string;
provider: string;
model: string | null;
modeId: string | null;
workerProvider: string | null;
workerModel: string | null;
verifierProvider: string | null;
verifierModel: string | null;
verifierModeId: string | null;
verifyPrompt: string | null;
verifyChecks: string[];
archive: boolean;
sleepMs: number;
maxIterations: number | null;
maxTimeMs: number | null;
status: LoopStatus;
createdAt: string;
updatedAt: string;
startedAt: string;
completedAt: string | null;
stopRequestedAt: string | null;
iterations: LoopIterationRecord[];
logs: LoopLogEntry[];
nextLogSeq: number;
activeIteration: number | null;
activeWorkerAgentId: string | null;
activeVerifierAgentId: string | null;
}
export interface LoopListItem {
id: string;
name: string | null;
status: LoopStatus;
cwd: string;
createdAt: string;
updatedAt: string;
activeIteration: number | null;
}
export interface LoopLogsResult {
loop: LoopRecord;
entries: LoopLogEntry[];
nextCursor: number;
}
export interface LoopRunPayload {
requestId: string;
loop: LoopRecord | null;
error: string | null;
}
export interface LoopListPayload {
requestId: string;
loops: LoopListItem[];
error: string | null;
}
export interface LoopInspectPayload {
requestId: string;
loop: LoopRecord | null;
error: string | null;
}
export interface LoopLogsPayload {
requestId: string;
loop: LoopRecord | null;
entries: LoopLogEntry[];
nextCursor: number;
error: string | null;
}
export interface LoopStopPayload {
requestId: string;
loop: LoopRecord | null;
error: string | null;
}
export interface LoopRunInput {
prompt: string;
cwd: string;
provider?: string;
model?: string;
modeId?: string;
workerProvider?: string;
workerModel?: string;
verifierProvider?: string;
verifierModel?: string;
verifierModeId?: string;
verifyPrompt?: string;
verifyChecks?: string[];
archive?: boolean;
name?: string;
sleepMs?: number;
maxIterations?: number;
maxTimeMs?: number;
}
export interface LoopDaemonClient {
loopRun(input: LoopRunInput): Promise<LoopRunPayload>;
loopList(): Promise<LoopListPayload>;
loopInspect(id: string): Promise<LoopInspectPayload>;
loopLogs(id: string, afterSeq?: number): Promise<LoopLogsPayload>;
loopStop(id: string): Promise<LoopStopPayload>;
close(): Promise<void>;
}
-95
View File
@@ -1,95 +0,0 @@
#!/usr/bin/env npx tsx
import assert from "node:assert";
import { rm } from "node:fs/promises";
import { createE2ETestContext } from "./helpers/test-daemon.ts";
console.log("=== Chat Command Tests ===\n");
const ctx = await createE2ETestContext({ timeout: 30000 });
try {
{
console.log("Test 1: chat create/ls/inspect work");
const created = await ctx.paseo(["chat", "create", "coord-room", "--purpose", "Coordination"]);
assert.strictEqual(created.exitCode, 0, created.stderr);
assert(created.stdout.includes("coord-room"), created.stdout);
const listed = await ctx.paseo(["chat", "ls"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
assert(listed.stdout.includes("coord-room"), listed.stdout);
const inspected = await ctx.paseo(["chat", "inspect", "coord-room"]);
assert.strictEqual(inspected.exitCode, 0, inspected.stderr);
assert(inspected.stdout.includes("Coordination"), inspected.stdout);
console.log("chat create/ls/inspect work\n");
}
{
console.log("Test 2: chat post/read/wait work");
const posted = await ctx.paseo(["chat", "post", "coord-room", "first message for @agent-1"], {
env: { PASEO_AGENT_ID: "00000000-0000-4000-8000-000000000111" },
});
assert.strictEqual(posted.exitCode, 0, posted.stderr);
assert(posted.stdout.includes("first message"), posted.stdout);
assert(posted.stdout.includes("00000000-0000-4000-8000-000000000111"), posted.stdout);
const read = await ctx.paseo(["chat", "read", "coord-room", "--limit", "10"]);
assert.strictEqual(read.exitCode, 0, read.stderr);
assert(read.stdout.includes("first message"), read.stdout);
assert(read.stdout.includes("00000000-0000-4000-8000-000000000111"), read.stdout);
const readJson = await ctx.paseo(["chat", "read", "coord-room", "--limit", "10", "--json"]);
assert.strictEqual(readJson.exitCode, 0, readJson.stderr);
const readPayload = JSON.parse(readJson.stdout);
assert.strictEqual(readPayload[0]?.author, "00000000-0000-4000-8000-000000000111");
// `chat wait` reads "latest message id" then subscribes for newer ones.
// Under CI load the subprocess can take >1s to bootstrap, so a single
// delayed post races against the read. Post repeatedly and race against
// wait — every post is newer than the snapshot wait took, so one of them
// always wakes it up.
const waitPromise = ctx.paseo(["chat", "wait", "coord-room", "--timeout", "30s"]);
const waitSentinel = waitPromise.then(() => "settled" as const);
let postedSecond = false;
for (let attempt = 0; attempt < 60; attempt++) {
const post = await ctx.paseo(["chat", "post", "coord-room", "second message"]);
assert.strictEqual(post.exitCode, 0, post.stderr);
const tick = new Promise<"tick">((resolve) => setTimeout(() => resolve("tick"), 250));
const result = await Promise.race([waitSentinel, tick]);
if (result === "settled") {
postedSecond = true;
break;
}
}
assert(postedSecond, "chat wait did not return after repeated posts");
const waited = await waitPromise;
assert.strictEqual(waited.exitCode, 0, waited.stderr);
assert(waited.stdout.includes("second message"), waited.stdout);
console.log("chat post/read/wait work\n");
}
{
console.log("Test 3: duplicate room create fails");
const duplicate = await ctx.paseo(["chat", "create", "coord-room"]);
assert.notStrictEqual(duplicate.exitCode, 0, "duplicate create should fail");
const combined = `${duplicate.stdout}\n${duplicate.stderr}`;
assert(combined.toLowerCase().includes("already exists"), combined);
console.log("duplicate room create fails\n");
}
{
console.log("Test 4: chat delete works");
const deleted = await ctx.paseo(["chat", "delete", "coord-room"]);
assert.strictEqual(deleted.exitCode, 0, deleted.stderr);
assert(deleted.stdout.includes("coord-room"), deleted.stdout);
console.log("chat delete works\n");
}
} finally {
await ctx.stop();
await rm(ctx.paseoHome, { recursive: true, force: true });
await rm(ctx.workDir, { recursive: true, force: true });
}
console.log("=== Chat Command Tests Passed ===");
@@ -4,31 +4,7 @@ import assert from "node:assert";
import { rm } from "node:fs/promises";
import { createE2ETestContext } from "./helpers/test-daemon.ts";
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForLoopInList(
ctx: Awaited<ReturnType<typeof createE2ETestContext>>,
id: string,
) {
for (let attempt = 0; attempt < 20; attempt++) {
const listed = await ctx.paseo(["loop", "ls", "--json"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
const listedJson = JSON.parse(listed.stdout);
assert(Array.isArray(listedJson), listed.stdout);
if (listedJson.some((item: { id: string }) => item.id === id)) {
return listedJson;
}
await sleep(250);
}
const listed = await ctx.paseo(["loop", "ls", "--json"]);
assert.strictEqual(listed.exitCode, 0, listed.stderr);
return JSON.parse(listed.stdout);
}
console.log("=== Loop And Schedule Command Tests ===\n");
console.log("=== Schedule Command Tests ===\n");
const ctx = await createE2ETestContext({ timeout: 30000 });
@@ -171,62 +147,10 @@ try {
assert.strictEqual(JSON.parse(deleted.stdout).id, createdJson.id);
console.log("compatibility agent-target schedules remain deletable\n");
}
{
console.log("Test 2: loop run/ls/inspect/logs/stop work");
const run = await ctx.paseo(
[
"loop",
"run",
"Return any response",
"--name",
"smoke-loop",
"--verify-check",
"true",
"--json",
],
{ timeout: 30000 },
);
assert.strictEqual(run.exitCode, 0, run.stderr);
const runJson = JSON.parse(run.stdout);
assert.strictEqual(runJson.name, "smoke-loop");
const listedJson = await waitForLoopInList(ctx, runJson.id);
assert(
listedJson.some((item: { id: string }) => item.id === runJson.id),
JSON.stringify(listedJson),
);
async function pollStatus(attempt: number): Promise<string> {
if (attempt >= 40) return "running";
const inspect = await ctx.paseo(["loop", "inspect", runJson.id, "--json"]);
assert.strictEqual(inspect.exitCode, 0, inspect.stderr);
const inspectJson = JSON.parse(inspect.stdout);
const current = inspectJson.status;
if (current !== "running") {
assert.strictEqual(current, "succeeded", inspect.stdout);
return current;
}
await sleep(250);
return pollStatus(attempt + 1);
}
const status = await pollStatus(0);
assert.strictEqual(status, "succeeded");
const logs = await ctx.paseo(["loop", "logs", runJson.id], { timeout: 15000 });
assert.strictEqual(logs.exitCode, 0, logs.stderr);
assert(logs.stdout.includes("verify-check"), logs.stdout);
const stopped = await ctx.paseo(["loop", "stop", runJson.id, "--json"]);
assert.strictEqual(stopped.exitCode, 0, stopped.stderr);
const stoppedJson = JSON.parse(stopped.stdout);
assert(["succeeded", "stopped"].includes(stoppedJson.status), stopped.stdout);
console.log("loop commands work\n");
}
} finally {
await ctx.stop();
await rm(ctx.paseoHome, { recursive: true, force: true });
await rm(ctx.workDir, { recursive: true, force: true });
}
console.log("=== Loop And Schedule Command Tests Passed ===");
console.log("=== Schedule Command Tests Passed ===");
-50
View File
@@ -1294,56 +1294,6 @@ test("honors explicit shutdownServer timeout below the session RPC default", asy
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)");
});
test("honors explicit readChatMessages timeout below the session RPC default", async () => {
useHeartbeatClock();
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const responsePromise = client.readChatMessages({
requestId: "req-chat-read-1",
room: "room-1",
limit: 1,
timeout: 2_500,
});
let settled = false;
void responsePromise.then(
() => {
settled = true;
return undefined;
},
() => {
settled = true;
return undefined;
},
);
expect(parseSentFrame(mock.sent[0])).toEqual({
type: "chat/read",
requestId: "req-chat-read-1",
room: "room-1",
limit: 1,
});
await vi.advanceTimersByTimeAsync(2_499);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(1);
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (2500ms)");
});
test("honors explicit getDaemonStatus timeout below the session RPC default", async () => {
useHeartbeatClock();
const logger = createMockLogger();
-250
View File
@@ -492,30 +492,6 @@ type SubscribeTerminalPayload = SubscribeTerminalResponse["payload"];
type CloseItemsPayload = CloseItemsResponse["payload"];
type KillTerminalPayload = KillTerminalResponse["payload"];
type CaptureTerminalPayload = CaptureTerminalResponse["payload"];
type ChatCreatePayload = Extract<
SessionOutboundMessage,
{ type: "chat/create/response" }
>["payload"];
type ChatListPayload = Extract<SessionOutboundMessage, { type: "chat/list/response" }>["payload"];
type ChatInspectPayload = Extract<
SessionOutboundMessage,
{ type: "chat/inspect/response" }
>["payload"];
type ChatDeletePayload = Extract<
SessionOutboundMessage,
{ type: "chat/delete/response" }
>["payload"];
type ChatPostPayload = Extract<SessionOutboundMessage, { type: "chat/post/response" }>["payload"];
type ChatReadPayload = Extract<SessionOutboundMessage, { type: "chat/read/response" }>["payload"];
type ChatWaitPayload = Extract<SessionOutboundMessage, { type: "chat/wait/response" }>["payload"];
type LoopRunPayload = Extract<SessionOutboundMessage, { type: "loop/run/response" }>["payload"];
type LoopListPayload = Extract<SessionOutboundMessage, { type: "loop/list/response" }>["payload"];
type LoopInspectPayload = Extract<
SessionOutboundMessage,
{ type: "loop/inspect/response" }
>["payload"];
type LoopLogsPayload = Extract<SessionOutboundMessage, { type: "loop/logs/response" }>["payload"];
type LoopStopPayload = Extract<SessionOutboundMessage, { type: "loop/stop/response" }>["payload"];
type ScheduleCreatePayload = Extract<
SessionOutboundMessage,
{ type: "schedule/create/response" }
@@ -697,70 +673,6 @@ export type ProjectListPayload = Extract<
SessionOutboundMessage,
{ type: "project.list.response" }
>["payload"];
export interface CreateChatRoomOptions {
name: string;
purpose?: string | null;
requestId?: string;
}
export interface InspectChatRoomOptions {
room: string;
requestId?: string;
}
export interface DeleteChatRoomOptions {
room: string;
requestId?: string;
}
export interface PostChatMessageOptions {
room: string;
body: string;
authorAgentId?: string;
replyToMessageId?: string | null;
requestId?: string;
}
export interface ReadChatMessagesOptions {
room: string;
limit?: number;
since?: string;
authorAgentId?: string;
requestId?: string;
timeout?: number;
}
export interface WaitForChatMessagesOptions {
room: string;
afterMessageId?: string | null;
timeoutMs?: number;
requestId?: string;
}
export interface RunLoopOptions {
prompt: string;
cwd: string;
provider?: string;
model?: string;
modeId?: string;
verifierProvider?: string;
verifierModel?: string;
verifierModeId?: string;
verifyPrompt?: string | null;
verifyChecks?: string[];
name?: string | null;
sleepMs?: number;
maxIterations?: number;
maxTimeMs?: number;
requestId?: string;
}
export interface InspectLoopOptions {
id: string;
requestId?: string;
}
export interface LoopLogsOptions {
id: string;
afterSeq?: number;
requestId?: string;
}
export interface StopLoopOptions {
id: string;
requestId?: string;
}
export interface CreateScheduleOptions {
prompt: string;
name?: string | null;
@@ -5116,93 +5028,6 @@ export class DaemonClient {
});
}
async createChatRoom(options: CreateChatRoomOptions): Promise<ChatCreatePayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "chat/create",
name: options.name,
...(options.purpose ? { purpose: options.purpose } : {}),
},
responseType: "chat/create/response",
});
}
async listChatRooms(requestId?: string): Promise<ChatListPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "chat/list",
},
responseType: "chat/list/response",
});
}
async inspectChatRoom(options: InspectChatRoomOptions): Promise<ChatInspectPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "chat/inspect",
room: options.room,
},
responseType: "chat/inspect/response",
});
}
async deleteChatRoom(options: DeleteChatRoomOptions): Promise<ChatDeletePayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "chat/delete",
room: options.room,
},
responseType: "chat/delete/response",
});
}
async postChatMessage(options: PostChatMessageOptions): Promise<ChatPostPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "chat/post",
room: options.room,
body: options.body,
...(options.authorAgentId ? { authorAgentId: options.authorAgentId } : {}),
...(options.replyToMessageId ? { replyToMessageId: options.replyToMessageId } : {}),
},
responseType: "chat/post/response",
});
}
async readChatMessages(options: ReadChatMessagesOptions): Promise<ChatReadPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "chat/read",
room: options.room,
...(typeof options.limit === "number" ? { limit: options.limit } : {}),
...(options.since ? { since: options.since } : {}),
...(options.authorAgentId ? { authorAgentId: options.authorAgentId } : {}),
},
responseType: "chat/read/response",
timeout: options.timeout,
});
}
async waitForChatMessages(options: WaitForChatMessagesOptions): Promise<ChatWaitPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "chat/wait",
room: options.room,
...(options.afterMessageId ? { afterMessageId: options.afterMessageId } : {}),
...(typeof options.timeoutMs === "number" ? { timeoutMs: options.timeoutMs } : {}),
},
responseType: "chat/wait/response",
timeout: (options.timeoutMs ?? 0) + 10000,
});
}
async scheduleCreate(options: CreateScheduleOptions): Promise<ScheduleCreatePayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
@@ -5313,81 +5138,6 @@ export class DaemonClient {
});
}
async loopRun(options: RunLoopOptions): Promise<LoopRunPayload> {
return this.sendCorrelatedSessionRequest({
requestId: options.requestId,
message: {
type: "loop/run",
prompt: options.prompt,
cwd: options.cwd,
...(options.provider ? { provider: options.provider } : {}),
...(options.model ? { model: options.model } : {}),
...(options.modeId ? { modeId: options.modeId } : {}),
...(options.verifierProvider ? { verifierProvider: options.verifierProvider } : {}),
...(options.verifierModel ? { verifierModel: options.verifierModel } : {}),
...(options.verifierModeId ? { verifierModeId: options.verifierModeId } : {}),
...(options.verifyPrompt ? { verifyPrompt: options.verifyPrompt } : {}),
...(options.verifyChecks && options.verifyChecks.length > 0
? { verifyChecks: options.verifyChecks }
: {}),
...(options.name ? { name: options.name } : {}),
...(typeof options.sleepMs === "number" ? { sleepMs: options.sleepMs } : {}),
...(typeof options.maxIterations === "number"
? { maxIterations: options.maxIterations }
: {}),
...(typeof options.maxTimeMs === "number" ? { maxTimeMs: options.maxTimeMs } : {}),
},
responseType: "loop/run/response",
});
}
async loopList(requestId?: string): Promise<LoopListPayload> {
return this.sendCorrelatedSessionRequest({
requestId,
message: {
type: "loop/list",
},
responseType: "loop/list/response",
});
}
async loopInspect(options: string | InspectLoopOptions): Promise<LoopInspectPayload> {
const normalized = typeof options === "string" ? { id: options } : options;
return this.sendCorrelatedSessionRequest({
requestId: normalized.requestId,
message: {
type: "loop/inspect",
id: normalized.id,
},
responseType: "loop/inspect/response",
});
}
async loopLogs(options: string | LoopLogsOptions, afterSeq?: number): Promise<LoopLogsPayload> {
const normalized = typeof options === "string" ? { id: options, afterSeq } : options;
return this.sendCorrelatedSessionRequest({
requestId: normalized.requestId,
message: {
type: "loop/logs",
id: normalized.id,
...(typeof normalized.afterSeq === "number" ? { afterSeq: normalized.afterSeq } : {}),
},
responseType: "loop/logs/response",
});
}
async loopStop(options: string | StopLoopOptions): Promise<LoopStopPayload> {
const normalized = typeof options === "string" ? { id: options } : options;
return this.sendCorrelatedSessionRequest({
requestId: normalized.requestId,
message: {
type: "loop/stop",
id: normalized.id,
},
responseType: "loop/stop/response",
});
}
onTerminalStreamEvent(handler: (event: TerminalStreamEvent) => void): () => void {
return this.terminalStreams.onEvent(handler);
}
@@ -1,6 +1,8 @@
import { z } from "zod";
import { ChatMessageSchema, ChatRoomDetailSchema } from "./types.js";
// COMPAT(chatRooms): retained after the v0.3.0 feature removal; remove after 2027-02-09 when mixed-version peers no longer send legacy messages.
export const ChatCreateRequestSchema = z.object({
type: z.literal("chat/create"),
requestId: z.string(),
+2
View File
@@ -1,5 +1,7 @@
import { z } from "zod";
// COMPAT(chatRooms): retained after the v0.3.0 feature removal; remove after 2027-02-09 when mixed-version peers no longer send legacy messages.
export const ChatRoomSchema = z.object({
id: z.string(),
name: z.string(),
@@ -1,6 +1,8 @@
import { z } from "zod";
import { AgentProviderSchema } from "../provider-manifest.js";
// COMPAT(agentLoops): retained after the v0.3.0 feature removal; remove after 2027-02-09 when mixed-version peers no longer send legacy messages.
export const LoopLogEntrySchema = z.object({
seq: z.number().int().positive(),
timestamp: z.string(),
@@ -102,6 +102,7 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
id: overrides.id ?? "agent-test",
provider: core.provider,
cwd: core.cwd,
workspaceId: overrides.workspaceId,
session: core.session,
capabilities: overrides.capabilities ?? buildDefaultCapabilities(),
config: core.config,
@@ -433,6 +434,47 @@ describe("AgentStorage", () => {
expect(record?.internal).toBe(true);
});
test("queries agents by provider session and native handle", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "matching-session",
provider: "codex",
persistence: {
provider: "codex",
sessionId: "session-1",
nativeHandle: "thread-1",
},
}),
);
await storage.applySnapshot(
createManagedAgent({
id: "other-session",
provider: "codex",
persistence: { provider: "codex", sessionId: "session-2" },
}),
);
await expect(storage.listByProviderSession("codex", "session-1")).resolves.toMatchObject([
{ id: "matching-session" },
]);
await expect(storage.listByProviderSession("codex", "thread-1")).resolves.toMatchObject([
{ id: "matching-session" },
]);
});
test("queries agents by workspace", async () => {
await storage.applySnapshot(
createManagedAgent({ id: "workspace-agent", workspaceId: "workspace-1" }),
);
await storage.applySnapshot(
createManagedAgent({ id: "other-workspace-agent", workspaceId: "workspace-2" }),
);
await expect(storage.listByWorkspace("workspace-1")).resolves.toMatchObject([
{ id: "workspace-agent" },
]);
});
test("internal flag is persisted and reloaded", async () => {
await storage.applySnapshot(
createManagedAgent({
@@ -126,6 +126,24 @@ export class AgentStorage {
return this.cache.get(agentId) ?? null;
}
async listByProviderSession(
provider: string,
providerHandleId: string,
): Promise<StoredAgentRecord[]> {
await this.load();
return Array.from(this.cache.values()).filter(
(record) =>
record.persistence?.provider === provider &&
(record.persistence.sessionId === providerHandleId ||
record.persistence.nativeHandle === providerHandleId),
);
}
async listByWorkspace(workspaceId: string): Promise<StoredAgentRecord[]> {
await this.load();
return Array.from(this.cache.values()).filter((record) => record.workspaceId === workspaceId);
}
async findByDaemonExecution(owner: DaemonAgentOwner): Promise<StoredAgentRecord | null> {
await this.load();
const agentId = this.daemonAgentIdsByExecution.get(daemonExecutionKey(owner));
@@ -191,8 +191,9 @@ async function importProviderSessionNow(
): Promise<ImportedProviderSession> {
const { provider, providerHandleId, labels } = input.request;
const matchingRecords = (await input.agentStorage.list()).filter((record) =>
recordMatchesProviderHandle(record, { provider, providerHandleId }),
const matchingRecords = await input.agentStorage.listByProviderSession(
provider,
providerHandleId,
);
const activeRecord = matchingRecords.find((record) => !record.archivedAt);
if (activeRecord) {
@@ -272,16 +273,18 @@ async function serializeProviderSessionImport<T>(
async function resolveProviderSessionImportMutationKey(
input: ImportProviderSessionInput,
): Promise<string> {
const identity = {
provider: input.request.provider,
providerHandleId: input.request.providerHandleId,
};
const matchingRecord = (await input.agentStorage.list()).find((record) =>
recordMatchesProviderHandle(record, identity),
);
const matchingRecord = (
await input.agentStorage.listByProviderSession(
input.request.provider,
input.request.providerHandleId,
)
).at(0);
return matchingRecord
? `agent\0${matchingRecord.id}`
: `handle\0${toProviderSessionHandleKey(identity.provider, identity.providerHandleId)}`;
: `handle\0${toProviderSessionHandleKey(
input.request.provider,
input.request.providerHandleId,
)}`;
}
async function rollbackArchivedImport(
@@ -311,17 +314,6 @@ async function rollbackArchivedImport(
}
}
function recordMatchesProviderHandle(
record: StoredAgentRecord,
identity: { provider: string; providerHandleId: string },
): boolean {
return (
record.persistence?.provider === identity.provider &&
(record.persistence.sessionId === identity.providerHandleId ||
record.persistence.nativeHandle === identity.providerHandleId)
);
}
function parseRecentProviderSessionsSince(since: string | undefined): number | null {
if (!since) {
return null;
-19
View File
@@ -144,9 +144,7 @@ import {
FileBackedWorkspaceRegistry,
type WorkspaceArchiveContext,
} from "./workspace-registry.js";
import { FileBackedChatService } from "./chat/chat-service.js";
import { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
import { DaemonConfigStore, type MutableDaemonConfig } from "./daemon-config-store.js";
import { BrowserToolsBroker } from "./browser-tools/broker.js";
@@ -798,10 +796,6 @@ export async function createPaseoDaemon(
path.join(config.paseoHome, "projects", "workspaces.json"),
logger,
);
const chatService = new FileBackedChatService({
paseoHome: config.paseoHome,
logger,
});
const github = createGitHubService();
const workspaceGitService = new WorkspaceGitServiceImpl({
logger,
@@ -882,8 +876,6 @@ export async function createPaseoDaemon(
void workspaceReconciliation.reconcileNow().catch((error) => {
logger.warn({ err: error }, "Initial workspace reconciliation failed");
});
await chatService.initialize();
logger.info({ elapsed: elapsed() }, "Chat service initialized");
const checkoutDiffManager = new CheckoutDiffManager({
logger,
paseoHome: config.paseoHome,
@@ -1143,15 +1135,6 @@ export async function createPaseoDaemon(
}),
});
const loopService = new LoopService({
paseoHome: config.paseoHome,
logger,
agentManager,
createAgent,
ensureWorkspaceForCreate: ensureWorkspaceForCreateAndBroadcastExternal,
});
await loopService.initialize();
logger.info({ elapsed: elapsed() }, "Loop service initialized");
const createScheduleLocalWorkspaceExternal = async (input: {
cwd: string;
firstAgentContext: FirstAgentContext;
@@ -1538,8 +1521,6 @@ export async function createPaseoDaemon(
},
projectRegistry,
workspaceRegistry,
chatService,
loopService,
scheduleService,
checkoutDiffManager,
serviceProxy,
@@ -1,205 +0,0 @@
import { describe, expect, test, vi } from "vitest";
import type pino from "pino";
import type { StoredAgentRecord } from "../agent/agent-storage.js";
import type { ManagedAgent } from "../agent/agent-manager.js";
import {
buildChatMentionNotification,
notifyChatMentions,
prepareChatMentionFanout,
} from "./chat-mentions.js";
function storedAgent(overrides: Partial<StoredAgentRecord> & { id: string }): StoredAgentRecord {
return {
internal: false,
archivedAt: null,
lastStatus: "idle",
...overrides,
} as StoredAgentRecord;
}
function liveAgent(overrides: Partial<ManagedAgent> & { id: string }): ManagedAgent {
return { internal: false, lifecycle: "idle", ...overrides } as ManagedAgent;
}
async function prepare(input: {
authorAgentId: string;
mentionAgentIds: string[];
storedAgents?: StoredAgentRecord[];
liveAgents?: ManagedAgent[];
roomPosterAgentIds?: string[];
limit?: number;
}) {
const result = await prepareChatMentionFanout({
authorAgentId: input.authorAgentId,
mentionAgentIds: input.mentionAgentIds,
storedAgents: input.storedAgents ?? [],
liveAgents: input.liveAgents ?? [],
listRoomPosterAgentIds: async () => input.roomPosterAgentIds ?? [],
limit: input.limit,
});
if (!result.ok) {
throw new Error(`expected ok prepare, got error: ${result.error}`);
}
return result.prepared;
}
describe("chat mentions", () => {
test("@everyone in an empty room resolves to no targets", async () => {
const prepared = await prepare({
authorAgentId: "author-agent",
mentionAgentIds: ["everyone"],
storedAgents: [storedAgent({ id: "unrelated-agent" })],
liveAgents: [liveAgent({ id: "live-unrelated-agent" })],
roomPosterAgentIds: [],
});
expect(prepared.targetMentionAgentIds).toEqual([]);
});
test("@everyone in a single-poster room excludes the author", async () => {
const prepared = await prepare({
authorAgentId: "author-agent",
mentionAgentIds: ["everyone"],
storedAgents: [storedAgent({ id: "author-agent" })],
roomPosterAgentIds: ["author-agent"],
});
expect(prepared.targetMentionAgentIds).toEqual([]);
});
test("@everyone only expands to active posters in the room", async () => {
const prepared = await prepare({
authorAgentId: "author-agent",
mentionAgentIds: ["everyone"],
storedAgents: [
storedAgent({ id: "agent-a" }),
storedAgent({ id: "agent-b" }),
storedAgent({ id: "unrelated-agent" }),
],
liveAgents: [liveAgent({ id: "agent-c" }), liveAgent({ id: "live-unrelated-agent" })],
roomPosterAgentIds: ["agent-a", "agent-b", "agent-c"],
});
expect([...prepared.targetMentionAgentIds].sort()).toEqual(["agent-a", "agent-b", "agent-c"]);
});
test("@everyone excludes archived and error-state agents", async () => {
const prepared = await prepare({
authorAgentId: "author-agent",
mentionAgentIds: ["everyone"],
storedAgents: [
storedAgent({ id: "active-agent" }),
storedAgent({ id: "archived-agent", archivedAt: "2026-03-28T00:00:00.000Z" }),
storedAgent({ id: "stored-error-agent", lastStatus: "error" }),
],
liveAgents: [liveAgent({ id: "live-error-agent", lifecycle: "error" })],
roomPosterAgentIds: [
"active-agent",
"archived-agent",
"stored-error-agent",
"live-error-agent",
],
});
expect(prepared.targetMentionAgentIds).toEqual(["active-agent"]);
});
test("@everyone deduplicates with explicit mentions and keeps explicit non-everyone mentions", async () => {
const prepared = await prepare({
authorAgentId: "author-agent",
mentionAgentIds: ["everyone", "agent-a", "custom-title"],
storedAgents: [storedAgent({ id: "agent-a" })],
liveAgents: [liveAgent({ id: "agent-b" })],
roomPosterAgentIds: ["agent-a", "agent-b"],
});
expect([...prepared.targetMentionAgentIds].sort()).toEqual([
"agent-a",
"agent-b",
"custom-title",
]);
});
test("does not list room posters when @everyone is not mentioned", async () => {
const listRoomPosterAgentIds = vi.fn(async () => []);
const result = await prepareChatMentionFanout({
authorAgentId: "author-agent",
mentionAgentIds: ["agent-a"],
storedAgents: [storedAgent({ id: "agent-a" })],
liveAgents: [],
listRoomPosterAgentIds,
});
expect(result.ok).toBe(true);
expect(listRoomPosterAgentIds).not.toHaveBeenCalled();
});
test("rejects @everyone fan-out above the hard cap", async () => {
const posters = Array.from({ length: 26 }, (_, index) => `agent-${index}`);
const result = await prepareChatMentionFanout({
authorAgentId: "author-agent",
mentionAgentIds: ["everyone"],
storedAgents: posters.map((id) => storedAgent({ id })),
liveAgents: [],
listRoomPosterAgentIds: async () => posters,
});
expect(result).toEqual({
ok: false,
error:
"@everyone would notify 26 agents, which exceeds the limit of 25. Narrow the room or mention specific agents.",
});
});
test("notification body strips inline mentions but keeps the room context", () => {
expect(
buildChatMentionNotification({
room: "coord-room",
authorAgentId: "author-agent",
body: "@agent-a @everyone Check the latest status.",
mentionAgentIds: ["agent-a", "everyone"],
}),
).toContain("Check the latest status.");
});
test("notifyChatMentions delegates sends for resolved targets", async () => {
const resolveAgentIdentifier = vi.fn(async (identifier: string) => ({
ok: true as const,
agentId: identifier,
}));
const sendAgentMessage = vi.fn(async () => {});
const logger = {
warn: vi.fn(),
} as unknown as pino.Logger;
const storedAgents = [storedAgent({ id: "agent-a" })];
const liveAgents = [liveAgent({ id: "agent-b" })];
await notifyChatMentions({
room: "coord-room",
authorAgentId: "author-agent",
body: "@everyone Check status",
mentionAgentIds: ["everyone"],
logger,
storedAgents,
liveAgents,
prepared: {
targetMentionAgentIds: ["agent-a", "agent-b"],
roomPosterAgentIds: ["agent-a", "agent-b"],
},
resolveAgentIdentifier,
sendAgentMessage,
});
expect(resolveAgentIdentifier).toHaveBeenCalledTimes(2);
expect(sendAgentMessage).toHaveBeenCalledTimes(2);
expect(sendAgentMessage).toHaveBeenCalledWith(
"agent-a",
expect.stringContaining('room "coord-room"'),
);
expect(sendAgentMessage).toHaveBeenCalledWith(
"agent-b",
expect.stringContaining("Check status"),
);
});
});
@@ -1,194 +0,0 @@
import type pino from "pino";
import type { StoredAgentRecord } from "../agent/agent-storage.js";
import type { ManagedAgent } from "../agent/agent-manager.js";
export const CHAT_MENTION_FANOUT_LIMIT = 25;
export interface ChatMentionNotificationInput {
room: string;
authorAgentId: string;
body: string;
mentionAgentIds: string[];
}
export interface PreparedChatMentionFanout {
targetMentionAgentIds: string[];
roomPosterAgentIds: string[];
}
export type PrepareChatMentionFanoutResult =
| { ok: true; prepared: PreparedChatMentionFanout }
| { ok: false; error: string };
export interface PrepareChatMentionFanoutInput {
authorAgentId: string;
mentionAgentIds: string[];
storedAgents: StoredAgentRecord[];
liveAgents: ManagedAgent[];
listRoomPosterAgentIds: () => Promise<string[]>;
limit?: number;
}
export interface NotifyChatMentionsInput extends ChatMentionNotificationInput {
logger: pino.Logger;
storedAgents: StoredAgentRecord[];
liveAgents: ManagedAgent[];
prepared: PreparedChatMentionFanout;
resolveAgentIdentifier: (
identifier: string,
) => Promise<{ ok: true; agentId: string } | { ok: false; error: string }>;
sendAgentMessage: (agentId: string, text: string) => Promise<void>;
}
export async function prepareChatMentionFanout(
input: PrepareChatMentionFanoutInput,
): Promise<PrepareChatMentionFanoutResult> {
const mentionsEveryone = input.mentionAgentIds.includes("everyone");
const roomPosterAgentIds = mentionsEveryone ? await input.listRoomPosterAgentIds() : [];
const targetMentionAgentIds = expandChatMentionTargets({
authorAgentId: input.authorAgentId,
mentionAgentIds: input.mentionAgentIds,
storedAgents: input.storedAgents,
liveAgents: input.liveAgents,
roomPosterAgentIds,
});
if (mentionsEveryone) {
const limit = input.limit ?? CHAT_MENTION_FANOUT_LIMIT;
if (targetMentionAgentIds.length > limit) {
return {
ok: false,
error: `@everyone would notify ${targetMentionAgentIds.length} agents, which exceeds the limit of ${limit}. Narrow the room or mention specific agents.`,
};
}
}
return { ok: true, prepared: { targetMentionAgentIds, roomPosterAgentIds } };
}
export async function notifyChatMentions(input: NotifyChatMentionsInput): Promise<void> {
const { targetMentionAgentIds } = input.prepared;
if (targetMentionAgentIds.length === 0) {
return;
}
const notification = buildChatMentionNotification({
room: input.room,
authorAgentId: input.authorAgentId,
body: input.body,
mentionAgentIds: targetMentionAgentIds,
});
await Promise.all(
targetMentionAgentIds.map(async (mentionedAgentId) => {
const resolved = await input.resolveAgentIdentifier(mentionedAgentId);
if (!resolved.ok) {
input.logger.warn(
{ mentionedAgentId, room: input.room, error: resolved.error },
"Failed to resolve chat mention target",
);
return;
}
// Re-check eligibility on the resolved canonical id: explicit mentions may
// be custom titles that resolve to an archived or error-state agent.
if (
!isChatMentionTargetEligible({
agentId: resolved.agentId,
authorAgentId: input.authorAgentId,
storedAgents: input.storedAgents,
liveAgents: input.liveAgents,
})
) {
return;
}
try {
await input.sendAgentMessage(resolved.agentId, notification);
} catch (error) {
input.logger.warn(
{ err: error, mentionedAgentId: resolved.agentId, room: input.room },
"Failed to notify mentioned agent about chat message",
);
}
}),
);
}
export function buildChatMentionNotification(input: ChatMentionNotificationInput): string {
const mentioned = input.mentionAgentIds.map((agentId) => `@${agentId}`).join(", ");
const bodyWithoutMentions = input.body.replace(/(^|\s)@[A-Za-z0-9][A-Za-z0-9._-]*/g, "$1").trim();
const body = bodyWithoutMentions.length > 0 ? bodyWithoutMentions : input.body;
return [
`Chat mention from ${input.authorAgentId} in room "${input.room}".`,
`Mentioned agents: ${mentioned}.`,
"Message:",
body,
`Read the room with: paseo chat read ${input.room} --limit 20`,
].join("\n");
}
function expandChatMentionTargets(input: {
authorAgentId: string;
mentionAgentIds: string[];
storedAgents: StoredAgentRecord[];
liveAgents: ManagedAgent[];
roomPosterAgentIds: string[];
}): string[] {
const candidates = new Set<string>();
const mentionsEveryone = input.mentionAgentIds.includes("everyone");
for (const mentionAgentId of input.mentionAgentIds) {
if (mentionAgentId === "everyone" || mentionAgentId === input.authorAgentId) {
continue;
}
candidates.add(mentionAgentId);
}
if (mentionsEveryone) {
for (const posterAgentId of input.roomPosterAgentIds) {
if (posterAgentId !== input.authorAgentId) {
candidates.add(posterAgentId);
}
}
}
const targets: string[] = [];
for (const candidate of candidates) {
if (
isChatMentionTargetEligible({
agentId: candidate,
authorAgentId: input.authorAgentId,
storedAgents: input.storedAgents,
liveAgents: input.liveAgents,
})
) {
targets.push(candidate);
}
}
return targets;
}
function isChatMentionTargetEligible(input: {
agentId: string;
authorAgentId: string;
storedAgents: StoredAgentRecord[];
liveAgents: ManagedAgent[];
}): boolean {
if (input.agentId === input.authorAgentId) {
return false;
}
const stored = input.storedAgents.find((record) => record.id === input.agentId);
if (stored?.internal || stored?.archivedAt || stored?.lastStatus === "error") {
return false;
}
const live = input.liveAgents.find((agent) => agent.id === input.agentId);
if (live) {
return !live.internal && live.lifecycle !== "error";
}
return true;
}
@@ -1,191 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from "vitest";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import {
type ChatServiceError,
FileBackedChatService,
parseMentionAgentIds,
type PostChatMessageInput,
} from "./chat-service.js";
describe("FileBackedChatService", () => {
let paseoHome: string;
let service: FileBackedChatService;
async function sendChatMessage(input: PostChatMessageInput) {
return await service.dispatchMessage(input);
}
beforeEach(async () => {
paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-chat-service-"));
service = new FileBackedChatService({
paseoHome,
logger: pino({ level: "silent" }),
});
await service.initialize();
});
afterEach(async () => {
await rm(paseoHome, { recursive: true, force: true });
});
test("creates rooms, enforces unique names, and persists to disk", async () => {
const created = await service.createRoom({
name: "cli-features-epic",
purpose: "Coordination room",
});
await expect(
service.createRoom({
name: "CLI-FEATURES-EPIC",
}),
).rejects.toMatchObject<Partial<ChatServiceError>>({
code: "chat_room_name_taken",
});
const raw = await readFile(path.join(paseoHome, "chat", "rooms.json"), "utf8");
expect(raw).toContain("cli-features-epic");
expect(created.name).toBe("cli-features-epic");
expect(created.purpose).toBe("Coordination room");
expect(created.messageCount).toBe(0);
});
test("resolves rooms by name or ID, validates replies, and reads filtered messages", async () => {
const room = await service.createRoom({ name: "auth-refactor" });
const first = await sendChatMessage({
room: room.name,
authorAgentId: "agent-a",
body: "first message for @agent-b and @agent-c and again @agent-b",
});
await sendChatMessage({
room: room.id,
authorAgentId: "agent-b",
body: "reply",
replyToMessageId: first.id,
});
await expect(
sendChatMessage({
room: room.name,
authorAgentId: "agent-b",
body: "bad reply",
replyToMessageId: "missing",
}),
).rejects.toMatchObject<Partial<ChatServiceError>>({
code: "chat_message_not_found",
});
const all = await service.readMessages({ room: room.name, limit: 10 });
expect(all).toHaveLength(2);
expect(all[0]?.mentionAgentIds).toEqual(["agent-b", "agent-c"]);
const byAuthor = await service.readMessages({
room: room.id,
authorAgentId: "agent-b",
limit: 10,
});
expect(byAuthor).toHaveLength(1);
expect(byAuthor[0]?.body).toBe("reply");
const detail = await service.inspectRoom({ room: room.name });
expect(detail.room.messageCount).toBe(2);
expect(detail.room.lastMessageAt).toBeTruthy();
});
test("lists unique agents who have posted to a room", async () => {
const room = await service.createRoom({ name: "incident-room" });
const otherRoom = await service.createRoom({ name: "other-room" });
await sendChatMessage({
room: room.name,
authorAgentId: "agent-a",
body: "first",
});
await sendChatMessage({
room: room.name,
authorAgentId: "agent-b",
body: "second",
});
await sendChatMessage({
room: room.name,
authorAgentId: "agent-a",
body: "third",
});
await sendChatMessage({
room: otherRoom.name,
authorAgentId: "unrelated-agent",
body: "different room",
});
await expect(service.listRoomPosterAgentIds({ room: room.name })).resolves.toEqual([
"agent-a",
"agent-b",
]);
});
test("waits for new messages after a cursor and times out with an empty result", async () => {
const room = await service.createRoom({ name: "loop-status" });
const first = await sendChatMessage({
room: room.name,
authorAgentId: "agent-a",
body: "ready",
});
const waitPromise = service.waitForMessages({
room: room.name,
afterMessageId: first.id,
timeoutMs: 1000,
});
await sendChatMessage({
room: room.name,
authorAgentId: "agent-b",
body: "new work",
});
const waited = await waitPromise;
expect(waited).toHaveLength(1);
expect(waited[0]?.body).toBe("new work");
const timedOut = await service.waitForMessages({
room: room.name,
afterMessageId: waited[0]?.id,
timeoutMs: 10,
});
expect(timedOut).toEqual([]);
});
test("deletes rooms, removes messages, and rejects pending waiters", async () => {
const room = await service.createRoom({ name: "schedule-jobs" });
await sendChatMessage({
room: room.name,
authorAgentId: "agent-a",
body: "hello",
});
const waitPromise = service.waitForMessages({
room: room.name,
timeoutMs: 1000,
});
const deleted = await service.deleteRoom({ room: room.id });
expect(deleted.room.messageCount).toBe(1);
await expect(waitPromise).rejects.toMatchObject<Partial<ChatServiceError>>({
code: "chat_room_deleted",
});
await expect(service.inspectRoom({ room: room.name })).rejects.toMatchObject<
Partial<ChatServiceError>
>({
code: "chat_room_not_found",
});
});
test("extracts inline mentions from chat bodies", () => {
expect(
parseMentionAgentIds(
"Checking with @agent-a, (@agent_b), @everyone, and duplicate @agent-a again.",
),
).toEqual(["agent-a", "agent_b", "everyone"]);
expect(parseMentionAgentIds("email@example.com is not a mention")).toEqual([]);
});
});
@@ -1,465 +0,0 @@
import { randomUUID } from "node:crypto";
import { promises as fs } from "node:fs";
import path from "node:path";
import type pino from "pino";
import { z } from "zod";
import { writeJsonFileAtomic } from "../atomic-file.js";
import {
ChatMessageSchema,
ChatRoomDetailSchema,
ChatRoomSchema,
type ChatMessage,
type ChatRoom,
type ChatRoomDetail,
} from "@getpaseo/protocol/chat/types";
const ChatStorePayloadSchema = z.object({
rooms: z.array(ChatRoomSchema),
messages: z.array(ChatMessageSchema),
});
type ChatStorePayload = z.infer<typeof ChatStorePayloadSchema>;
function normalizeRoomName(name: string): string {
return name.trim().toLocaleLowerCase();
}
function trimToNull(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
const CHAT_MENTION_PATTERN = /(?:^|[\s(])@([A-Za-z0-9][A-Za-z0-9._-]*)/g;
export function parseMentionAgentIds(body: string): string[] {
const mentionAgentIds = new Set<string>();
for (const match of body.matchAll(CHAT_MENTION_PATTERN)) {
const agentId = match[1]?.trim();
if (agentId) {
mentionAgentIds.add(agentId);
}
}
return Array.from(mentionAgentIds).sort();
}
export class ChatServiceError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.name = "ChatServiceError";
this.code = code;
}
}
interface Waiter {
roomId: string;
afterMessageId: string | null;
resolve: (messages: ChatMessage[]) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout> | null;
}
export interface CreateChatRoomInput {
name: string;
purpose?: string | null;
}
export interface InspectChatRoomInput {
room: string;
}
export interface DeleteChatRoomInput {
room: string;
}
export interface PostChatMessageInput {
room: string;
authorAgentId: string;
body: string;
replyToMessageId?: string | null;
}
export interface ReadChatMessagesInput {
room: string;
limit?: number;
since?: string;
authorAgentId?: string;
}
export interface ListChatRoomPosterAgentIdsInput {
room: string;
}
export interface WaitForChatMessagesInput {
room: string;
afterMessageId?: string | null;
timeoutMs?: number;
}
export interface DeleteChatRoomResult {
room: ChatRoomDetail;
}
export interface InspectChatRoomResult {
room: ChatRoomDetail;
}
export class FileBackedChatService {
private readonly filePath: string;
private readonly logger: pino.Logger;
private loaded = false;
private readonly rooms = new Map<string, ChatRoom>();
private readonly messagesByRoomId = new Map<string, ChatMessage[]>();
private persistQueue: Promise<void> = Promise.resolve();
private readonly waitersByRoomId = new Map<string, Set<Waiter>>();
constructor(options: { paseoHome: string; logger: pino.Logger }) {
this.filePath = path.join(options.paseoHome, "chat", "rooms.json");
this.logger = options.logger.child({ component: "chat-service" });
}
async initialize(): Promise<void> {
await this.load();
}
async createRoom(input: CreateChatRoomInput): Promise<ChatRoomDetail> {
await this.load();
const name = input.name.trim();
if (name.length === 0) {
throw new ChatServiceError("invalid_chat_room_name", "Chat room name is required");
}
if (this.findRoomByName(name)) {
throw new ChatServiceError(
"chat_room_name_taken",
`Chat room already exists with name: ${name}`,
);
}
const now = new Date().toISOString();
const room = ChatRoomSchema.parse({
id: randomUUID(),
name,
purpose: trimToNull(input.purpose),
createdAt: now,
updatedAt: now,
});
this.rooms.set(room.id, room);
await this.enqueuePersist();
return this.toRoomDetail(room);
}
async listRooms(): Promise<ChatRoomDetail[]> {
await this.load();
return Array.from(this.rooms.values())
.map((room) => this.toRoomDetail(room))
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
}
async inspectRoom(input: InspectChatRoomInput): Promise<InspectChatRoomResult> {
await this.load();
const room = this.resolveRoom(input.room);
return {
room: this.toRoomDetail(room),
};
}
async deleteRoom(input: DeleteChatRoomInput): Promise<DeleteChatRoomResult> {
await this.load();
const room = this.resolveRoom(input.room);
const detail = this.toRoomDetail(room);
this.rooms.delete(room.id);
this.messagesByRoomId.delete(room.id);
await this.enqueuePersist();
this.rejectWaiters(
room.id,
new ChatServiceError("chat_room_deleted", `Chat room deleted: ${room.name}`),
);
return { room: detail };
}
async dispatchMessage(input: PostChatMessageInput): Promise<ChatMessage> {
await this.load();
const room = this.resolveRoom(input.room);
const body = input.body.trim();
if (body.length === 0) {
throw new ChatServiceError("invalid_chat_message", "Chat message body is required");
}
const authorAgentId = input.authorAgentId.trim();
if (authorAgentId.length === 0) {
throw new ChatServiceError("invalid_chat_author", "Chat message author is required");
}
const messages = this.getRoomMessages(room.id);
const replyToMessageId = trimToNull(input.replyToMessageId);
if (replyToMessageId) {
const replyTarget = messages.find((message) => message.id === replyToMessageId);
if (!replyTarget) {
throw new ChatServiceError(
"chat_message_not_found",
`Reply target not found: ${replyToMessageId}`,
);
}
}
const createdAt = new Date().toISOString();
const message = ChatMessageSchema.parse({
id: randomUUID(),
roomId: room.id,
authorAgentId,
body,
replyToMessageId,
mentionAgentIds: parseMentionAgentIds(body),
createdAt,
});
messages.push(message);
this.messagesByRoomId.set(room.id, messages);
this.rooms.set(
room.id,
ChatRoomSchema.parse({
...room,
updatedAt: createdAt,
}),
);
await this.enqueuePersist();
this.notifyWaiters(room.id);
return message;
}
async readMessages(input: ReadChatMessagesInput): Promise<ChatMessage[]> {
await this.load();
const room = this.resolveRoom(input.room);
const messages = [...this.getRoomMessages(room.id)];
const since = trimToNull(input.since);
const authorAgentId = trimToNull(input.authorAgentId);
const limit = this.normalizeLimit(input.limit);
const filtered = messages.filter((message) => {
if (since && message.createdAt < since) {
return false;
}
if (authorAgentId && message.authorAgentId !== authorAgentId) {
return false;
}
return true;
});
if (limit === 0 || filtered.length <= limit) {
return filtered;
}
return filtered.slice(filtered.length - limit);
}
async listRoomPosterAgentIds(input: ListChatRoomPosterAgentIdsInput): Promise<string[]> {
await this.load();
const room = this.resolveRoom(input.room);
const posters = new Set<string>();
for (const message of this.getRoomMessages(room.id)) {
posters.add(message.authorAgentId);
}
return Array.from(posters);
}
async waitForMessages(input: WaitForChatMessagesInput): Promise<ChatMessage[]> {
await this.load();
const room = this.resolveRoom(input.room);
const timeoutMs = Math.max(0, Math.floor(input.timeoutMs ?? 0));
const afterMessageId = trimToNull(input.afterMessageId);
if (afterMessageId) {
const existing = this.selectMessagesAfter(room.id, afterMessageId);
if (existing.length > 0) {
return existing;
}
const knownMessage = this.getRoomMessages(room.id).some(
(message) => message.id === afterMessageId,
);
if (!knownMessage) {
throw new ChatServiceError(
"chat_message_not_found",
`Wait cursor not found: ${afterMessageId}`,
);
}
}
return new Promise<ChatMessage[]>((resolve, reject) => {
const waiter: Waiter = {
roomId: room.id,
afterMessageId,
resolve: (messages) => {
if (waiter.timeout) {
clearTimeout(waiter.timeout);
waiter.timeout = null;
}
this.removeWaiter(waiter);
resolve(messages);
},
reject: (error) => {
if (waiter.timeout) {
clearTimeout(waiter.timeout);
waiter.timeout = null;
}
this.removeWaiter(waiter);
reject(error);
},
timeout: null,
};
if (timeoutMs > 0) {
waiter.timeout = setTimeout(() => {
waiter.resolve([]);
}, timeoutMs);
}
const roomWaiters = this.waitersByRoomId.get(room.id) ?? new Set<Waiter>();
roomWaiters.add(waiter);
this.waitersByRoomId.set(room.id, roomWaiters);
});
}
private async load(): Promise<void> {
if (this.loaded) {
return;
}
this.rooms.clear();
this.messagesByRoomId.clear();
try {
const raw = await fs.readFile(this.filePath, "utf8");
const parsed = ChatStorePayloadSchema.parse(JSON.parse(raw));
for (const room of parsed.rooms) {
this.rooms.set(room.id, room);
}
for (const message of parsed.messages) {
const messages = this.messagesByRoomId.get(message.roomId) ?? [];
messages.push(message);
this.messagesByRoomId.set(message.roomId, messages);
}
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT") {
this.logger.error({ err: error, filePath: this.filePath }, "Failed to load chat store");
}
}
this.loaded = true;
}
private async enqueuePersist(): Promise<void> {
const nextPersist = this.persistQueue.then(() => this.persist());
this.persistQueue = nextPersist.catch(() => {});
await nextPersist;
}
private async persist(): Promise<void> {
const payload: ChatStorePayload = {
rooms: Array.from(this.rooms.values()).sort((left, right) =>
left.createdAt.localeCompare(right.createdAt),
),
messages: Array.from(this.messagesByRoomId.values())
.flat()
.sort((left, right) => left.createdAt.localeCompare(right.createdAt)),
};
await writeJsonFileAtomic(this.filePath, payload);
}
private findRoomByName(name: string): ChatRoom | null {
const normalizedName = normalizeRoomName(name);
for (const room of this.rooms.values()) {
if (normalizeRoomName(room.name) === normalizedName) {
return room;
}
}
return null;
}
private resolveRoom(roomSelector: string): ChatRoom {
const selector = roomSelector.trim();
if (selector.length === 0) {
throw new ChatServiceError("invalid_chat_room", "Chat room name or ID is required");
}
const byId = this.rooms.get(selector);
if (byId) {
return byId;
}
const byName = this.findRoomByName(selector);
if (byName) {
return byName;
}
throw new ChatServiceError("chat_room_not_found", `Chat room not found: ${selector}`);
}
private getRoomMessages(roomId: string): ChatMessage[] {
return this.messagesByRoomId.get(roomId) ?? [];
}
private toRoomDetail(room: ChatRoom): ChatRoomDetail {
const messages = this.getRoomMessages(room.id);
return ChatRoomDetailSchema.parse({
...room,
messageCount: messages.length,
lastMessageAt: messages[messages.length - 1]?.createdAt ?? null,
});
}
private normalizeLimit(limit: number | undefined): number {
if (limit === undefined) {
return 20;
}
const normalized = Math.max(0, Math.floor(limit));
return normalized;
}
private selectMessagesAfter(roomId: string, afterMessageId: string): ChatMessage[] {
const messages = this.getRoomMessages(roomId);
const index = messages.findIndex((message) => message.id === afterMessageId);
if (index === -1) {
return [];
}
return messages.slice(index + 1);
}
private notifyWaiters(roomId: string): void {
const waiters = this.waitersByRoomId.get(roomId);
if (!waiters || waiters.size === 0) {
return;
}
for (const waiter of Array.from(waiters)) {
const messages =
waiter.afterMessageId === null
? this.getRoomMessages(roomId).slice(-1)
: this.selectMessagesAfter(roomId, waiter.afterMessageId);
if (messages.length === 0) {
continue;
}
waiter.resolve(messages);
}
}
private removeWaiter(waiter: Waiter): void {
const waiters = this.waitersByRoomId.get(waiter.roomId);
if (!waiters) {
return;
}
waiters.delete(waiter);
if (waiters.size === 0) {
this.waitersByRoomId.delete(waiter.roomId);
}
}
private rejectWaiters(roomId: string, error: Error): void {
const waiters = this.waitersByRoomId.get(roomId);
if (!waiters) {
return;
}
for (const waiter of Array.from(waiters)) {
waiter.reject(error);
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+3 -52
View File
@@ -34,9 +34,7 @@ import {
asAgentStorage,
asDownloadTokenStore,
asPushTokenStore,
asChatService,
asScheduleService,
asLoopService,
asCheckoutDiffManager,
asGitHubService,
asWorkspaceGitService,
@@ -395,9 +393,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
get: vi.fn(),
list: vi.fn().mockResolvedValue([]),
},
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager(checkoutDiffManager),
github: asGitHubService(github),
workspaceGitService: asWorkspaceGitService(workspaceGitService),
@@ -4615,57 +4611,12 @@ describe("session pull request timeline handling", () => {
});
});
describe("chat/schedule/loop dispatch routing (behavior preservation)", () => {
// Each chat/*, loop/*, and schedule/* type must reach its domain handler. The
// injected service stubs are unstubbed, so every handler's own try/catch fires
// and emits its domain rpc_error code — proving the message routed (a dropped
// case would silently no-op and emit nothing). schedule/* historically routed
// via the chat dispatcher's fall-through arm; this guards that path explicitly.
describe("schedule dispatch routing", () => {
// Each schedule/* type must reach its domain handler. The injected service stub
// is unstubbed, so every handler's own try/catch emits its domain rpc_error code.
// handleMessage receives already-parsed messages, so these fixtures only need to
// satisfy the TS union here — zod parsing happens upstream at the transport.
const routingCases: Array<{ msg: SessionInboundMessage; code: string }> = [
{
msg: { type: "chat/create", requestId: "rt-chat-create", name: "room" },
code: "chat_request_failed",
},
{ msg: { type: "chat/list", requestId: "rt-chat-list" }, code: "chat_request_failed" },
{
msg: { type: "chat/inspect", requestId: "rt-chat-inspect", room: "room" },
code: "chat_request_failed",
},
{
msg: { type: "chat/delete", requestId: "rt-chat-delete", room: "room" },
code: "chat_request_failed",
},
{
msg: { type: "chat/post", requestId: "rt-chat-post", room: "room", body: "hi" },
code: "chat_request_failed",
},
{
msg: { type: "chat/read", requestId: "rt-chat-read", room: "room" },
code: "chat_request_failed",
},
{
msg: { type: "chat/wait", requestId: "rt-chat-wait", room: "room" },
code: "chat_request_failed",
},
{
msg: { type: "loop/run", requestId: "rt-loop-run", prompt: "p", cwd: "/tmp/loop" },
code: "loop_request_failed",
},
{ msg: { type: "loop/list", requestId: "rt-loop-list" }, code: "loop_request_failed" },
{
msg: { type: "loop/inspect", requestId: "rt-loop-inspect", id: "loop-1" },
code: "loop_request_failed",
},
{
msg: { type: "loop/logs", requestId: "rt-loop-logs", id: "loop-1" },
code: "loop_request_failed",
},
{
msg: { type: "loop/stop", requestId: "rt-loop-stop", id: "loop-1" },
code: "loop_request_failed",
},
{
msg: {
type: "schedule/create",
+31 -84
View File
@@ -41,7 +41,6 @@ import {
} from "./persistence-hooks.js";
import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent/agent-loading.js";
import {
formatSystemNotificationPrompt,
sendPromptToAgent,
waitForAgentRunStartWithTimeout,
unarchiveAgentState,
@@ -159,7 +158,7 @@ import {
createAgentStructuredTextGeneration,
createGitMetadataGenerator,
} from "./session/checkout/git-metadata-generator.js";
import { ChatScheduleLoopSession } from "./session/chat/chat-schedule-loop-session.js";
import { ScheduleSession } from "./session/schedule/schedule-session.js";
import { ProviderCatalogSession } from "./session/provider/provider-catalog-session.js";
import { WorkspaceFilesSession } from "./session/files/workspace-files-session.js";
import { AgentConfigSession } from "./session/agent-config/agent-config-session.js";
@@ -204,8 +203,6 @@ import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
import type { Resolvable } from "./speech/provider-resolver.js";
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
import type pino from "pino";
import { FileBackedChatService } from "./chat/chat-service.js";
import { LoopService } from "./loop-service.js";
import { ScheduleService } from "./schedule/service.js";
import {
createGitHubService,
@@ -450,9 +447,7 @@ export interface SessionOptions {
projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry;
filesystem?: SessionFileSystem;
chatService: FileBackedChatService;
scheduleService: ScheduleService;
loopService: LoopService;
checkoutDiffManager: CheckoutDiffManager;
github?: ForgeService;
createAgentMcpTransport?: AgentMcpTransportFactory;
@@ -671,7 +666,7 @@ export class Session {
private readonly workspaceDirectory: WorkspaceDirectory;
private readonly voiceSession: VoiceSession;
private readonly checkoutSession: CheckoutSession;
private readonly chatScheduleLoopSession: ChatScheduleLoopSession;
private readonly scheduleSession: ScheduleSession;
private readonly providerCatalogSession: ProviderCatalogSession;
private readonly workspaceFilesSession: WorkspaceFilesSession;
private readonly agentConfigSession: AgentConfigSession;
@@ -704,9 +699,7 @@ export class Session {
projectRegistry,
workspaceRegistry,
filesystem,
chatService,
scheduleService,
loopService,
checkoutDiffManager,
github,
renameCurrentBranch,
@@ -832,27 +825,9 @@ export class Session {
onBranchChanged,
logger: this.sessionLogger,
});
this.chatScheduleLoopSession = new ChatScheduleLoopSession({
host: {
emit: (msg) => this.emit(msg),
listStoredAgents: () => this.agentStorage.list(),
listLiveAgents: () => this.agentManager.listAgents(),
resolveAgentIdentifier: (identifier) => this.resolveAgentIdentifier(identifier),
sendAgentMessage: async (agentId, text) => {
await sendPromptToAgent({
agentManager: this.agentManager,
agentStorage: this.agentStorage,
agentId,
prompt: formatSystemNotificationPrompt(text),
unarchive: false,
logger: this.sessionLogger,
});
},
},
chatService,
this.scheduleSession = new ScheduleSession({
host: { emit: (msg) => this.emit(msg) },
scheduleService,
loopService,
clientId: this.clientId,
logger: this.sessionLogger,
});
this.providerCatalogSession = new ProviderCatalogSession({
@@ -1856,7 +1831,7 @@ export class Session {
this.dispatchWorkspaceFileMessage(msg, source) ??
this.dispatchProviderMessage(msg) ??
this.dispatchTerminalMessage(msg) ??
this.dispatchChatScheduleLoopMessage(msg) ??
this.dispatchScheduleMessage(msg) ??
this.dispatchMiscMessage(msg);
if (promise) await promise;
}
@@ -2269,51 +2244,26 @@ export class Session {
}
}
// eslint-disable-next-line complexity
private dispatchChatScheduleLoopMessage(msg: SessionInboundMessage): Promise<void> | undefined {
private dispatchScheduleMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "chat/create":
return this.chatScheduleLoopSession.handleChatCreateRequest(msg);
case "chat/list":
return this.chatScheduleLoopSession.handleChatListRequest(msg);
case "chat/inspect":
return this.chatScheduleLoopSession.handleChatInspectRequest(msg);
case "chat/delete":
return this.chatScheduleLoopSession.handleChatDeleteRequest(msg);
case "chat/post":
return this.chatScheduleLoopSession.handleChatPostRequest(msg);
case "chat/read":
return this.chatScheduleLoopSession.handleChatReadRequest(msg);
case "chat/wait":
return this.chatScheduleLoopSession.handleChatWaitRequest(msg);
case "loop/run":
return this.chatScheduleLoopSession.handleLoopRunRequest(msg);
case "loop/list":
return this.chatScheduleLoopSession.handleLoopListRequest(msg);
case "loop/inspect":
return this.chatScheduleLoopSession.handleLoopInspectRequest(msg);
case "loop/logs":
return this.chatScheduleLoopSession.handleLoopLogsRequest(msg);
case "loop/stop":
return this.chatScheduleLoopSession.handleLoopStopRequest(msg);
case "schedule/create":
return this.chatScheduleLoopSession.handleScheduleCreateRequest(msg);
return this.scheduleSession.handleScheduleCreateRequest(msg);
case "schedule/list":
return this.chatScheduleLoopSession.handleScheduleListRequest(msg);
return this.scheduleSession.handleScheduleListRequest(msg);
case "schedule/inspect":
return this.chatScheduleLoopSession.handleScheduleInspectRequest(msg);
return this.scheduleSession.handleScheduleInspectRequest(msg);
case "schedule/logs":
return this.chatScheduleLoopSession.handleScheduleLogsRequest(msg);
return this.scheduleSession.handleScheduleLogsRequest(msg);
case "schedule/pause":
return this.chatScheduleLoopSession.handleSchedulePauseRequest(msg);
return this.scheduleSession.handleSchedulePauseRequest(msg);
case "schedule/resume":
return this.chatScheduleLoopSession.handleScheduleResumeRequest(msg);
return this.scheduleSession.handleScheduleResumeRequest(msg);
case "schedule/delete":
return this.chatScheduleLoopSession.handleScheduleDeleteRequest(msg);
return this.scheduleSession.handleScheduleDeleteRequest(msg);
case "schedule/run-once":
return this.chatScheduleLoopSession.handleScheduleRunOnceRequest(msg);
return this.scheduleSession.handleScheduleRunOnceRequest(msg);
case "schedule/update":
return this.chatScheduleLoopSession.handleScheduleUpdateRequest(msg);
return this.scheduleSession.handleScheduleUpdateRequest(msg);
default:
return undefined;
}
@@ -2584,25 +2534,22 @@ export class Session {
didUnarchive: boolean;
originalArchivedAt: string | null;
} | null> {
const records = await this.agentStorage.list();
const matched = records
.filter(
(record) =>
record.persistence?.provider === handle.provider &&
record.persistence?.sessionId === handle.sessionId,
)
.reduce<StoredAgentRecord | null>((latest, candidate) => {
if (!latest) {
return candidate;
}
const updatedDelta =
Date.parse(resolveStoredAgentPayloadUpdatedAt(candidate)) -
Date.parse(resolveStoredAgentPayloadUpdatedAt(latest));
if (updatedDelta !== 0) {
return updatedDelta > 0 ? candidate : latest;
}
return Date.parse(candidate.createdAt) > Date.parse(latest.createdAt) ? candidate : latest;
}, null);
const records = await this.agentStorage.listByProviderSession(
handle.provider,
handle.sessionId,
);
const matched = records.reduce<StoredAgentRecord | null>((latest, candidate) => {
if (!latest) {
return candidate;
}
const updatedDelta =
Date.parse(resolveStoredAgentPayloadUpdatedAt(candidate)) -
Date.parse(resolveStoredAgentPayloadUpdatedAt(latest));
if (updatedDelta !== 0) {
return updatedDelta > 0 ? candidate : latest;
}
return Date.parse(candidate.createdAt) > Date.parse(latest.createdAt) ? candidate : latest;
}, null);
if (!matched) {
return null;
}
@@ -159,9 +159,7 @@ function createHarness(input: {
},
}),
filesystem: { isDirectory: async () => true },
chatService: createStub<SessionOptions["chatService"]>({}),
scheduleService: createStub<SessionOptions["scheduleService"]>({}),
loopService: createStub<SessionOptions["loopService"]>({}),
checkoutDiffManager: createStub<SessionOptions["checkoutDiffManager"]>({
subscribe: async () => ({
initial: { cwd: "/tmp", files: [], error: null },
@@ -54,9 +54,7 @@ import {
asAgentStorage,
asDownloadTokenStore,
asPushTokenStore,
asChatService,
asScheduleService,
asLoopService,
asCheckoutDiffManager,
asDaemonConfigStore,
asTerminalManager,
@@ -692,9 +690,7 @@ function createSessionForWorkspaceTests(
},
workspaceRegistry,
filesystem: { isDirectory: async () => true },
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({
initial: { cwd: "/tmp", files: [], error: null },
@@ -956,9 +952,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
agentStorage,
projectRegistry,
workspaceRegistry,
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({
initial: { cwd: child, files: [], error: null },
@@ -1110,9 +1104,7 @@ test("create_agent_request launches from an exact subdirectory in a created work
agentStorage,
projectRegistry,
workspaceRegistry,
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({
initial: { cwd: child, files: [], error: null },
@@ -1249,9 +1241,7 @@ test("create_agent_request does not title an existing workspace from the agent p
agentStorage,
projectRegistry,
workspaceRegistry,
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({
initial: { cwd, files: [], error: null },
@@ -1580,9 +1570,7 @@ test("archive emits an authoritative agent_update upsert for subscribed clients"
remove: async () => {},
};
})(),
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({
initial: { cwd: REPO_CWD, files: [], error: null },
@@ -1942,9 +1930,7 @@ test("close_items_request archives agents and kills terminals in one batch", asy
remove: async () => {},
};
})(),
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({
initial: { cwd: "/tmp", files: [], error: null },
@@ -2129,9 +2115,7 @@ test("close_items_request archives stored agents that are not currently loaded",
remove: async () => {},
};
})(),
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({
initial: { cwd: "/tmp", files: [], error: null },
@@ -2278,9 +2262,7 @@ test("close_items_request continues after an archive failure", async () => {
remove: async () => {},
};
})(),
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({
initial: { cwd: "/tmp", files: [], error: null },
@@ -3524,9 +3506,7 @@ test("workspace update stream keeps persisted workspace visible after agents sto
archive: async () => {},
remove: async () => {},
},
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({
initial: { cwd: "/tmp", files: [], error: null },
@@ -1,228 +0,0 @@
import { describe, expect, it } from "vitest";
import pino from "pino";
import {
ChatScheduleLoopSession,
type ChatScheduleLoopSessionHost,
} from "./chat-schedule-loop-session.js";
import { createStub } from "../../test-utils/class-mocks.js";
import { findByType } from "../../test-utils/session-stubs.js";
import type { SessionOutboundMessage } from "../../messages.js";
import type { FileBackedChatService } from "../../chat/chat-service.js";
import type { ScheduleService } from "../../schedule/service.js";
import type { LoopService } from "../../loop-service.js";
type ChatMessageFixture = Awaited<ReturnType<FileBackedChatService["dispatchMessage"]>>;
interface MakeOptions {
chat?: { [K in keyof FileBackedChatService]?: unknown };
schedule?: { [K in keyof ScheduleService]?: unknown };
loop?: { [K in keyof LoopService]?: unknown };
host?: Partial<ChatScheduleLoopSessionHost>;
}
function makeSubsystem(options: MakeOptions = {}) {
const emitted: SessionOutboundMessage[] = [];
const sentAgentMessages: Array<{ agentId: string; text: string }> = [];
let onSend: (() => void) | null = null;
const host: ChatScheduleLoopSessionHost = {
emit: (msg) => emitted.push(msg),
listStoredAgents: async () => [],
listLiveAgents: () => [],
resolveAgentIdentifier: async (identifier) => ({ ok: true, agentId: identifier }),
sendAgentMessage: async (agentId, text) => {
sentAgentMessages.push({ agentId, text });
onSend?.();
},
...options.host,
};
const subsystem = new ChatScheduleLoopSession({
host,
chatService: createStub<FileBackedChatService>(options.chat ?? {}),
scheduleService: createStub<ScheduleService>(options.schedule ?? {}),
loopService: createStub<LoopService>(options.loop ?? {}),
clientId: "client-1",
logger: pino({ level: "silent" }),
});
// notifyChatMentions is fire-and-forget; arm the signal before dispatching so the
// mentioned-agent send is observed deterministically without polling.
function waitForSend(): Promise<void> {
return new Promise((resolve) => {
onSend = resolve;
});
}
return { subsystem, emitted, sentAgentMessages, waitForSend };
}
describe("ChatScheduleLoopSession", () => {
it("chat/post emits the stored message and does not fan out without mentions", async () => {
const message: ChatMessageFixture = {
id: "m1",
roomId: "r1",
authorAgentId: "client-1",
body: "hello",
replyToMessageId: null,
mentionAgentIds: [],
createdAt: "2026-01-01T00:00:00.000Z",
};
const { subsystem, emitted, sentAgentMessages } = makeSubsystem({
chat: { dispatchMessage: async () => message, listRoomPosterAgentIds: async () => [] },
});
await subsystem.handleChatPostRequest({
type: "chat/post",
requestId: "p1",
room: "r1",
body: "hello",
});
const res = findByType(emitted, "chat/post/response");
expect(res?.payload.message).toEqual(message);
expect(res?.payload.error).toBeNull();
expect(sentAgentMessages).toEqual([]);
});
it("chat/post notifies a mentioned agent through the host send seam", async () => {
const message: ChatMessageFixture = {
id: "m2",
roomId: "r1",
authorAgentId: "client-1",
body: "@agent-2 ping",
replyToMessageId: null,
mentionAgentIds: ["agent-2"],
createdAt: "2026-01-01T00:00:00.000Z",
};
const { subsystem, emitted, sentAgentMessages, waitForSend } = makeSubsystem({
chat: { dispatchMessage: async () => message, listRoomPosterAgentIds: async () => [] },
});
const sent = waitForSend();
await subsystem.handleChatPostRequest({
type: "chat/post",
requestId: "p2",
room: "r1",
body: "@agent-2 ping",
});
await sent;
expect(findByType(emitted, "chat/post/response")?.payload.error).toBeNull();
expect(sentAgentMessages).toHaveLength(1);
expect(sentAgentMessages[0]?.agentId).toBe("agent-2");
expect(sentAgentMessages[0]?.text).toContain('in room "r1"');
});
it("chat/post rejects @everyone past the fanout limit with the chat error code", async () => {
const posters = Array.from({ length: 26 }, (_, i) => `poster-${i}`);
const { subsystem, emitted } = makeSubsystem({
chat: { listRoomPosterAgentIds: async () => posters },
});
await subsystem.handleChatPostRequest({
type: "chat/post",
requestId: "p3",
room: "r1",
body: "@everyone go",
});
const err = findByType(emitted, "rpc_error");
expect(err?.payload.code).toBe("chat_mention_fanout_limit_exceeded");
expect(err?.payload.requestId).toBe("p3");
});
it("schedule/create returns a summary with the runs stripped", async () => {
const stored = {
id: "s1",
name: null,
prompt: "p",
cadence: { type: "every" as const, everyMs: 1000 },
target: { type: "agent" as const, agentId: "a" },
status: "active" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
nextRunAt: null,
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
runs: [
{
id: "run-1",
scheduledFor: "2026-01-01T00:00:00.000Z",
startedAt: "2026-01-01T00:00:00.000Z",
endedAt: null,
status: "running" as const,
agentId: null,
output: null,
error: null,
},
],
};
const { subsystem, emitted } = makeSubsystem({ schedule: { create: async () => stored } });
await subsystem.handleScheduleCreateRequest({
type: "schedule/create",
requestId: "sc1",
prompt: "p",
cadence: { type: "every", everyMs: 1000 },
target: { type: "agent", agentId: "a" },
});
const res = findByType(emitted, "schedule/create/response");
expect(res?.payload.schedule).toBeDefined();
expect(res?.payload.schedule).not.toHaveProperty("runs");
expect(res?.payload.schedule.id).toBe("s1");
});
it("schedule/create remaps a self target to an agent target before creating", async () => {
let received: Parameters<ScheduleService["create"]>[0] | undefined;
const stored = {
id: "s2",
name: null,
prompt: "p",
cadence: { type: "every" as const, everyMs: 1000 },
target: { type: "agent" as const, agentId: "agent-9" },
status: "active" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
nextRunAt: null,
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
runs: [],
};
const { subsystem, emitted } = makeSubsystem({
schedule: {
create: async (input: Parameters<ScheduleService["create"]>[0]) => {
received = input;
return stored;
},
},
});
await subsystem.handleScheduleCreateRequest({
type: "schedule/create",
requestId: "sc2",
prompt: "p",
cadence: { type: "every", everyMs: 1000 },
target: { type: "self", agentId: "agent-9" },
});
expect(received?.target).toEqual({ type: "agent", agentId: "agent-9" });
expect(findByType(emitted, "schedule/create/response")?.payload.error).toBeNull();
});
it("loop/run emits the loop summary from the loop service", async () => {
const loop = { id: "loop-1", status: "running" };
const { subsystem, emitted } = makeSubsystem({ loop: { runLoop: async () => loop } });
await subsystem.handleLoopRunRequest({
type: "loop/run",
requestId: "l1",
prompt: "p",
cwd: "/tmp/loop",
});
const res = findByType(emitted, "loop/run/response");
expect(res?.payload.loop).toEqual(loop);
});
});
@@ -1,609 +0,0 @@
import type pino from "pino";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
import type { StoredAgentRecord } from "../../agent/agent-storage.js";
import type { ManagedAgent } from "../../agent/agent-manager.js";
import {
ChatServiceError,
type FileBackedChatService,
parseMentionAgentIds,
} from "../../chat/chat-service.js";
import { notifyChatMentions, prepareChatMentionFanout } from "../../chat/chat-mentions.js";
import type { LoopService } from "../../loop-service.js";
import type { ScheduleService } from "../../schedule/service.js";
/**
* The collaborators a chat command reaches that are NOT part of the chat/schedule/loop
* domain: the agent roster reads and the agent-message send used only by chat/post
* mention fanout. The Session shell owns the agent lifecycle; this subsystem orchestrates
* a notification through it but does not own it.
*/
export interface ChatScheduleLoopSessionHost {
emit(msg: SessionOutboundMessage): void;
listStoredAgents(): Promise<StoredAgentRecord[]>;
listLiveAgents(): ManagedAgent[];
resolveAgentIdentifier(
identifier: string,
): Promise<{ ok: true; agentId: string } | { ok: false; error: string }>;
sendAgentMessage(agentId: string, text: string): Promise<void>;
}
export interface ChatScheduleLoopSessionOptions {
host: ChatScheduleLoopSessionHost;
chatService: FileBackedChatService;
scheduleService: ScheduleService;
loopService: LoopService;
clientId: string;
logger: pino.Logger;
}
/**
* A client's chat, schedule, and loop request surface. The three families are the
* least-coupled in the session: each is a stateless request/response over its own
* service (chat rooms, cron routines, autonomous loops), with no shared observer,
* git, or voice state and no subscriptions to tear down. They live in one subsystem
* because they are dispatched together — schedule/* was historically reached through
* the chat dispatcher's fall-through arm. The three rpc-error emitters stay separate:
* they differ by default code, and only the chat one reads ChatServiceError.code.
*/
export class ChatScheduleLoopSession {
private readonly host: ChatScheduleLoopSessionHost;
private readonly chatService: FileBackedChatService;
private readonly scheduleService: ScheduleService;
private readonly loopService: LoopService;
private readonly clientId: string;
private readonly logger: pino.Logger;
constructor(options: ChatScheduleLoopSessionOptions) {
this.host = options.host;
this.chatService = options.chatService;
this.scheduleService = options.scheduleService;
this.loopService = options.loopService;
this.clientId = options.clientId;
this.logger = options.logger;
}
private emitChatRpcError(request: { requestId: string; type: string }, error: unknown): void {
const message = error instanceof Error ? error.message : "Chat request failed";
const code = error instanceof ChatServiceError ? error.code : "chat_request_failed";
this.logger.error({ err: error, requestType: request.type }, "Chat request failed");
this.host.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code,
},
});
}
async handleChatCreateRequest(
request: Extract<SessionInboundMessage, { type: "chat/create" }>,
): Promise<void> {
try {
const room = await this.chatService.createRoom({
name: request.name,
purpose: request.purpose,
});
this.host.emit({
type: "chat/create/response",
payload: {
requestId: request.requestId,
room,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
async handleChatListRequest(
request: Extract<SessionInboundMessage, { type: "chat/list" }>,
): Promise<void> {
try {
const rooms = await this.chatService.listRooms();
this.host.emit({
type: "chat/list/response",
payload: {
requestId: request.requestId,
rooms,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
async handleChatInspectRequest(
request: Extract<SessionInboundMessage, { type: "chat/inspect" }>,
): Promise<void> {
try {
const result = await this.chatService.inspectRoom({
room: request.room,
});
this.host.emit({
type: "chat/inspect/response",
payload: {
requestId: request.requestId,
room: result.room,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
async handleChatDeleteRequest(
request: Extract<SessionInboundMessage, { type: "chat/delete" }>,
): Promise<void> {
try {
const result = await this.chatService.deleteRoom({
room: request.room,
});
this.host.emit({
type: "chat/delete/response",
payload: {
requestId: request.requestId,
room: result.room,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
async handleChatPostRequest(
request: Extract<SessionInboundMessage, { type: "chat/post" }>,
): Promise<void> {
try {
const authorAgentId = request.authorAgentId?.trim() || this.clientId;
const mentionAgentIds = parseMentionAgentIds(request.body);
const storedAgents = await this.host.listStoredAgents();
const liveAgents = this.host.listLiveAgents();
const fanout = await prepareChatMentionFanout({
authorAgentId,
mentionAgentIds,
storedAgents,
liveAgents,
listRoomPosterAgentIds: () =>
this.chatService.listRoomPosterAgentIds({ room: request.room }),
});
if (!fanout.ok) {
throw new ChatServiceError("chat_mention_fanout_limit_exceeded", fanout.error);
}
const message = await this.chatService.dispatchMessage({
room: request.room,
authorAgentId,
body: request.body,
replyToMessageId: request.replyToMessageId,
});
this.host.emit({
type: "chat/post/response",
payload: {
requestId: request.requestId,
message,
error: null,
},
});
void notifyChatMentions({
room: request.room,
authorAgentId,
body: request.body,
mentionAgentIds: message.mentionAgentIds,
logger: this.logger,
storedAgents,
liveAgents,
prepared: fanout.prepared,
resolveAgentIdentifier: (identifier) => this.host.resolveAgentIdentifier(identifier),
sendAgentMessage: (agentId, text) => this.host.sendAgentMessage(agentId, text),
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
async handleChatReadRequest(
request: Extract<SessionInboundMessage, { type: "chat/read" }>,
): Promise<void> {
try {
const messages = await this.chatService.readMessages({
room: request.room,
limit: request.limit,
since: request.since,
authorAgentId: request.authorAgentId,
});
this.host.emit({
type: "chat/read/response",
payload: {
requestId: request.requestId,
messages,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
async handleChatWaitRequest(
request: Extract<SessionInboundMessage, { type: "chat/wait" }>,
): Promise<void> {
try {
const messages = await this.chatService.waitForMessages({
room: request.room,
afterMessageId: request.afterMessageId,
timeoutMs: request.timeoutMs,
});
this.host.emit({
type: "chat/wait/response",
payload: {
requestId: request.requestId,
messages,
timedOut: messages.length === 0,
error: null,
},
});
} catch (error) {
this.emitChatRpcError(request, error);
}
}
private toScheduleSummary(
schedule: Awaited<ReturnType<ScheduleService["inspect"]>>,
): Extract<
SessionOutboundMessage,
{ type: "schedule/list/response" }
>["payload"]["schedules"][number] {
const { runs: _runs, ...summary } = schedule;
return summary;
}
private emitScheduleRpcError(
request: Extract<
SessionInboundMessage,
{
type:
| "schedule/create"
| "schedule/list"
| "schedule/inspect"
| "schedule/logs"
| "schedule/pause"
| "schedule/resume"
| "schedule/delete"
| "schedule/run-once"
| "schedule/update";
}
>,
error: unknown,
): void {
const message = error instanceof Error ? error.message : String(error);
this.logger.error({ err: error, requestType: request.type }, "Schedule request failed");
this.host.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code: "schedule_request_failed",
},
});
}
async handleScheduleCreateRequest(
request: Extract<SessionInboundMessage, { type: "schedule/create" }>,
): Promise<void> {
try {
const target =
request.target.type === "self"
? { type: "agent" as const, agentId: request.target.agentId }
: request.target;
const schedule = await this.scheduleService.create({
prompt: request.prompt,
name: request.name,
cadence: request.cadence,
target,
maxRuns: request.maxRuns,
expiresAt: request.expiresAt,
runOnCreate: request.runOnCreate,
});
this.host.emit({
type: "schedule/create/response",
payload: {
requestId: request.requestId,
schedule: this.toScheduleSummary(schedule),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleListRequest(
request: Extract<SessionInboundMessage, { type: "schedule/list" }>,
): Promise<void> {
try {
const schedules = await this.scheduleService.list();
this.host.emit({
type: "schedule/list/response",
payload: {
requestId: request.requestId,
schedules: schedules.map((schedule) => this.toScheduleSummary(schedule)),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleInspectRequest(
request: Extract<SessionInboundMessage, { type: "schedule/inspect" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.inspect(request.scheduleId);
this.host.emit({
type: "schedule/inspect/response",
payload: {
requestId: request.requestId,
schedule,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleLogsRequest(
request: Extract<SessionInboundMessage, { type: "schedule/logs" }>,
): Promise<void> {
try {
const runs = await this.scheduleService.logs(request.scheduleId);
this.host.emit({
type: "schedule/logs/response",
payload: {
requestId: request.requestId,
runs,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleSchedulePauseRequest(
request: Extract<SessionInboundMessage, { type: "schedule/pause" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.pause(request.scheduleId);
this.host.emit({
type: "schedule/pause/response",
payload: {
requestId: request.requestId,
schedule: this.toScheduleSummary(schedule),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleResumeRequest(
request: Extract<SessionInboundMessage, { type: "schedule/resume" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.resume(request.scheduleId);
this.host.emit({
type: "schedule/resume/response",
payload: {
requestId: request.requestId,
schedule: this.toScheduleSummary(schedule),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleDeleteRequest(
request: Extract<SessionInboundMessage, { type: "schedule/delete" }>,
): Promise<void> {
try {
await this.scheduleService.delete(request.scheduleId);
this.host.emit({
type: "schedule/delete/response",
payload: {
requestId: request.requestId,
scheduleId: request.scheduleId,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleRunOnceRequest(
request: Extract<SessionInboundMessage, { type: "schedule/run-once" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.runOnce(request.scheduleId);
this.host.emit({
type: "schedule/run-once/response",
payload: {
requestId: request.requestId,
schedule,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleUpdateRequest(
request: Extract<SessionInboundMessage, { type: "schedule/update" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.update({
id: request.scheduleId,
...(request.name !== undefined ? { name: request.name } : {}),
...(request.prompt !== undefined ? { prompt: request.prompt } : {}),
...(request.cadence !== undefined ? { cadence: request.cadence } : {}),
...(request.newAgentConfig !== undefined ? { newAgentConfig: request.newAgentConfig } : {}),
...(request.maxRuns !== undefined ? { maxRuns: request.maxRuns } : {}),
...(request.expiresAt !== undefined ? { expiresAt: request.expiresAt } : {}),
});
this.host.emit({
type: "schedule/update/response",
payload: {
requestId: request.requestId,
schedule,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
private emitLoopRpcError(
request: Extract<
SessionInboundMessage,
{
type: "loop/run" | "loop/list" | "loop/inspect" | "loop/logs" | "loop/stop";
}
>,
error: unknown,
): void {
const message = error instanceof Error ? error.message : String(error);
this.logger.error({ err: error, requestType: request.type }, "Loop request failed");
this.host.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code: "loop_request_failed",
},
});
}
async handleLoopRunRequest(
request: Extract<SessionInboundMessage, { type: "loop/run" }>,
): Promise<void> {
try {
const loop = await this.loopService.runLoop({
prompt: request.prompt,
cwd: request.cwd,
provider: request.provider,
model: request.model,
modeId: request.modeId,
workerProvider: request.workerProvider,
workerModel: request.workerModel,
verifierProvider: request.verifierProvider,
verifierModel: request.verifierModel,
verifierModeId: request.verifierModeId,
verifyPrompt: request.verifyPrompt,
verifyChecks: request.verifyChecks,
archive: request.archive,
name: request.name,
sleepMs: request.sleepMs,
maxIterations: request.maxIterations,
maxTimeMs: request.maxTimeMs,
});
this.host.emit({
type: "loop/run/response",
payload: {
requestId: request.requestId,
loop,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
async handleLoopListRequest(
request: Extract<SessionInboundMessage, { type: "loop/list" }>,
): Promise<void> {
try {
const loops = await this.loopService.listLoops();
this.host.emit({
type: "loop/list/response",
payload: {
requestId: request.requestId,
loops,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
async handleLoopInspectRequest(
request: Extract<SessionInboundMessage, { type: "loop/inspect" }>,
): Promise<void> {
try {
const loop = await this.loopService.inspectLoop(request.id);
this.host.emit({
type: "loop/inspect/response",
payload: {
requestId: request.requestId,
loop,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
async handleLoopLogsRequest(
request: Extract<SessionInboundMessage, { type: "loop/logs" }>,
): Promise<void> {
try {
const result = await this.loopService.getLoopLogs(request.id, request.afterSeq ?? 0);
this.host.emit({
type: "loop/logs/response",
payload: {
requestId: request.requestId,
loop: result.loop,
entries: result.entries,
nextCursor: result.nextCursor,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
async handleLoopStopRequest(
request: Extract<SessionInboundMessage, { type: "loop/stop" }>,
): Promise<void> {
try {
const loop = await this.loopService.stopLoop(request.id);
this.host.emit({
type: "loop/stop/response",
payload: {
requestId: request.requestId,
loop,
error: null,
},
});
} catch (error) {
this.emitLoopRpcError(request, error);
}
}
}
@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import pino from "pino";
import { ScheduleSession } from "./schedule-session.js";
import { createStub } from "../../test-utils/class-mocks.js";
import { findByType } from "../../test-utils/session-stubs.js";
import type { SessionOutboundMessage } from "../../messages.js";
import type { ScheduleService } from "../../schedule/service.js";
function makeSession(schedule: { [K in keyof ScheduleService]?: unknown }) {
const emitted: SessionOutboundMessage[] = [];
const session = new ScheduleSession({
host: { emit: (message) => emitted.push(message) },
scheduleService: createStub<ScheduleService>(schedule),
logger: pino({ level: "silent" }),
});
return { session, emitted };
}
describe("ScheduleSession", () => {
it("schedule/create returns a summary with the runs stripped", async () => {
const stored = {
id: "s1",
name: null,
prompt: "p",
cadence: { type: "every" as const, everyMs: 1000 },
target: { type: "agent" as const, agentId: "a" },
status: "active" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
nextRunAt: null,
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
runs: [
{
id: "run-1",
scheduledFor: "2026-01-01T00:00:00.000Z",
startedAt: "2026-01-01T00:00:00.000Z",
endedAt: null,
status: "running" as const,
agentId: null,
output: null,
error: null,
},
],
};
const { session, emitted } = makeSession({ create: async () => stored });
await session.handleScheduleCreateRequest({
type: "schedule/create",
requestId: "sc1",
prompt: "p",
cadence: { type: "every", everyMs: 1000 },
target: { type: "agent", agentId: "a" },
});
const response = findByType(emitted, "schedule/create/response");
expect(response?.payload.schedule).toBeDefined();
expect(response?.payload.schedule).not.toHaveProperty("runs");
expect(response?.payload.schedule.id).toBe("s1");
});
it("schedule/create remaps a self target to an agent target before creating", async () => {
let received: Parameters<ScheduleService["create"]>[0] | undefined;
const stored = {
id: "s2",
name: null,
prompt: "p",
cadence: { type: "every" as const, everyMs: 1000 },
target: { type: "agent" as const, agentId: "agent-9" },
status: "active" as const,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
nextRunAt: null,
lastRunAt: null,
pausedAt: null,
expiresAt: null,
maxRuns: null,
runs: [],
};
const { session, emitted } = makeSession({
create: async (input: Parameters<ScheduleService["create"]>[0]) => {
received = input;
return stored;
},
});
await session.handleScheduleCreateRequest({
type: "schedule/create",
requestId: "sc2",
prompt: "p",
cadence: { type: "every", everyMs: 1000 },
target: { type: "self", agentId: "agent-9" },
});
expect(received?.target).toEqual({ type: "agent", agentId: "agent-9" });
expect(findByType(emitted, "schedule/create/response")?.payload.error).toBeNull();
});
});
@@ -0,0 +1,248 @@
import type pino from "pino";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
import type { ScheduleService } from "../../schedule/service.js";
export interface ScheduleSessionHost {
emit(msg: SessionOutboundMessage): void;
}
export interface ScheduleSessionOptions {
host: ScheduleSessionHost;
scheduleService: ScheduleService;
logger: pino.Logger;
}
export class ScheduleSession {
private readonly host: ScheduleSessionHost;
private readonly scheduleService: ScheduleService;
private readonly logger: pino.Logger;
constructor(options: ScheduleSessionOptions) {
this.host = options.host;
this.scheduleService = options.scheduleService;
this.logger = options.logger;
}
private toScheduleSummary(
schedule: Awaited<ReturnType<ScheduleService["inspect"]>>,
): Extract<
SessionOutboundMessage,
{ type: "schedule/list/response" }
>["payload"]["schedules"][number] {
const { runs: _runs, ...summary } = schedule;
return summary;
}
private emitScheduleRpcError(
request: Extract<
SessionInboundMessage,
{
type:
| "schedule/create"
| "schedule/list"
| "schedule/inspect"
| "schedule/logs"
| "schedule/pause"
| "schedule/resume"
| "schedule/delete"
| "schedule/run-once"
| "schedule/update";
}
>,
error: unknown,
): void {
const message = error instanceof Error ? error.message : String(error);
this.logger.error({ err: error, requestType: request.type }, "Schedule request failed");
this.host.emit({
type: "rpc_error",
payload: {
requestId: request.requestId,
requestType: request.type,
error: message,
code: "schedule_request_failed",
},
});
}
async handleScheduleCreateRequest(
request: Extract<SessionInboundMessage, { type: "schedule/create" }>,
): Promise<void> {
try {
const target =
request.target.type === "self"
? { type: "agent" as const, agentId: request.target.agentId }
: request.target;
const schedule = await this.scheduleService.create({
prompt: request.prompt,
name: request.name,
cadence: request.cadence,
target,
maxRuns: request.maxRuns,
expiresAt: request.expiresAt,
runOnCreate: request.runOnCreate,
});
this.host.emit({
type: "schedule/create/response",
payload: {
requestId: request.requestId,
schedule: this.toScheduleSummary(schedule),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleListRequest(
request: Extract<SessionInboundMessage, { type: "schedule/list" }>,
): Promise<void> {
try {
const schedules = await this.scheduleService.list();
this.host.emit({
type: "schedule/list/response",
payload: {
requestId: request.requestId,
schedules: schedules.map((schedule) => this.toScheduleSummary(schedule)),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleInspectRequest(
request: Extract<SessionInboundMessage, { type: "schedule/inspect" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.inspect(request.scheduleId);
this.host.emit({
type: "schedule/inspect/response",
payload: {
requestId: request.requestId,
schedule,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleLogsRequest(
request: Extract<SessionInboundMessage, { type: "schedule/logs" }>,
): Promise<void> {
try {
const runs = await this.scheduleService.logs(request.scheduleId);
this.host.emit({
type: "schedule/logs/response",
payload: {
requestId: request.requestId,
runs,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleSchedulePauseRequest(
request: Extract<SessionInboundMessage, { type: "schedule/pause" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.pause(request.scheduleId);
this.host.emit({
type: "schedule/pause/response",
payload: {
requestId: request.requestId,
schedule: this.toScheduleSummary(schedule),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleResumeRequest(
request: Extract<SessionInboundMessage, { type: "schedule/resume" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.resume(request.scheduleId);
this.host.emit({
type: "schedule/resume/response",
payload: {
requestId: request.requestId,
schedule: this.toScheduleSummary(schedule),
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleDeleteRequest(
request: Extract<SessionInboundMessage, { type: "schedule/delete" }>,
): Promise<void> {
try {
await this.scheduleService.delete(request.scheduleId);
this.host.emit({
type: "schedule/delete/response",
payload: {
requestId: request.requestId,
scheduleId: request.scheduleId,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleRunOnceRequest(
request: Extract<SessionInboundMessage, { type: "schedule/run-once" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.runOnce(request.scheduleId);
this.host.emit({
type: "schedule/run-once/response",
payload: {
requestId: request.requestId,
schedule,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
async handleScheduleUpdateRequest(
request: Extract<SessionInboundMessage, { type: "schedule/update" }>,
): Promise<void> {
try {
const schedule = await this.scheduleService.update({
id: request.scheduleId,
...(request.name !== undefined ? { name: request.name } : {}),
...(request.prompt !== undefined ? { prompt: request.prompt } : {}),
...(request.cadence !== undefined ? { cadence: request.cadence } : {}),
...(request.newAgentConfig !== undefined ? { newAgentConfig: request.newAgentConfig } : {}),
...(request.maxRuns !== undefined ? { maxRuns: request.maxRuns } : {}),
...(request.expiresAt !== undefined ? { expiresAt: request.expiresAt } : {}),
});
this.host.emit({
type: "schedule/update/response",
payload: {
requestId: request.requestId,
schedule,
error: null,
},
});
} catch (error) {
this.emitScheduleRpcError(request, error);
}
}
}
@@ -42,7 +42,11 @@ export function asAgentManager(stub: {
export function asAgentStorage(stub: {
[K in keyof SessionOptions["agentStorage"]]?: unknown;
}): SessionOptions["agentStorage"] {
return createStub<SessionOptions["agentStorage"]>(stub);
return createStub<SessionOptions["agentStorage"]>({
listByProviderSession: async () => [],
listByWorkspace: async () => [],
...stub,
});
}
export function asDownloadTokenStore(): SessionOptions["downloadTokenStore"] {
@@ -53,18 +57,10 @@ export function asPushTokenStore(): SessionOptions["pushTokenStore"] {
return createStub<SessionOptions["pushTokenStore"]>({});
}
export function asChatService(): SessionOptions["chatService"] {
return createStub<SessionOptions["chatService"]>({});
}
export function asScheduleService(): SessionOptions["scheduleService"] {
return createStub<SessionOptions["scheduleService"]>({});
}
export function asLoopService(): SessionOptions["loopService"] {
return createStub<SessionOptions["loopService"]>({});
}
export function asCheckoutDiffManager(stub: {
[K in keyof SessionOptions["checkoutDiffManager"]]?: unknown;
}): SessionOptions["checkoutDiffManager"] {
@@ -15,10 +15,8 @@ import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import { BrowserToolsBroker } from "./browser-tools/broker.js";
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
import type { FileBackedChatService } from "./chat/chat-service.js";
import type { DaemonConfigStore } from "./daemon-config-store.js";
import type { DownloadTokenStore } from "./file-download/token-store.js";
import type { LoopService } from "./loop-service.js";
import type { ScheduleService } from "./schedule/service.js";
import { createStub } from "./test-utils/class-mocks.js";
import { DaemonClient } from "./test-utils/daemon-client.js";
@@ -307,8 +305,6 @@ function createVoiceAssistantWebSocketServer(params: {
undefined,
undefined,
undefined,
createStub<FileBackedChatService>({}),
createStub<LoopService>({}),
createStub<ScheduleService>({}),
createStub<CheckoutDiffManager>({
subscribe: () => {},
@@ -5,8 +5,6 @@ import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import type { DownloadTokenStore } from "./file-download/token-store.js";
import type { DaemonConfigStore } from "./daemon-config-store.js";
import type { FileBackedChatService } from "./chat/chat-service.js";
import type { LoopService } from "./loop-service.js";
import type { ScheduleService } from "./schedule/service.js";
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
import { asInternals, createStub } from "./test-utils/class-mocks.js";
@@ -125,8 +123,6 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
undefined,
undefined,
undefined,
createStub<FileBackedChatService>({}),
createStub<LoopService>({}),
createStub<ScheduleService>({}),
createStub<CheckoutDiffManager>({
subscribe: vi.fn(),
@@ -5,8 +5,6 @@ import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import type { DownloadTokenStore } from "./file-download/token-store.js";
import type { DaemonConfigStore } from "./daemon-config-store.js";
import type { FileBackedChatService } from "./chat/chat-service.js";
import type { LoopService } from "./loop-service.js";
import type { ScheduleService } from "./schedule/service.js";
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
import type { WorkspaceAutoName } from "./workspace-auto-name.js";
@@ -281,8 +279,6 @@ function createServer(options?: {
undefined,
undefined,
undefined,
createStub<FileBackedChatService>({}),
createStub<LoopService>({}),
createStub<ScheduleService>({}),
createStub<CheckoutDiffManager>({
subscribe: vi.fn(),
@@ -5,8 +5,6 @@ import type { AgentManager } from "./agent/agent-manager.js";
import type { AgentStorage } from "./agent/agent-storage.js";
import type { DownloadTokenStore } from "./file-download/token-store.js";
import type { DaemonConfigStore } from "./daemon-config-store.js";
import type { FileBackedChatService } from "./chat/chat-service.js";
import type { LoopService } from "./loop-service.js";
import type { ScheduleService } from "./schedule/service.js";
import type { CheckoutDiffManager } from "./checkout-diff-manager.js";
import type {
@@ -156,8 +154,6 @@ function createServer(terminalManager: TerminalManager, workspaceRegistry?: Work
undefined,
undefined,
workspaceRegistry,
createStub<FileBackedChatService>({}),
createStub<LoopService>({}),
createStub<ScheduleService>({}),
createStub<CheckoutDiffManager>({
subscribe: vi.fn(),
+2 -24
View File
@@ -11,8 +11,6 @@ import type { TerminalManager } from "../terminal/terminal-manager.js";
import type pino from "pino";
import type { ProjectRegistry, WorkspaceRegistry } from "./workspace-registry.js";
import type { ProjectUpdate } from "./workspace-reconciliation-service.js";
import type { FileBackedChatService } from "./chat/chat-service.js";
import type { LoopService } from "./loop-service.js";
import type { ScheduleService } from "./schedule/service.js";
import type { CheckoutDiffManager, CheckoutDiffMetrics } from "./checkout-diff-manager.js";
import type { DaemonConfigStore, MutableDaemonConfig } from "./daemon-config-store.js";
@@ -492,32 +490,22 @@ export class MissingDaemonVersionError extends Error {
}
interface RequiredWebSocketServices {
chatService: FileBackedChatService;
loopService: LoopService;
scheduleService: ScheduleService;
checkoutDiffManager: CheckoutDiffManager;
}
function requireWebSocketServices(params: {
chatService?: FileBackedChatService;
loopService?: LoopService;
scheduleService?: ScheduleService;
checkoutDiffManager?: CheckoutDiffManager;
}): RequiredWebSocketServices {
const { chatService, loopService, scheduleService, checkoutDiffManager } = params;
if (!chatService) {
throw new Error("VoiceAssistantWebSocketServer requires a chat service.");
}
if (!loopService) {
throw new Error("VoiceAssistantWebSocketServer requires a loop service.");
}
const { scheduleService, checkoutDiffManager } = params;
if (!scheduleService) {
throw new Error("VoiceAssistantWebSocketServer requires a schedule service.");
}
if (!checkoutDiffManager) {
throw new Error("VoiceAssistantWebSocketServer requires a checkout diff manager.");
}
return { chatService, loopService, scheduleService, checkoutDiffManager };
return { scheduleService, checkoutDiffManager };
}
/**
@@ -537,8 +525,6 @@ export class VoiceAssistantWebSocketServer {
private readonly agentStorage: AgentStorage;
private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry;
private readonly chatService: FileBackedChatService;
private readonly loopService: LoopService;
private readonly scheduleService: ScheduleService;
private readonly checkoutDiffManager: CheckoutDiffManager;
private readonly github: ForgeService;
@@ -611,8 +597,6 @@ export class VoiceAssistantWebSocketServer {
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void,
projectRegistry?: ProjectRegistry,
workspaceRegistry?: WorkspaceRegistry,
chatService?: FileBackedChatService,
loopService?: LoopService,
scheduleService?: ScheduleService,
checkoutDiffManager?: CheckoutDiffManager,
serviceProxy?: ServiceProxySubsystem | null,
@@ -652,13 +636,9 @@ export class VoiceAssistantWebSocketServer {
this.projectRegistry = projectRegistry ?? createNoopProjectRegistry();
this.workspaceRegistry = workspaceRegistry ?? createNoopWorkspaceRegistry();
const requiredServices = requireWebSocketServices({
chatService,
loopService,
scheduleService,
checkoutDiffManager,
});
this.chatService = requiredServices.chatService;
this.loopService = requiredServices.loopService;
this.scheduleService = requiredServices.scheduleService;
this.checkoutDiffManager = requiredServices.checkoutDiffManager;
this.github = github ?? createGitHubService();
@@ -1359,8 +1339,6 @@ export class VoiceAssistantWebSocketServer {
agentStorage: this.agentStorage,
projectRegistry: this.projectRegistry,
workspaceRegistry: this.workspaceRegistry,
chatService: this.chatService,
loopService: this.loopService,
scheduleService: this.scheduleService,
checkoutDiffManager: this.checkoutDiffManager,
github: this.github,
@@ -218,9 +218,7 @@ function createSessionForWireCompatTest(options?: {
projectRegistry: new EmptyProjectRegistry() as unknown as SessionOptions["projectRegistry"],
workspaceRegistry:
new EmptyWorkspaceRegistry() as unknown as SessionOptions["workspaceRegistry"],
chatService: {} as SessionOptions["chatService"],
scheduleService: {} as SessionOptions["scheduleService"],
loopService: {} as SessionOptions["loopService"],
checkoutDiffManager: {
scheduleRefreshForCwd() {},
onWorkspaceStateMayHaveChanged() {},
@@ -153,8 +153,8 @@ function createArchiveDeps(input: ArchiveDepsInput): ArchiveTestDependencies {
}),
},
agentStorage: {
list: async (): Promise<StoredAgentRecord[]> => [],
} as Pick<AgentStorage, "list">,
listByWorkspace: async (): Promise<StoredAgentRecord[]> => [],
} as Pick<AgentStorage, "listByWorkspace">,
findWorkspaceIdForCwd: input.findWorkspaceIdForCwd ?? vi.fn(async () => null),
listActiveWorkspaces: async () =>
active.filter((workspace) => !archivedWorkspaceIds.has(workspace.workspaceId)),
@@ -703,12 +703,15 @@ describe("archiveByScope", () => {
}),
};
deps.agentStorage = {
list: async () =>
[
{ id: targetStoredAgentId, workspaceId: targetWorkspaceId, archivedAt: null },
{ id: otherStoredAgentId, workspaceId: otherWorkspaceId, archivedAt: null },
] as StoredAgentRecord[],
} as Pick<AgentStorage, "list">;
listByWorkspace: async (workspaceId: string) =>
workspaceId === targetWorkspaceId
? ([
{ id: targetStoredAgentId, workspaceId: targetWorkspaceId, archivedAt: null },
] as StoredAgentRecord[])
: ([
{ id: otherStoredAgentId, workspaceId: otherWorkspaceId, archivedAt: null },
] as StoredAgentRecord[]),
} as Pick<AgentStorage, "listByWorkspace">;
const result = await archiveByScope(deps, {
scope: { kind: "workspace", workspaceId: targetWorkspaceId },
@@ -33,7 +33,7 @@ export interface ArchiveDependencies {
github: ForgeService;
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
agentManager: Pick<AgentManager, "listAgents" | "getAgent" | "archiveAgent" | "archiveSnapshot">;
agentStorage: Pick<AgentStorage, "list">;
agentStorage: Pick<AgentStorage, "listByWorkspace">;
// Resolves the worktree at a path to its workspaceId for archive-by-path. The
// path uniquely identifies a worktree workspace; this is a directory lookup for
// the archive target, not status/ownership.
@@ -452,16 +452,14 @@ export async function archiveWorkspaceContents(
let storedRecords: StoredAgentRecord[] = [];
try {
storedRecords = await dependencies.agentStorage.list();
storedRecords = await dependencies.agentStorage.listByWorkspace(workspaceId);
} catch (error) {
dependencies.sessionLogger?.warn(
{ err: error, workspaceId },
"Failed to list stored agents during workspace archive; continuing",
);
}
const matchingStoredRecords = storedRecords.filter(
(record) => record.workspaceId === workspaceId,
);
const matchingStoredRecords = storedRecords;
for (const record of matchingStoredRecords) {
archivedAgents.add(record.id);
}
@@ -1,45 +0,0 @@
---
title: "Why agent chat rooms beat agent-to-agent prompting"
description: "Why shared chat rooms are a better coordination model than hidden agent-to-agent prompt chains once tasks stop being trivial."
date: "2026-03-29"
draft: "true"
---
I keep seeing people try to wire agents together by having one agent prompt another directly.
Planner prompts implementer. Implementer prompts reviewer. Reviewer prompts planner again. Maybe it works for a demo. Maybe it even works for a narrow task.
But I think it breaks down pretty quickly.
The problem is that agent-to-agent prompting turns coordination into hidden private messages. The whole workflow disappears into a chain of opaque handoffs. Context gets duplicated, instructions drift, and it becomes hard for the human to see what is going on, step in, or redirect things cleanly.
What has been working much better for me is a shared chat room model.
Instead of agents calling each other directly, they all communicate in a room that the human can also read and participate in. The room becomes the coordination surface. Not just a log, but the place where work gets assigned, clarified, reviewed, and handed off.
That changes a few things.
First, coordination becomes visible. If a planning agent says "I think we should split this into 3 steps", that is just there in the room. If an implementation agent finishes and asks for review, that is there too. The human does not need to reconstruct the workflow from tool logs or imagine what one agent told another 5 minutes ago.
Second, handoff gets cleaner. You are no longer depending on one giant prompt chain staying intact. Agents can join, leave, catch up, and respond to the current shared context. That matters a lot once tasks stop being trivial.
Third, the human stays in the loop without micromanaging. This is the part I care about most. I do not want to disappear into a black box where agents recursively talk to each other and I only see the final result. I want to be able to glance at the room, redirect something, answer a question, or spawn another agent when needed.
Fourth, it matches how collaborative work already feels. A lot of software development is not one linear prompt. It is more like a room with planning, implementation, review, clarification, waiting, and resuming. If that is true for humans, I think it will be true for agents too.
This also connects to a broader point I keep coming back to: a real agent development environment probably needs organizational entities above a single chat.
If every task is just one long chat with one agent, things get messy fast. Once you have parallel tasks, multiple models, review loops, different hosts, or work that continues while you are away from your desk, you need structure. Projects, workspaces, rooms, whatever you want to call them. Some stable place where the work lives.
I think people underestimate this because current demos are still too simple. One agent edits files, another reviews, done. But once you actually live in these tools every day, the bottleneck is not just model quality. It is coordination.
My current belief is that the future is less "one super agent in one chat box" and more "teams of agents collaborating in shared contexts that humans can also inhabit".
Not because it sounds grand. Just because it seems to work better.
If you're building in this space, I'm curious what has been working for you:
- direct agent-to-agent prompting
- shared rooms
- workspaces
- something else entirely
@@ -1157,19 +1157,6 @@ paseo run --host devbox:6767 "run the full test suite"
paseo ls # list running agents
paseo attach abc123 # stream live output
paseo send abc123 "also add tests" # follow-up task`,
},
{
title: "Loops",
description:
"Have one agent do the work, another verify the result, and loop until it passes. Built-in, no shell scripting needed.",
code: `# Worker-verifier loop: fix tests until they pass
paseo loop run "make all tests pass" \\
--verify "verify tests pass and the code is production-ready" \\
--verify-check "npm test" \\
--max-iterations 5
paseo loop ls # list running loops
paseo loop logs abc123 # stream loop output`,
},
{
title: "Schedules",
@@ -79,7 +79,7 @@ paseo send <agent-id> "add tests"
paseo schedule create --cron "0 9 * * 1" "audit the codebase"
```
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. `paseo loop` retries an agent until a verification command passes. The MCP server lets other agents create worktrees, launch agents, open terminals, and send prompts.
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. The MCP server lets other agents create worktrees, launch agents, open terminals, and send prompts.
## Worktrees and services
@@ -79,7 +79,7 @@ paseo send <agent-id> "add tests"
paseo schedule create --cron "0 9 * * 1" "audit the codebase"
```
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. `paseo loop` retries an agent until a verification command passes. The MCP server lets other agents create worktrees, launch agents, open terminals, and send prompts.
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. The MCP server lets other agents create worktrees, launch agents, open terminals, and send prompts.
## Voice
@@ -58,7 +58,7 @@ paseo send <agent-id> "add tests"
paseo schedule create --cron "0 9 * * 1" "audit the codebase"
```
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. `paseo loop` retries an agent until a verification command passes.
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron.
Conductor does not have a CLI.
@@ -70,7 +70,7 @@ paseo send <agent-id> "add tests"
paseo schedule create --cron "0 9 * * 1" "audit the codebase"
```
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. `paseo loop` retries an agent until a verification command passes.
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron.
Happy Coder has a CLI to launch the wrapped session. It does not have schedules or loops.
@@ -60,7 +60,7 @@ paseo send <agent-id> "add tests"
paseo schedule create --cron "0 9 * * 1" "audit the codebase"
```
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. `paseo loop` retries an agent until a verification command passes.
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron.
OpenChamber does not have a CLI.
@@ -75,7 +75,7 @@ paseo send <agent-id> "add tests"
paseo schedule create --cron "0 9 * * 1" "audit the codebase"
```
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. `paseo loop` retries an agent until a verification command passes. The MCP server lets other agents create worktrees, launch agents, open terminals, and send prompts.
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. The MCP server lets other agents create worktrees, launch agents, open terminals, and send prompts.
## Worktrees and services
@@ -70,7 +70,7 @@ paseo send <agent-id> "add tests"
paseo schedule create --cron "0 9 * * 1" "audit the codebase"
```
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron. `paseo loop` retries an agent until a verification command passes.
`paseo run --host` connects to a remote daemon. `paseo schedule` runs an agent on a cron.
Superset is a desktop app and does not have a CLI.