factory: rename project and handoff actors

This commit is contained in:
Nathan Flurry 2026-03-10 21:55:30 -07:00
parent 3022bce2ad
commit ea7c36a8e7
147 changed files with 6313 additions and 14364 deletions

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { HandoffRecord, HistoryEvent } from "@sandbox-agent/factory-shared";
import type { TaskRecord, HistoryEvent } from "@sandbox-agent/factory-shared";
import { createBackendClient } from "../../src/backend-client.js";
const RUN_E2E = process.env.HF_ENABLE_DAEMON_E2E === "1";
@ -79,20 +79,20 @@ function parseHistoryPayload(event: HistoryEvent): Record<string, unknown> {
}
}
async function debugDump(client: ReturnType<typeof createBackendClient>, workspaceId: string, handoffId: string): Promise<string> {
async function debugDump(client: ReturnType<typeof createBackendClient>, workspaceId: string, taskId: string): Promise<string> {
try {
const handoff = await client.getHandoff(workspaceId, handoffId);
const history = await client.listHistory({ workspaceId, handoffId, limit: 80 }).catch(() => []);
const task = await client.getTask(workspaceId, taskId);
const history = await client.listHistory({ workspaceId, taskId, limit: 80 }).catch(() => []);
const historySummary = history
.slice(0, 20)
.map((e) => `${new Date(e.createdAt).toISOString()} ${e.kind}`)
.join("\n");
let sessionEventsSummary = "";
if (handoff.activeSandboxId && handoff.activeSessionId) {
if (task.activeSandboxId && task.activeSessionId) {
const events = await client
.listSandboxSessionEvents(workspaceId, handoff.providerId, handoff.activeSandboxId, {
sessionId: handoff.activeSessionId,
.listSandboxSessionEvents(workspaceId, task.providerId, task.activeSandboxId, {
sessionId: task.activeSessionId,
limit: 50,
})
.then((r) => r.items)
@ -104,17 +104,17 @@ async function debugDump(client: ReturnType<typeof createBackendClient>, workspa
}
return [
"=== handoff ===",
"=== task ===",
JSON.stringify(
{
status: handoff.status,
statusMessage: handoff.statusMessage,
title: handoff.title,
branchName: handoff.branchName,
activeSandboxId: handoff.activeSandboxId,
activeSessionId: handoff.activeSessionId,
prUrl: handoff.prUrl,
prSubmitted: handoff.prSubmitted,
status: task.status,
statusMessage: task.statusMessage,
title: task.title,
branchName: task.branchName,
activeSandboxId: task.activeSandboxId,
activeSessionId: task.activeSessionId,
prUrl: task.prUrl,
prSubmitted: task.prSubmitted,
},
null,
2
@ -144,7 +144,7 @@ async function githubApi(token: string, path: string, init?: RequestInit): Promi
describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
it.skipIf(!RUN_E2E)(
"creates a handoff, waits for agent to implement, and opens a PR",
"creates a task, waits for agent to implement, and opens a PR",
{ timeout: 15 * 60_000 },
async () => {
const endpoint =
@ -164,7 +164,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
const repo = await client.addRepo(workspaceId, repoRemote);
const created = await client.createHandoff({
const created = await client.createTask({
workspaceId,
repoId: repo.repoId,
task: [
@ -187,42 +187,42 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
let lastStatus: string | null = null;
try {
const namedAndProvisioned = await poll<HandoffRecord>(
"handoff naming + sandbox provisioning",
const namedAndProvisioned = await poll<TaskRecord>(
"task naming + sandbox provisioning",
// Cold Daytona snapshot/image preparation can exceed 5 minutes on first run.
8 * 60_000,
1_000,
async () => client.getHandoff(workspaceId, created.handoffId),
async () => client.getTask(workspaceId, created.taskId),
(h) => Boolean(h.title && h.branchName && h.activeSandboxId),
(h) => {
if (h.status !== lastStatus) {
lastStatus = h.status;
}
if (h.status === "error") {
throw new Error("handoff entered error state during provisioning");
throw new Error("task entered error state during provisioning");
}
}
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.handoffId);
const dump = await debugDump(client, workspaceId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
branchName = namedAndProvisioned.branchName!;
sandboxId = namedAndProvisioned.activeSandboxId!;
const withSession = await poll<HandoffRecord>(
"handoff to create active session",
const withSession = await poll<TaskRecord>(
"task to create active session",
3 * 60_000,
1_500,
async () => client.getHandoff(workspaceId, created.handoffId),
async () => client.getTask(workspaceId, created.taskId),
(h) => Boolean(h.activeSessionId),
(h) => {
if (h.status === "error") {
throw new Error("handoff entered error state while waiting for active session");
throw new Error("task entered error state while waiting for active session");
}
}
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.handoffId);
const dump = await debugDump(client, workspaceId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
@ -241,23 +241,23 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
).items,
(events) => events.length > 0
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.handoffId);
const dump = await debugDump(client, workspaceId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
await poll<HandoffRecord>(
"handoff to reach idle state",
await poll<TaskRecord>(
"task to reach idle state",
8 * 60_000,
2_000,
async () => client.getHandoff(workspaceId, created.handoffId),
async () => client.getTask(workspaceId, created.taskId),
(h) => h.status === "idle",
(h) => {
if (h.status === "error") {
throw new Error("handoff entered error state while waiting for idle");
throw new Error("task entered error state while waiting for idle");
}
}
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.handoffId);
const dump = await debugDump(client, workspaceId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
@ -265,14 +265,14 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
"PR creation history event",
3 * 60_000,
2_000,
async () => client.listHistory({ workspaceId, handoffId: created.handoffId, limit: 200 }),
(events) => events.some((e) => e.kind === "handoff.pr_created")
async () => client.listHistory({ workspaceId, taskId: created.taskId, limit: 200 }),
(events) => events.some((e) => e.kind === "task.pr_created")
)
.catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.handoffId);
const dump = await debugDump(client, workspaceId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
})
.then((events) => events.find((e) => e.kind === "handoff.pr_created")!);
.then((events) => events.find((e) => e.kind === "task.pr_created")!);
const payload = parseHistoryPayload(prCreatedEvent);
prNumber = Number(payload.prNumber);
@ -293,17 +293,17 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
const prFiles = (await prFilesRes.json()) as Array<{ filename: string }>;
expect(prFiles.some((f) => f.filename === expectedFile)).toBe(true);
// Close the handoff and assert the sandbox is released (stopped).
await client.runAction(workspaceId, created.handoffId, "archive");
// Close the task and assert the sandbox is released (stopped).
await client.runAction(workspaceId, created.taskId, "archive");
await poll<HandoffRecord>(
"handoff to become archived (session released)",
await poll<TaskRecord>(
"task to become archived (session released)",
60_000,
1_000,
async () => client.getHandoff(workspaceId, created.handoffId),
async () => client.getTask(workspaceId, created.taskId),
(h) => h.status === "archived" && h.activeSessionId === null
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.handoffId);
const dump = await debugDump(client, workspaceId, created.taskId);
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`);
});
@ -318,7 +318,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
return st.includes("stopped") || st.includes("suspended") || st.includes("paused");
}
).catch(async (err) => {
const dump = await debugDump(client, workspaceId, created.handoffId);
const dump = await debugDump(client, workspaceId, created.taskId);
const state = await client
.sandboxProviderState(workspaceId, "daytona", sandboxId!)
.catch(() => null);

View file

@ -3,10 +3,10 @@ import { mkdir, writeFile } from "node:fs/promises";
import { promisify } from "node:util";
import { describe, expect, it } from "vitest";
import type {
HandoffRecord,
HandoffWorkbenchSnapshot,
TaskRecord,
TaskWorkbenchSnapshot,
WorkbenchAgentTab,
WorkbenchHandoff,
WorkbenchTask,
WorkbenchModelId,
WorkbenchTranscriptEvent,
} from "@sandbox-agent/factory-shared";
@ -76,18 +76,18 @@ async function resolveBackendContainerName(endpoint: string): Promise<string | n
return containerName ?? null;
}
function sandboxRepoPath(record: HandoffRecord): string {
function sandboxRepoPath(record: TaskRecord): string {
const activeSandbox =
record.sandboxes.find((sandbox) => sandbox.sandboxId === record.activeSandboxId) ??
record.sandboxes.find((sandbox) => typeof sandbox.cwd === "string" && sandbox.cwd.length > 0);
const cwd = activeSandbox?.cwd?.trim();
if (!cwd) {
throw new Error(`No sandbox cwd is available for handoff ${record.handoffId}`);
throw new Error(`No sandbox cwd is available for task ${record.taskId}`);
}
return cwd;
}
async function seedSandboxFile(endpoint: string, record: HandoffRecord, filePath: string, content: string): Promise<void> {
async function seedSandboxFile(endpoint: string, record: TaskRecord, filePath: string, content: string): Promise<void> {
const repoPath = sandboxRepoPath(record);
const containerName = await resolveBackendContainerName(endpoint);
if (!containerName) {
@ -128,18 +128,18 @@ async function poll<T>(
}
}
function findHandoff(snapshot: HandoffWorkbenchSnapshot, handoffId: string): WorkbenchHandoff {
const handoff = snapshot.handoffs.find((candidate) => candidate.id === handoffId);
if (!handoff) {
throw new Error(`handoff ${handoffId} missing from snapshot`);
function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTask {
const task = snapshot.tasks.find((candidate) => candidate.id === taskId);
if (!task) {
throw new Error(`task ${taskId} missing from snapshot`);
}
return handoff;
return task;
}
function findTab(handoff: WorkbenchHandoff, tabId: string): WorkbenchAgentTab {
const tab = handoff.tabs.find((candidate) => candidate.id === tabId);
function findTab(task: WorkbenchTask, tabId: string): WorkbenchAgentTab {
const tab = task.tabs.find((candidate) => candidate.id === tabId);
if (!tab) {
throw new Error(`tab ${tabId} missing from handoff ${handoff.id}`);
throw new Error(`tab ${tabId} missing from task ${task.id}`);
}
return tab;
}
@ -218,7 +218,7 @@ function transcriptIncludesAgentText(
describe("e2e(client): workbench flows", () => {
it.skipIf(!RUN_WORKBENCH_E2E)(
"creates a handoff, adds sessions, exchanges messages, and manages workbench state",
"creates a task, adds sessions, exchanges messages, and manages workbench state",
{ timeout: 20 * 60_000 },
async () => {
const endpoint =
@ -237,7 +237,7 @@ describe("e2e(client): workbench flows", () => {
});
const repo = await client.addRepo(workspaceId, repoRemote);
const created = await client.createWorkbenchHandoff(workspaceId, {
const created = await client.createWorkbenchTask(workspaceId, {
repoId: repo.repoId,
title: `Workbench E2E ${runId}`,
branch: `e2e/${runId}`,
@ -246,11 +246,11 @@ describe("e2e(client): workbench flows", () => {
});
const provisioned = await poll(
"handoff provisioning",
"task provisioning",
12 * 60_000,
2_000,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => handoff.branch === `e2e/${runId}` && handoff.tabs.length > 0,
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => task.branch === `e2e/${runId}` && task.tabs.length > 0,
);
const primaryTab = provisioned.tabs[0]!;
@ -259,11 +259,11 @@ describe("e2e(client): workbench flows", () => {
"initial agent response",
12 * 60_000,
2_000,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => {
const tab = findTab(handoff, primaryTab.id);
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => {
const tab = findTab(task, primaryTab.id);
return (
handoff.status === "idle" &&
task.status === "idle" &&
tab.status === "idle" &&
transcriptIncludesAgentText(tab.transcript, expectedInitialReply)
);
@ -273,41 +273,41 @@ describe("e2e(client): workbench flows", () => {
expect(findTab(initialCompleted, primaryTab.id).sessionId).toBeTruthy();
expect(transcriptIncludesAgentText(findTab(initialCompleted, primaryTab.id).transcript, expectedInitialReply)).toBe(true);
const detail = await client.getHandoff(workspaceId, created.handoffId);
const detail = await client.getTask(workspaceId, created.taskId);
await seedSandboxFile(endpoint, detail, expectedFile, runId);
const fileSeeded = await poll(
"seeded sandbox file reflected in workbench",
30_000,
1_000,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => handoff.fileChanges.some((file) => file.path === expectedFile),
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => task.fileChanges.some((file) => file.path === expectedFile),
);
expect(fileSeeded.fileChanges.some((file) => file.path === expectedFile)).toBe(true);
await client.renameWorkbenchHandoff(workspaceId, {
handoffId: created.handoffId,
await client.renameWorkbenchTask(workspaceId, {
taskId: created.taskId,
value: `Workbench E2E ${runId} Renamed`,
});
await client.renameWorkbenchSession(workspaceId, {
handoffId: created.handoffId,
taskId: created.taskId,
tabId: primaryTab.id,
title: "Primary Session",
});
const secondTab = await client.createWorkbenchSession(workspaceId, {
handoffId: created.handoffId,
taskId: created.taskId,
model,
});
await client.renameWorkbenchSession(workspaceId, {
handoffId: created.handoffId,
taskId: created.taskId,
tabId: secondTab.tabId,
title: "Follow-up Session",
});
await client.updateWorkbenchDraft(workspaceId, {
handoffId: created.handoffId,
taskId: created.taskId,
tabId: secondTab.tabId,
text: `Reply with exactly: ${expectedReply}`,
attachments: [
@ -320,12 +320,12 @@ describe("e2e(client): workbench flows", () => {
],
});
const drafted = findHandoff(await client.getWorkbench(workspaceId), created.handoffId);
const drafted = findTask(await client.getWorkbench(workspaceId), created.taskId);
expect(findTab(drafted, secondTab.tabId).draft.text).toContain(expectedReply);
expect(findTab(drafted, secondTab.tabId).draft.attachments).toHaveLength(1);
await client.sendWorkbenchMessage(workspaceId, {
handoffId: created.handoffId,
taskId: created.taskId,
tabId: secondTab.tabId,
text: `Reply with exactly: ${expectedReply}`,
attachments: [],
@ -335,9 +335,9 @@ describe("e2e(client): workbench flows", () => {
"follow-up session response",
10 * 60_000,
2_000,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => {
const tab = findTab(handoff, secondTab.tabId);
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => {
const tab = findTab(task, secondTab.tabId);
return (
tab.status === "idle" &&
transcriptIncludesAgentText(tab.transcript, expectedReply)
@ -349,17 +349,17 @@ describe("e2e(client): workbench flows", () => {
expect(transcriptIncludesAgentText(secondTranscript, expectedReply)).toBe(true);
await client.setWorkbenchSessionUnread(workspaceId, {
handoffId: created.handoffId,
taskId: created.taskId,
tabId: secondTab.tabId,
unread: false,
});
await client.markWorkbenchUnread(workspaceId, { handoffId: created.handoffId });
await client.markWorkbenchUnread(workspaceId, { taskId: created.taskId });
const unreadSnapshot = findHandoff(await client.getWorkbench(workspaceId), created.handoffId);
const unreadSnapshot = findTask(await client.getWorkbench(workspaceId), created.taskId);
expect(unreadSnapshot.tabs.some((tab) => tab.unread)).toBe(true);
await client.closeWorkbenchSession(workspaceId, {
handoffId: created.handoffId,
taskId: created.taskId,
tabId: secondTab.tabId,
});
@ -367,13 +367,13 @@ describe("e2e(client): workbench flows", () => {
"secondary session closed",
30_000,
1_000,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => !handoff.tabs.some((tab) => tab.id === secondTab.tabId),
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => !task.tabs.some((tab) => tab.id === secondTab.tabId),
);
expect(closedSnapshot.tabs).toHaveLength(1);
await client.revertWorkbenchFile(workspaceId, {
handoffId: created.handoffId,
taskId: created.taskId,
path: expectedFile,
});
@ -381,8 +381,8 @@ describe("e2e(client): workbench flows", () => {
"file revert reflected in workbench",
30_000,
1_000,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => !handoff.fileChanges.some((file) => file.path === expectedFile),
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => !task.fileChanges.some((file) => file.path === expectedFile),
);
expect(revertedSnapshot.fileChanges.some((file) => file.path === expectedFile)).toBe(false);

View file

@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import type {
HandoffWorkbenchSnapshot,
TaskWorkbenchSnapshot,
WorkbenchAgentTab,
WorkbenchHandoff,
WorkbenchTask,
WorkbenchModelId,
WorkbenchTranscriptEvent,
} from "@sandbox-agent/factory-shared";
@ -70,18 +70,18 @@ async function poll<T>(
}
}
function findHandoff(snapshot: HandoffWorkbenchSnapshot, handoffId: string): WorkbenchHandoff {
const handoff = snapshot.handoffs.find((candidate) => candidate.id === handoffId);
if (!handoff) {
throw new Error(`handoff ${handoffId} missing from snapshot`);
function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTask {
const task = snapshot.tasks.find((candidate) => candidate.id === taskId);
if (!task) {
throw new Error(`task ${taskId} missing from snapshot`);
}
return handoff;
return task;
}
function findTab(handoff: WorkbenchHandoff, tabId: string): WorkbenchAgentTab {
const tab = handoff.tabs.find((candidate) => candidate.id === tabId);
function findTab(task: WorkbenchTask, tabId: string): WorkbenchAgentTab {
const tab = task.tabs.find((candidate) => candidate.id === tabId);
if (!tab) {
throw new Error(`tab ${tabId} missing from handoff ${handoff.id}`);
throw new Error(`tab ${tabId} missing from task ${task.id}`);
}
return tab;
}
@ -156,12 +156,12 @@ async function measureWorkbenchSnapshot(
avgMs: number;
maxMs: number;
payloadBytes: number;
handoffCount: number;
taskCount: number;
tabCount: number;
transcriptEventCount: number;
}> {
const durations: number[] = [];
let snapshot: HandoffWorkbenchSnapshot | null = null;
let snapshot: TaskWorkbenchSnapshot | null = null;
for (let index = 0; index < iterations; index += 1) {
const startedAt = performance.now();
@ -173,13 +173,13 @@ async function measureWorkbenchSnapshot(
workspaceId,
repos: [],
projects: [],
handoffs: [],
tasks: [],
};
const payloadBytes = Buffer.byteLength(JSON.stringify(finalSnapshot), "utf8");
const tabCount = finalSnapshot.handoffs.reduce((sum, handoff) => sum + handoff.tabs.length, 0);
const transcriptEventCount = finalSnapshot.handoffs.reduce(
(sum, handoff) =>
sum + handoff.tabs.reduce((tabSum, tab) => tabSum + tab.transcript.length, 0),
const tabCount = finalSnapshot.tasks.reduce((sum, task) => sum + task.tabs.length, 0);
const transcriptEventCount = finalSnapshot.tasks.reduce(
(sum, task) =>
sum + task.tabs.reduce((tabSum, tab) => tabSum + tab.transcript.length, 0),
0,
);
@ -187,7 +187,7 @@ async function measureWorkbenchSnapshot(
avgMs: Math.round(average(durations)),
maxMs: Math.round(Math.max(...durations, 0)),
payloadBytes,
handoffCount: finalSnapshot.handoffs.length,
taskCount: finalSnapshot.tasks.length,
tabCount,
transcriptEventCount,
};
@ -202,7 +202,7 @@ describe("e2e(client): workbench load", () => {
const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default";
const repoRemote = requiredRepoRemote();
const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o");
const handoffCount = intEnv("HF_LOAD_HANDOFF_COUNT", 3);
const taskCount = intEnv("HF_LOAD_HANDOFF_COUNT", 3);
const extraSessionCount = intEnv("HF_LOAD_EXTRA_SESSION_COUNT", 2);
const pollIntervalMs = intEnv("HF_LOAD_POLL_INTERVAL_MS", 2_000);
@ -212,12 +212,12 @@ describe("e2e(client): workbench load", () => {
});
const repo = await client.addRepo(workspaceId, repoRemote);
const createHandoffLatencies: number[] = [];
const createTaskLatencies: number[] = [];
const provisionLatencies: number[] = [];
const createSessionLatencies: number[] = [];
const messageRoundTripLatencies: number[] = [];
const snapshotSeries: Array<{
handoffCount: number;
taskCount: number;
avgMs: number;
maxMs: number;
payloadBytes: number;
@ -227,31 +227,31 @@ describe("e2e(client): workbench load", () => {
snapshotSeries.push(await measureWorkbenchSnapshot(client, workspaceId, 2));
for (let handoffIndex = 0; handoffIndex < handoffCount; handoffIndex += 1) {
const runId = `load-${handoffIndex}-${Date.now().toString(36)}`;
for (let taskIndex = 0; taskIndex < taskCount; taskIndex += 1) {
const runId = `load-${taskIndex}-${Date.now().toString(36)}`;
const initialReply = `LOAD_INIT_${runId}`;
const createStartedAt = performance.now();
const created = await client.createWorkbenchHandoff(workspaceId, {
const created = await client.createWorkbenchTask(workspaceId, {
repoId: repo.repoId,
title: `Workbench Load ${runId}`,
branch: `load/${runId}`,
model,
task: `Reply with exactly: ${initialReply}`,
});
createHandoffLatencies.push(performance.now() - createStartedAt);
createTaskLatencies.push(performance.now() - createStartedAt);
const provisionStartedAt = performance.now();
const provisioned = await poll(
`handoff ${runId} provisioning`,
`task ${runId} provisioning`,
12 * 60_000,
pollIntervalMs,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => {
const tab = handoff.tabs[0];
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => {
const tab = task.tabs[0];
return Boolean(
tab &&
handoff.status === "idle" &&
task.status === "idle" &&
tab.status === "idle" &&
transcriptIncludesAgentText(tab.transcript, initialReply),
);
@ -267,13 +267,13 @@ describe("e2e(client): workbench load", () => {
const expectedReply = `LOAD_REPLY_${runId}_${sessionIndex}`;
const createSessionStartedAt = performance.now();
const createdSession = await client.createWorkbenchSession(workspaceId, {
handoffId: created.handoffId,
taskId: created.taskId,
model,
});
createSessionLatencies.push(performance.now() - createSessionStartedAt);
await client.sendWorkbenchMessage(workspaceId, {
handoffId: created.handoffId,
taskId: created.taskId,
tabId: createdSession.tabId,
text: `Run pwd in the repo, then reply with exactly: ${expectedReply}`,
attachments: [],
@ -281,12 +281,12 @@ describe("e2e(client): workbench load", () => {
const messageStartedAt = performance.now();
const withReply = await poll(
`handoff ${runId} session ${sessionIndex} reply`,
`task ${runId} session ${sessionIndex} reply`,
10 * 60_000,
pollIntervalMs,
async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId),
(handoff) => {
const tab = findTab(handoff, createdSession.tabId);
async () => findTask(await client.getWorkbench(workspaceId), created.taskId),
(task) => {
const tab = findTab(task, createdSession.tabId);
return tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedReply);
},
);
@ -300,7 +300,7 @@ describe("e2e(client): workbench load", () => {
console.info(
"[workbench-load-snapshot]",
JSON.stringify({
handoffIndex: handoffIndex + 1,
taskIndex: taskIndex + 1,
...snapshotMetrics,
}),
);
@ -309,9 +309,9 @@ describe("e2e(client): workbench load", () => {
const firstSnapshot = snapshotSeries[0]!;
const lastSnapshot = snapshotSeries[snapshotSeries.length - 1]!;
const summary = {
handoffCount,
taskCount,
extraSessionCount,
createHandoffAvgMs: Math.round(average(createHandoffLatencies)),
createTaskAvgMs: Math.round(average(createTaskLatencies)),
provisionAvgMs: Math.round(average(provisionLatencies)),
createSessionAvgMs: Math.round(average(createSessionLatencies)),
messageRoundTripAvgMs: Math.round(average(messageRoundTripLatencies)),
@ -326,10 +326,10 @@ describe("e2e(client): workbench load", () => {
console.info("[workbench-load-summary]", JSON.stringify(summary));
expect(createHandoffLatencies.length).toBe(handoffCount);
expect(provisionLatencies.length).toBe(handoffCount);
expect(createSessionLatencies.length).toBe(handoffCount * extraSessionCount);
expect(messageRoundTripLatencies.length).toBe(handoffCount * extraSessionCount);
expect(createTaskLatencies.length).toBe(taskCount);
expect(provisionLatencies.length).toBe(taskCount);
expect(createSessionLatencies.length).toBe(taskCount * extraSessionCount);
expect(messageRoundTripLatencies.length).toBe(taskCount * extraSessionCount);
},
);
});

View file

@ -1,11 +1,11 @@
import { describe, expect, it } from "vitest";
import {
handoffKey,
handoffStatusSyncKey,
taskKey,
taskStatusSyncKey,
historyKey,
projectBranchSyncKey,
projectKey,
projectPrSyncKey,
repoBranchSyncKey,
repoKey,
repoPrSyncKey,
sandboxInstanceKey,
workspaceKey
} from "../src/keys.js";
@ -14,13 +14,13 @@ describe("actor keys", () => {
it("prefixes every key with workspace namespace", () => {
const keys = [
workspaceKey("default"),
projectKey("default", "repo"),
handoffKey("default", "repo", "handoff"),
repoKey("default", "repo"),
taskKey("default", "task"),
sandboxInstanceKey("default", "daytona", "sbx"),
historyKey("default", "repo"),
projectPrSyncKey("default", "repo"),
projectBranchSyncKey("default", "repo"),
handoffStatusSyncKey("default", "repo", "handoff", "sandbox-1", "session-1")
repoPrSyncKey("default", "repo"),
repoBranchSyncKey("default", "repo"),
taskStatusSyncKey("default", "repo", "task", "sandbox-1", "session-1")
];
for (const key of keys) {

View file

@ -1,17 +1,17 @@
import { describe, expect, it } from "vitest";
import type { HandoffRecord } from "@sandbox-agent/factory-shared";
import type { TaskRecord } from "@sandbox-agent/factory-shared";
import {
filterHandoffs,
filterTasks,
formatRelativeAge,
fuzzyMatch,
summarizeHandoffs
summarizeTasks
} from "../src/view-model.js";
const sample: HandoffRecord = {
const sample: TaskRecord = {
workspaceId: "default",
repoId: "repo-a",
repoRemote: "https://example.com/repo-a.git",
handoffId: "handoff-1",
taskId: "task-1",
branchName: "feature/test",
title: "Test Title",
task: "Do test",
@ -53,19 +53,19 @@ describe("search helpers", () => {
});
it("filters rows across branch and title", () => {
const rows: HandoffRecord[] = [
const rows: TaskRecord[] = [
sample,
{
...sample,
handoffId: "handoff-2",
taskId: "task-2",
branchName: "docs/update-intro",
title: "Docs Intro Refresh",
status: "idle"
}
];
expect(filterHandoffs(rows, "doc")).toHaveLength(1);
expect(filterHandoffs(rows, "h2")).toHaveLength(1);
expect(filterHandoffs(rows, "test")).toHaveLength(2);
expect(filterTasks(rows, "doc")).toHaveLength(1);
expect(filterTasks(rows, "h2")).toHaveLength(1);
expect(filterTasks(rows, "test")).toHaveLength(2);
});
});
@ -76,13 +76,13 @@ describe("summary helpers", () => {
});
it("summarizes by status and provider", () => {
const rows: HandoffRecord[] = [
const rows: TaskRecord[] = [
sample,
{ ...sample, handoffId: "handoff-2", status: "idle", providerId: "daytona" },
{ ...sample, handoffId: "handoff-3", status: "error", providerId: "daytona" }
{ ...sample, taskId: "task-2", status: "idle", providerId: "daytona" },
{ ...sample, taskId: "task-3", status: "error", providerId: "daytona" }
];
const summary = summarizeHandoffs(rows);
const summary = summarizeTasks(rows);
expect(summary.total).toBe(3);
expect(summary.byStatus.running).toBe(1);
expect(summary.byStatus.idle).toBe(1);

View file

@ -1,18 +1,18 @@
import { describe, expect, it } from "vitest";
import type { BackendClient } from "../src/backend-client.js";
import { createHandoffWorkbenchClient } from "../src/workbench-client.js";
import { createTaskWorkbenchClient } from "../src/workbench-client.js";
async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
describe("createHandoffWorkbenchClient", () => {
describe("createTaskWorkbenchClient", () => {
it("scopes mock clients by workspace", async () => {
const alpha = createHandoffWorkbenchClient({
const alpha = createTaskWorkbenchClient({
mode: "mock",
workspaceId: "mock-alpha",
});
const beta = createHandoffWorkbenchClient({
const beta = createTaskWorkbenchClient({
mode: "mock",
workspaceId: "mock-beta",
});
@ -22,24 +22,24 @@ describe("createHandoffWorkbenchClient", () => {
expect(alphaInitial.workspaceId).toBe("mock-alpha");
expect(betaInitial.workspaceId).toBe("mock-beta");
await alpha.createHandoff({
await alpha.createTask({
repoId: alphaInitial.repos[0]!.id,
task: "Ship alpha-only change",
title: "Alpha only",
});
expect(alpha.getSnapshot().handoffs).toHaveLength(alphaInitial.handoffs.length + 1);
expect(beta.getSnapshot().handoffs).toHaveLength(betaInitial.handoffs.length);
expect(alpha.getSnapshot().tasks).toHaveLength(alphaInitial.tasks.length + 1);
expect(beta.getSnapshot().tasks).toHaveLength(betaInitial.tasks.length);
});
it("uses the initial task to bootstrap a new mock handoff session", async () => {
const client = createHandoffWorkbenchClient({
it("uses the initial task to bootstrap a new mock task session", async () => {
const client = createTaskWorkbenchClient({
mode: "mock",
workspaceId: "mock-onboarding",
});
const snapshot = client.getSnapshot();
const created = await client.createHandoff({
const created = await client.createTask({
repoId: snapshot.repos[0]!.id,
task: "Reply with exactly: MOCK_WORKBENCH_READY",
title: "Mock onboarding",
@ -47,22 +47,22 @@ describe("createHandoffWorkbenchClient", () => {
model: "gpt-4o",
});
const runningHandoff = client.getSnapshot().handoffs.find((handoff) => handoff.id === created.handoffId);
expect(runningHandoff).toEqual(
const runningTask = client.getSnapshot().tasks.find((task) => task.id === created.taskId);
expect(runningTask).toEqual(
expect.objectContaining({
title: "Mock onboarding",
branch: "feat/mock-onboarding",
status: "running",
}),
);
expect(runningHandoff?.tabs[0]).toEqual(
expect(runningTask?.tabs[0]).toEqual(
expect.objectContaining({
id: created.tabId,
created: true,
status: "running",
}),
);
expect(runningHandoff?.tabs[0]?.transcript).toEqual([
expect(runningTask?.tabs[0]?.transcript).toEqual([
expect.objectContaining({
sender: "client",
payload: expect.objectContaining({
@ -73,26 +73,26 @@ describe("createHandoffWorkbenchClient", () => {
await sleep(2_700);
const completedHandoff = client.getSnapshot().handoffs.find((handoff) => handoff.id === created.handoffId);
expect(completedHandoff?.status).toBe("idle");
expect(completedHandoff?.tabs[0]).toEqual(
const completedTask = client.getSnapshot().tasks.find((task) => task.id === created.taskId);
expect(completedTask?.status).toBe("idle");
expect(completedTask?.tabs[0]).toEqual(
expect.objectContaining({
status: "idle",
unread: true,
}),
);
expect(completedHandoff?.tabs[0]?.transcript).toEqual([
expect(completedTask?.tabs[0]?.transcript).toEqual([
expect.objectContaining({ sender: "client" }),
expect.objectContaining({ sender: "agent" }),
]);
});
it("routes remote push actions through the backend boundary", async () => {
const actions: Array<{ workspaceId: string; handoffId: string; action: string }> = [];
const actions: Array<{ workspaceId: string; taskId: string; action: string }> = [];
let snapshotReads = 0;
const backend = {
async runAction(workspaceId: string, handoffId: string, action: string): Promise<void> {
actions.push({ workspaceId, handoffId, action });
async runAction(workspaceId: string, taskId: string, action: string): Promise<void> {
actions.push({ workspaceId, taskId, action });
},
async getWorkbench(workspaceId: string) {
snapshotReads += 1;
@ -100,7 +100,7 @@ describe("createHandoffWorkbenchClient", () => {
workspaceId,
repos: [],
projects: [],
handoffs: [],
tasks: [],
};
},
subscribeWorkbench(): () => void {
@ -108,18 +108,18 @@ describe("createHandoffWorkbenchClient", () => {
},
} as unknown as BackendClient;
const client = createHandoffWorkbenchClient({
const client = createTaskWorkbenchClient({
mode: "remote",
backend,
workspaceId: "remote-ws",
});
await client.pushHandoff({ handoffId: "handoff-123" });
await client.pushTask({ taskId: "task-123" });
expect(actions).toEqual([
{
workspaceId: "remote-ws",
handoffId: "handoff-123",
taskId: "task-123",
action: "push",
},
]);