257 lines
7.4 KiB
TypeScript
257 lines
7.4 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
import { existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
import type { SubagentState } from "../../cli/helpers/subagentState";
|
|
import {
|
|
clearAllSubagents,
|
|
registerSubagent,
|
|
updateSubagent,
|
|
} from "../../cli/helpers/subagentState";
|
|
import { backgroundTasks } from "../../tools/impl/process_manager";
|
|
import {
|
|
spawnBackgroundSubagentTask,
|
|
waitForBackgroundSubagentLink,
|
|
} from "../../tools/impl/Task";
|
|
|
|
describe("spawnBackgroundSubagentTask", () => {
|
|
let subagentCounter = 0;
|
|
const queueMessages: Array<{
|
|
kind: "user" | "task_notification";
|
|
text: string;
|
|
}> = [];
|
|
|
|
const generateSubagentIdImpl = () => {
|
|
subagentCounter += 1;
|
|
return `subagent-test-${subagentCounter}`;
|
|
};
|
|
|
|
const registerSubagentImpl = mock(
|
|
(
|
|
_id: string,
|
|
_type: string,
|
|
_description: string,
|
|
_toolCallId?: string,
|
|
_isBackground?: boolean,
|
|
) => {},
|
|
);
|
|
const completeSubagentImpl = mock(
|
|
(_id: string, _result: { success: boolean; error?: string }) => {},
|
|
);
|
|
const buildSnapshot = (id: string): SubagentState => ({
|
|
id,
|
|
type: "Reflection",
|
|
description: "Reflect on memory",
|
|
status: "running",
|
|
agentURL: null,
|
|
toolCalls: [
|
|
{ id: "tc-1", name: "Read", args: "{}" },
|
|
{ id: "tc-2", name: "Edit", args: "{}" },
|
|
],
|
|
totalTokens: 0,
|
|
durationMs: 0,
|
|
startTime: Date.now(),
|
|
});
|
|
const getSubagentSnapshotImpl = () => ({
|
|
agents: [buildSnapshot("subagent-test-1")],
|
|
expanded: false,
|
|
});
|
|
const addToMessageQueueImpl = (msg: {
|
|
kind: "user" | "task_notification";
|
|
text: string;
|
|
}) => {
|
|
queueMessages.push(msg);
|
|
};
|
|
const formatTaskNotificationImpl = mock(
|
|
(_args: unknown) => "<task-notification/>",
|
|
);
|
|
const runSubagentStopHooksImpl = mock(async () => ({
|
|
blocked: false,
|
|
errored: false,
|
|
feedback: [],
|
|
results: [],
|
|
}));
|
|
|
|
beforeEach(() => {
|
|
subagentCounter = 0;
|
|
queueMessages.length = 0;
|
|
registerSubagentImpl.mockClear();
|
|
completeSubagentImpl.mockClear();
|
|
formatTaskNotificationImpl.mockClear();
|
|
runSubagentStopHooksImpl.mockClear();
|
|
backgroundTasks.clear();
|
|
clearAllSubagents();
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const task of backgroundTasks.values()) {
|
|
if (existsSync(task.outputFile)) {
|
|
unlinkSync(task.outputFile);
|
|
}
|
|
}
|
|
backgroundTasks.clear();
|
|
clearAllSubagents();
|
|
});
|
|
|
|
test("runs background subagent and preserves queue + hook behavior on success", async () => {
|
|
const spawnSubagentImpl = mock(async () => ({
|
|
agentId: "agent-123",
|
|
conversationId: "default",
|
|
report: "reflection done",
|
|
success: true,
|
|
totalTokens: 55,
|
|
}));
|
|
|
|
const launched = spawnBackgroundSubagentTask({
|
|
subagentType: "reflection",
|
|
prompt: "Reflect",
|
|
description: "Reflect on memory",
|
|
deps: {
|
|
spawnSubagentImpl,
|
|
addToMessageQueueImpl,
|
|
formatTaskNotificationImpl,
|
|
runSubagentStopHooksImpl,
|
|
generateSubagentIdImpl,
|
|
registerSubagentImpl,
|
|
completeSubagentImpl,
|
|
getSubagentSnapshotImpl,
|
|
},
|
|
});
|
|
|
|
expect(launched.taskId).toMatch(/^task_\d+$/);
|
|
expect(launched.subagentId).toBe("subagent-test-1");
|
|
expect(backgroundTasks.get(launched.taskId)?.status).toBe("running");
|
|
expect(registerSubagentImpl).toHaveBeenCalledTimes(1);
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
const task = backgroundTasks.get(launched.taskId);
|
|
expect(task?.status).toBe("completed");
|
|
expect(task?.output[0]).toContain("reflection done");
|
|
expect(completeSubagentImpl).toHaveBeenCalledTimes(1);
|
|
expect(queueMessages.length).toBe(1);
|
|
expect(runSubagentStopHooksImpl).toHaveBeenCalledWith(
|
|
"reflection",
|
|
"subagent-test-1",
|
|
true,
|
|
undefined,
|
|
"agent-123",
|
|
"default",
|
|
);
|
|
|
|
const outputContent = readFileSync(launched.outputFile, "utf-8");
|
|
expect(outputContent).toContain("[Task started: Reflect on memory]");
|
|
expect(outputContent).toContain("[Task completed]");
|
|
});
|
|
|
|
test("silentCompletion skips message queue notification", async () => {
|
|
const spawnSubagentImpl = mock(async () => ({
|
|
agentId: "agent-silent",
|
|
conversationId: "default",
|
|
report: "init done",
|
|
success: true,
|
|
totalTokens: 30,
|
|
}));
|
|
|
|
const launched = spawnBackgroundSubagentTask({
|
|
subagentType: "init",
|
|
prompt: "Init memory",
|
|
description: "Initializing memory",
|
|
silentCompletion: true,
|
|
deps: {
|
|
spawnSubagentImpl,
|
|
addToMessageQueueImpl,
|
|
formatTaskNotificationImpl,
|
|
runSubagentStopHooksImpl,
|
|
generateSubagentIdImpl,
|
|
registerSubagentImpl,
|
|
completeSubagentImpl,
|
|
getSubagentSnapshotImpl,
|
|
},
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
const task = backgroundTasks.get(launched.taskId);
|
|
expect(task?.status).toBe("completed");
|
|
expect(task?.output[0]).toContain("init done");
|
|
expect(completeSubagentImpl).toHaveBeenCalledTimes(1);
|
|
// No notification queued
|
|
expect(queueMessages.length).toBe(0);
|
|
expect(formatTaskNotificationImpl).not.toHaveBeenCalled();
|
|
// Hooks still run
|
|
expect(runSubagentStopHooksImpl).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test("marks background task failed and emits notification on error", async () => {
|
|
const spawnSubagentImpl = mock(async () => {
|
|
throw new Error("subagent exploded");
|
|
});
|
|
|
|
const launched = spawnBackgroundSubagentTask({
|
|
subagentType: "reflection",
|
|
prompt: "Reflect",
|
|
description: "Reflect on memory",
|
|
deps: {
|
|
spawnSubagentImpl,
|
|
addToMessageQueueImpl,
|
|
formatTaskNotificationImpl,
|
|
runSubagentStopHooksImpl,
|
|
generateSubagentIdImpl,
|
|
registerSubagentImpl,
|
|
completeSubagentImpl,
|
|
getSubagentSnapshotImpl,
|
|
},
|
|
});
|
|
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
|
|
const task = backgroundTasks.get(launched.taskId);
|
|
expect(task?.status).toBe("failed");
|
|
expect(task?.error).toBe("subagent exploded");
|
|
expect(queueMessages.length).toBe(1);
|
|
expect(runSubagentStopHooksImpl).toHaveBeenCalledWith(
|
|
"reflection",
|
|
"subagent-test-1",
|
|
false,
|
|
"subagent exploded",
|
|
undefined,
|
|
undefined,
|
|
);
|
|
|
|
const outputContent = readFileSync(launched.outputFile, "utf-8");
|
|
expect(outputContent).toContain("[error] subagent exploded");
|
|
});
|
|
});
|
|
|
|
describe("waitForBackgroundSubagentLink", () => {
|
|
afterEach(() => {
|
|
clearAllSubagents();
|
|
});
|
|
|
|
test("returns after agent URL becomes available", async () => {
|
|
registerSubagent("subagent-link-1", "reflection", "Reflect", "tc-1", true);
|
|
|
|
setTimeout(() => {
|
|
updateSubagent("subagent-link-1", {
|
|
agentURL: "https://app.letta.com/agents/agent-123",
|
|
});
|
|
}, 20);
|
|
|
|
const start = Date.now();
|
|
await waitForBackgroundSubagentLink("subagent-link-1", 300);
|
|
const elapsed = Date.now() - start;
|
|
|
|
expect(elapsed).toBeGreaterThanOrEqual(10);
|
|
expect(elapsed).toBeLessThan(250);
|
|
});
|
|
|
|
test("times out when URL is unavailable", async () => {
|
|
registerSubagent("subagent-link-2", "reflection", "Reflect", "tc-2", true);
|
|
|
|
const start = Date.now();
|
|
await waitForBackgroundSubagentLink("subagent-link-2", 70);
|
|
const elapsed = Date.now() - start;
|
|
|
|
expect(elapsed).toBeGreaterThanOrEqual(50);
|
|
});
|
|
});
|