Q3: record a structured handoff on every delegated task
create_task now pins upstream files by content hash and carries the source task, source thread, verified facts, open questions and acceptance criteria into the assignee's prompt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9f2adce7b3
commit
6570492daa
22 files changed
+219
-28
No files matched your search
@@ -0,0 +1,6 @@
|
||||
export const taskHandoffSchema = `
|
||||
alter table tasks add column handoff_json text;
|
||||
|
||||
insert into schema_migrations(version, name, applied_at)
|
||||
values (59, 'task_handoff', datetime('now'));
|
||||
`;
|
||||
@@ -57,6 +57,7 @@ import { taskOriginSchema } from "./migrations/055-task-origin";
|
||||
import { kimiProvidersSchema } from "./migrations/056-kimi-providers";
|
||||
import { kimiCodeProviderSchema } from "./migrations/057-kimi-code-provider";
|
||||
import { integrationKeysSchema } from "./migrations/058-integration-keys";
|
||||
import { taskHandoffSchema } from "./migrations/059-task-handoff";
|
||||
|
||||
interface Migration {
|
||||
version: number;
|
||||
@@ -123,6 +124,7 @@ const MIGRATIONS: Migration[] = [
|
||||
{ version: 56, name: "kimi_providers", sql: kimiProvidersSchema },
|
||||
{ version: 57, name: "kimi_code_provider", sql: kimiCodeProviderSchema },
|
||||
{ version: 58, name: "integration_keys", sql: integrationKeysSchema },
|
||||
{ version: 59, name: "task_handoff", sql: taskHandoffSchema },
|
||||
];
|
||||
|
||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import { optionalStringArray } from "./agent-tool-args";
|
||||
import type { JsonRecord } from "./provider-http";
|
||||
|
||||
export interface TaskHandoff {
|
||||
sourceTaskId: string | null;
|
||||
sourceThreadId: string | null;
|
||||
inputs: Array<{ fileId: string; name: string; sha256: string }>;
|
||||
facts: string[];
|
||||
openQuestions: string[];
|
||||
acceptanceCriteria: string[];
|
||||
}
|
||||
|
||||
export const HANDOFF_TOOL_PROPERTIES = {
|
||||
acceptance_criteria: { type: "array", items: { type: "string" }, description: "Checks that prove the task is done." },
|
||||
input_file_ids: { type: "array", items: { type: "string" }, description: "file_ids of upstream deliverables the assignee builds on; their current version is pinned." },
|
||||
facts: { type: "array", items: { type: "string" }, description: "Facts already verified, so the assignee does not redo them." },
|
||||
open_questions: { type: "array", items: { type: "string" }, description: "Questions still unresolved." },
|
||||
} as const;
|
||||
|
||||
export function buildTaskHandoff(
|
||||
db: DatabaseSync,
|
||||
orgId: string,
|
||||
source: { taskId: string | null; threadId: string | null },
|
||||
args: JsonRecord,
|
||||
): TaskHandoff {
|
||||
const inputs = optionalStringArray(args.input_file_ids, "input_file_ids").map((fileId) => {
|
||||
const file = db.prepare("select id, name from file_objects where id = ? and org_id = ?")
|
||||
.get(fileId, orgId) as { id: string; name: string } | undefined;
|
||||
if (!file) throw new Error(`No file with file_id "${fileId}" exists in this company.`);
|
||||
const sha256 = currentSha256(db, orgId, file.id);
|
||||
if (!sha256) throw new Error(`File ${file.name} (file_id "${fileId}") is missing on disk.`);
|
||||
return { fileId: file.id, name: file.name, sha256 };
|
||||
});
|
||||
return {
|
||||
sourceTaskId: source.taskId,
|
||||
sourceThreadId: source.threadId,
|
||||
inputs,
|
||||
facts: optionalStringArray(args.facts, "facts"),
|
||||
openQuestions: optionalStringArray(args.open_questions, "open_questions"),
|
||||
acceptanceCriteria: optionalStringArray(args.acceptance_criteria, "acceptance_criteria"),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderTaskHandoff(db: DatabaseSync, orgId: string, handoffJson: string | null): string[] {
|
||||
if (!handoffJson) return [];
|
||||
const handoff = JSON.parse(handoffJson) as TaskHandoff;
|
||||
const lines = ["", "Handoff:"];
|
||||
if (handoff.sourceTaskId) {
|
||||
const source = db.prepare("select title from tasks where id = ? and org_id = ?")
|
||||
.get(handoff.sourceTaskId, orgId) as { title: string } | undefined;
|
||||
lines.push(`- Delegated from task "${source?.title ?? "deleted task"}" (task_id=${handoff.sourceTaskId}).`);
|
||||
}
|
||||
if (handoff.sourceThreadId) lines.push(`- Source conversation: thread_id="${handoff.sourceThreadId}".`);
|
||||
for (const input of handoff.inputs) {
|
||||
const current = currentSha256(db, orgId, input.fileId);
|
||||
const state = !current ? "deleted since the handoff" : current === input.sha256 ? "unchanged since the handoff" : "CHANGED since the handoff — re-check it";
|
||||
lines.push(`- Input ${input.name} (file_id=${input.fileId}, sha256=${input.sha256.slice(0, 12)}): ${state}.`);
|
||||
}
|
||||
for (const fact of handoff.facts) lines.push(`- Verified: ${fact}`);
|
||||
for (const question of handoff.openQuestions) lines.push(`- Open question: ${question}`);
|
||||
for (const check of handoff.acceptanceCriteria) lines.push(`- Done when: ${check}`);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function currentSha256(db: DatabaseSync, orgId: string, fileId: string): string | null {
|
||||
const row = db.prepare("select storage_path from file_objects where id = ? and org_id = ?")
|
||||
.get(fileId, orgId) as { storage_path: string } | undefined;
|
||||
if (!row || !existsSync(row.storage_path)) return null;
|
||||
return createHash("sha256").update(readFileSync(row.storage_path)).digest("hex");
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { DatabaseSync } from "node:sqlite";
|
||||
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS, type RiskLevel } from "../../shared/domain";
|
||||
import { ORGANIZATION_DEPARTMENTS } from "./org-governance-service";
|
||||
import type { OrganizationService } from "./organization-service";
|
||||
import { buildTaskHandoff, HANDOFF_TOOL_PROPERTIES } from "./agent-task-handoff";
|
||||
import type { ChatService } from "./chat-service";
|
||||
import { canonicalMentionHandle } from "../../shared/mention-handles";
|
||||
import { readMessagesTool } from "./agent-read-messages";
|
||||
@@ -23,6 +24,9 @@ export interface AgentToolContext {
|
||||
agent: { id: string; modelProviderId: string | null; modelId: string | null };
|
||||
taskProjectId: string | null;
|
||||
taskGoalId: string | null;
|
||||
/** The task this run executes. */
|
||||
taskId: string | null;
|
||||
sourceThreadId: string | null;
|
||||
/** writeAccess mirrors the task's approved filesystem grant (read_write mount) and gates run_command. */
|
||||
workspace: { id: string; localPath: string; writeAccess: boolean } | null;
|
||||
}
|
||||
@@ -64,7 +68,7 @@ export const AGENT_TOOLS: ToolDefinition[] = [
|
||||
},
|
||||
{
|
||||
name: "create_task",
|
||||
description: "Create a task for the team and optionally assign it to one of your reports by name.",
|
||||
description: "Create a task for the team and optionally assign it to one of your reports by name. Hand over what you know: acceptance criteria, upstream file_ids, verified facts, and open questions.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
additionalProperties: false,
|
||||
@@ -77,6 +81,7 @@ export const AGENT_TOOLS: ToolDefinition[] = [
|
||||
type: "boolean",
|
||||
description: "Set true for engineering work that must change code or run commands (build, test) in the workspace. The assignee then gets run_command; the operator approves such tasks before they execute.",
|
||||
},
|
||||
...HANDOFF_TOOL_PROPERTIES,
|
||||
},
|
||||
required: ["title", "description"],
|
||||
},
|
||||
@@ -306,6 +311,7 @@ function createTask(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord
|
||||
const description = requireString(args.description, "description");
|
||||
const assigneeName = optionalString(args.assignee_agent_name);
|
||||
const assignee = assigneeName ? requireRunnableAgentByName(deps.db, ctx.orgId, assigneeName) : null;
|
||||
const handoff = buildTaskHandoff(deps.db, ctx.orgId, { taskId: ctx.taskId, threadId: ctx.sourceThreadId }, args);
|
||||
const create = () => {
|
||||
const taskId = deps.organization.createTask({
|
||||
orgId: ctx.orgId,
|
||||
@@ -320,6 +326,7 @@ function createTask(deps: AgentToolDeps, ctx: AgentToolContext, args: JsonRecord
|
||||
priority: TASK_PRIORITY.NORMAL,
|
||||
requiresFilesystem: args.requires_filesystem === true,
|
||||
});
|
||||
deps.db.prepare("update tasks set handoff_json = ? where id = ?").run(JSON.stringify(handoff), taskId);
|
||||
const assignmentThread = options.announceTaskAssignments !== false && assignee && assignee.id !== ctx.agent.id
|
||||
? postAssignmentMessage(deps, ctx, { taskId, title, description, assignee })
|
||||
: null;
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
type TaskRunIsolation,
|
||||
type WorkspaceRow,
|
||||
} from "./task-prompts";
|
||||
import { renderTaskHandoff } from "./agent-task-handoff";
|
||||
import { loadComposioTools } from "./composio/composio-tool-bridge";
|
||||
import { connectorDisplayName } from "./composio/connector-brands";
|
||||
import { ConnectorGrantsService } from "./composio/connector-grants";
|
||||
@@ -133,14 +134,16 @@ export class TaskExecutionService {
|
||||
let skillRuntimeSnapshot: SkillRuntimeSnapshotRow | null = null;
|
||||
try {
|
||||
skillRuntimeSnapshot = this.resolveSkillRuntimeSnapshot(orgId, agent.id, runId);
|
||||
const chatContext = this.loadChatContext(orgId, isolation);
|
||||
const toolContext: AgentToolContext = {
|
||||
orgId,
|
||||
agent: { id: agent.id, modelProviderId: agent.model_provider_id, modelId: agent.model_id },
|
||||
taskProjectId: task.project_id,
|
||||
taskGoalId: task.goal_id,
|
||||
taskId: task.id,
|
||||
sourceThreadId: chatContext?.threadId ?? null,
|
||||
workspace: workspace ? { id: workspace.id, localPath: workspace.local_path, writeAccess: task.requires_filesystem === 1 } : null,
|
||||
};
|
||||
const chatContext = this.loadChatContext(orgId, isolation);
|
||||
// The CEO is the c_suite role — the same predicate the grant system
|
||||
// enforces — not "has no manager", which any board hire can match.
|
||||
const isCeo = agent.role_level === "c_suite";
|
||||
@@ -177,6 +180,7 @@ export class TaskExecutionService {
|
||||
hasReports,
|
||||
managerName: this.agentName(orgId, agent.manager_agent_id),
|
||||
chatContext,
|
||||
handoffLines: renderTaskHandoff(this.db, orgId, task.handoff_json),
|
||||
priorCompletedRuns: this.completedRunCount(task.id, runId),
|
||||
}),
|
||||
toolContext,
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface TaskExecutionTaskRow {
|
||||
workspace_id: string | null;
|
||||
priority: string;
|
||||
requires_filesystem: number;
|
||||
handoff_json: string | null;
|
||||
}
|
||||
|
||||
export interface TaskExecutionAgentRow {
|
||||
@@ -163,6 +164,7 @@ export function buildTaskPrompt(input: {
|
||||
hasReports: boolean;
|
||||
managerName: string | null;
|
||||
chatContext: ChatContextBlock | null;
|
||||
handoffLines: string[];
|
||||
/** Completed runs this task already spent without reaching done. */
|
||||
priorCompletedRuns: number;
|
||||
}): string {
|
||||
@@ -194,6 +196,7 @@ export function buildTaskPrompt(input: {
|
||||
`Mounts: ${input.isolation.workspaceMountJson}`,
|
||||
);
|
||||
}
|
||||
lines.push(...input.handoffLines);
|
||||
lines.push("", `Secret scope: ${input.isolation.secretScopeJson}`);
|
||||
if (input.skillRuntimeSnapshot) {
|
||||
lines.push(
|
||||
|
||||
@@ -82,7 +82,7 @@ describe("interactive execution lane", () => {
|
||||
// Older autonomous work: the CEO DMs Dana, waking her.
|
||||
dispatchAgentTool({ db: connection.db, organization, chat }, {
|
||||
orgId, agent: { id: ceoId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null,
|
||||
}, call("send_message", { to: "Dana", body: "Grind through the migration backlog." }));
|
||||
|
||||
// Newer interactive work: the founder DMs the CEO.
|
||||
@@ -169,7 +169,7 @@ describe("interactive execution lane", () => {
|
||||
// Autonomous work that will trip the transient failure and park the provider.
|
||||
dispatchAgentTool({ db: connection.db, organization, chat }, {
|
||||
orgId, agent: { id: ceoId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null,
|
||||
}, call("send_message", { to: "Dana", body: "Grind through the migration backlog." }));
|
||||
|
||||
const stopWorker = startTaskQueueWorker({
|
||||
@@ -232,7 +232,7 @@ describe("interactive execution lane", () => {
|
||||
const taskExecution = new TaskExecutionService(connection.db, providers);
|
||||
const wakeDana = () => dispatchAgentTool({ db: connection.db, organization, chat }, {
|
||||
orgId, agent: { id: ceoId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null,
|
||||
}, call("send_message", { to: "Dana", body: "Grind through the migration backlog." }));
|
||||
|
||||
wakeDana();
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("thread participant wake", () => {
|
||||
const deps = { db: connection.db, organization, chat };
|
||||
const ctx: AgentToolContext = {
|
||||
orgId, agent: { id: ceoId, modelProviderId: providerId, modelId: "gpt-5.4" },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null,
|
||||
};
|
||||
|
||||
const firstResult = dispatchAgentTool(deps, ctx, call("send_message", { channel: "general", body: "The plan is ready for review." }));
|
||||
|
||||
@@ -57,7 +57,7 @@ function harness(options: { enableProvider: boolean }): Harness {
|
||||
orgId,
|
||||
agent: { id: ceoId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null,
|
||||
taskGoalId: null,
|
||||
taskGoalId: null, taskId: null, sourceThreadId: null,
|
||||
workspace: null,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -70,7 +70,7 @@ function argsFor(h: Harness, agentId: string, auditor: Auditor, toolCall: ToolCa
|
||||
orgId: h.orgId,
|
||||
agent: { id: agentId, modelProviderId: h.providerId, modelId: "gpt-5.4" },
|
||||
taskProjectId: null,
|
||||
taskGoalId: h.goalId,
|
||||
taskGoalId: h.goalId, taskId: null, sourceThreadId: null,
|
||||
workspace: { id: "wsp_audit", localPath: workspaceRoot, writeAccess: true },
|
||||
};
|
||||
return { deps: h.deps, ctx, providers: { runToolTurn: auditor }, providerId: h.providerId, workspaceId: null, call: toolCall };
|
||||
@@ -158,7 +158,7 @@ function toolCtx(h: Harness, agentId: string): AgentToolContext {
|
||||
orgId: h.orgId,
|
||||
agent: { id: agentId, modelProviderId: h.providerId, modelId: "gpt-5.4" },
|
||||
taskProjectId: null,
|
||||
taskGoalId: null,
|
||||
taskGoalId: null, taskId: null, sourceThreadId: null,
|
||||
workspace: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ describe("agent messaging", () => {
|
||||
const deps = { db: connection.db, organization, chat: new ChatService(connection.db) };
|
||||
const ctx: AgentToolContext = {
|
||||
orgId, agent: { id: ceoId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null,
|
||||
};
|
||||
|
||||
const first = dispatchAgentTool(deps, ctx, call("send_message", { channel: "engineering", body: "Assigning the booking API to Dana." }));
|
||||
@@ -85,7 +85,7 @@ describe("agent messaging", () => {
|
||||
const deps = { db: connection.db, organization, chat: new ChatService(connection.db) };
|
||||
const ctx: AgentToolContext = {
|
||||
orgId, agent: { id: ceoId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null,
|
||||
};
|
||||
|
||||
const result = dispatchAgentTool(deps, ctx, call("send_message", { channel: "engineering", body: "@Dana can you take this?" }));
|
||||
|
||||
@@ -66,7 +66,7 @@ describe("agent run loop (real tool dispatch)", () => {
|
||||
orgId,
|
||||
agent: { id: ceoId, modelProviderId: providerId, modelId: "gpt-5.4" },
|
||||
taskProjectId: null,
|
||||
taskGoalId: null,
|
||||
taskGoalId: null, taskId: null, sourceThreadId: null,
|
||||
workspace: { id: workspaceId, localPath: workspaceRoot, writeAccess: true },
|
||||
};
|
||||
|
||||
@@ -139,7 +139,7 @@ describe("agent run loop (real tool dispatch)", () => {
|
||||
});
|
||||
const ctx: AgentToolContext = {
|
||||
orgId, agent: { id: ceoId, modelProviderId: providerId, modelId: "gpt-5.4" },
|
||||
taskProjectId: null, taskGoalId: null, workspace: { id: "wsp", localPath: workspaceRoot, writeAccess: true },
|
||||
taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: { id: "wsp", localPath: workspaceRoot, writeAccess: true },
|
||||
};
|
||||
const script: ScriptedTurn[] = [
|
||||
{ text: "escape", toolCalls: [call("write_deliverable", { path: "../escape.txt", content: "x" })] },
|
||||
@@ -174,7 +174,7 @@ describe("agent run loop (real tool dispatch)", () => {
|
||||
});
|
||||
const ctx: AgentToolContext = {
|
||||
orgId, agent: { id: ceoId, modelProviderId: providerId, modelId: "gpt-5.4" },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null,
|
||||
};
|
||||
const script: ScriptedTurn[] = [
|
||||
{ text: "staff", toolCalls: [call("hire_agent", { name: "Alex Rivera", role: "Engineer", department: "Engineering" })] },
|
||||
@@ -226,7 +226,7 @@ describe("agent run loop (real tool dispatch)", () => {
|
||||
});
|
||||
const ctx: AgentToolContext = {
|
||||
orgId, agent: { id: ceoId, modelProviderId: providerId, modelId: "gpt-5.4" },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null,
|
||||
};
|
||||
await expect(runAgentLoop(
|
||||
{ providers: fakeProvider([{ text: "", toolCalls: [] }], connection.db), tools: { db: connection.db, organization, chat: new ChatService(connection.db) } },
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
import { ChatService } from "../../src/main/services/chat-service";
|
||||
import { dispatchAgentTool, type AgentToolContext } from "../../src/main/services/agent-tools";
|
||||
import { renderTaskHandoff } from "../../src/main/services/agent-task-handoff";
|
||||
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../src/shared/domain";
|
||||
import type { ToolCall } from "../../src/main/services/provider-tool-turn";
|
||||
|
||||
function sha256(text: string): string {
|
||||
return createHash("sha256").update(text).digest("hex");
|
||||
}
|
||||
|
||||
function call(name: string, args: Record<string, unknown>): ToolCall {
|
||||
return { id: `call_${randomUUID()}`, name, arguments: args };
|
||||
}
|
||||
|
||||
function harness() {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-handoff-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
let ceoId = "";
|
||||
let sourceTaskId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({ organizationName: "HandoffCo", employeeName: "Yang", mission: "m", goal: "g" });
|
||||
const providerId = (connection.db.prepare("select id from providers where org_id = ? and provider_key = 'openai'").get(orgId) as { id: string }).id;
|
||||
ceoId = organization.createAgent({
|
||||
orgId, name: "CEO", role: "CEO", department: "Executive",
|
||||
modelProviderId: providerId, modelId: "gpt-5.4", budgetCents: 25000, managerAgentId: null, roleLevel: "c_suite",
|
||||
});
|
||||
sourceTaskId = organization.createTask({
|
||||
orgId, title: "Plan the launch", description: "d", status: TASK_STATUS.IN_PROGRESS, assigneeAgentId: ceoId,
|
||||
risk: RISK_LEVEL.LOW, projectId: null, goalId: null, workspaceId: null, priority: TASK_PRIORITY.NORMAL, requiresFilesystem: false,
|
||||
});
|
||||
});
|
||||
const specPath = join(tmpdir(), `ac-handoff-spec-${randomUUID()}.md`);
|
||||
writeFileSync(specPath, "v1");
|
||||
connection.db.prepare(`
|
||||
insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at)
|
||||
values ('fil_spec', ?, 'spec.md', 'document', 2, ?, ?, 'agent', ?, ?)
|
||||
`).run(orgId, sha256("v1"), specPath, ceoId, new Date().toISOString());
|
||||
const deps = { db: connection.db, organization, chat: new ChatService(connection.db) };
|
||||
const ctx: AgentToolContext = {
|
||||
orgId, agent: { id: ceoId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null, taskGoalId: null, taskId: sourceTaskId, sourceThreadId: "thr_src", workspace: null,
|
||||
};
|
||||
return { connection, deps, ctx, orgId, sourceTaskId, specPath };
|
||||
}
|
||||
|
||||
describe("structured task handoff", () => {
|
||||
test("create_task pins inputs and carries facts, questions, and acceptance criteria", () => {
|
||||
const h = harness();
|
||||
const result = dispatchAgentTool(h.deps, h.ctx, call("create_task", {
|
||||
title: "Build the API", description: "Ship it.",
|
||||
input_file_ids: ["fil_spec"], facts: ["Postgres is the store"], open_questions: ["Rate limits?"],
|
||||
acceptance_criteria: ["All endpoint tests pass"],
|
||||
}));
|
||||
expect(result).toContain("Created task");
|
||||
const taskId = /task_id=([^)\s]+)\)/.exec(result)![1];
|
||||
const row = h.connection.db.prepare("select handoff_json from tasks where id = ?").get(taskId) as { handoff_json: string };
|
||||
expect(JSON.parse(row.handoff_json)).toEqual({
|
||||
sourceTaskId: h.sourceTaskId,
|
||||
sourceThreadId: "thr_src",
|
||||
inputs: [{ fileId: "fil_spec", name: "spec.md", sha256: sha256("v1") }],
|
||||
facts: ["Postgres is the store"],
|
||||
openQuestions: ["Rate limits?"],
|
||||
acceptanceCriteria: ["All endpoint tests pass"],
|
||||
});
|
||||
|
||||
const before = renderTaskHandoff(h.connection.db, h.orgId, row.handoff_json).join("\n");
|
||||
expect(before).toContain('Delegated from task "Plan the launch"');
|
||||
expect(before).toContain('thread_id="thr_src"');
|
||||
expect(before).toContain(`spec.md (file_id=fil_spec, sha256=${sha256("v1").slice(0, 12)}): unchanged since the handoff`);
|
||||
expect(before).toContain("Verified: Postgres is the store");
|
||||
expect(before).toContain("Open question: Rate limits?");
|
||||
expect(before).toContain("Done when: All endpoint tests pass");
|
||||
|
||||
writeFileSync(h.specPath, "v2 edited outside the file registry");
|
||||
expect(renderTaskHandoff(h.connection.db, h.orgId, row.handoff_json).join("\n")).toContain("CHANGED since the handoff");
|
||||
rmSync(h.specPath);
|
||||
expect(renderTaskHandoff(h.connection.db, h.orgId, row.handoff_json).join("\n")).toContain("deleted since the handoff");
|
||||
h.connection.close();
|
||||
});
|
||||
|
||||
test("an unknown input file id fails the call and creates nothing", () => {
|
||||
const h = harness();
|
||||
expect(dispatchAgentTool(h.deps, h.ctx, call("create_task", { title: "X", description: "d", input_file_ids: ["fil_missing"] })))
|
||||
.toBe('ERROR: No file with file_id "fil_missing" exists in this company.');
|
||||
expect(h.connection.db.prepare("select count(*) as n from tasks where title = 'X'").get()).toEqual({ n: 0 });
|
||||
h.connection.close();
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ function ctx(writeAccess: boolean): AgentToolContext {
|
||||
orgId: "org_test",
|
||||
agent: { id: "agt_test", modelProviderId: null, modelId: null },
|
||||
taskProjectId: null,
|
||||
taskGoalId: null,
|
||||
taskGoalId: null, taskId: null, sourceThreadId: null,
|
||||
workspace: { id: "wsp_test", localPath: workspaceRoot, writeAccess },
|
||||
};
|
||||
}
|
||||
@@ -91,7 +91,7 @@ describe("agent workspace tools", () => {
|
||||
const deps = { db: connection.db, organization, chat: new ChatService(connection.db) };
|
||||
const toolCtx: AgentToolContext = {
|
||||
orgId, agent: { id: ceoId, modelProviderId: providerId, modelId: "gpt-5.4" },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null,
|
||||
};
|
||||
|
||||
const result = dispatchAgentTool(deps, toolCtx, call("create_task", {
|
||||
|
||||
@@ -162,7 +162,7 @@ describe("built-in skills", () => {
|
||||
orgId: h.orgId,
|
||||
agent: { id: ceoId, modelProviderId: providerId, modelId: "gpt-5.4" },
|
||||
taskProjectId: null,
|
||||
taskGoalId: null,
|
||||
taskGoalId: null, taskId: null, sourceThreadId: null,
|
||||
workspace: null,
|
||||
};
|
||||
const deps = { db: h.connection.db, organization: h.organization, chat: new ChatService(h.connection.db) };
|
||||
|
||||
@@ -50,7 +50,7 @@ describe("queue claim priority", () => {
|
||||
dispatchAgentTool(deps, {
|
||||
orgId: h.orgId,
|
||||
agent: { id: h.ceoId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null,
|
||||
}, { id: `call_${randomUUID()}`, name: "send_message", arguments: { to: "Dana", body: "Status?" } });
|
||||
const danaRun = h.connection.db.prepare(
|
||||
"select id from task_runs where org_id = ? and agent_id = ? and trigger = 'chat_mention'",
|
||||
|
||||
@@ -44,7 +44,7 @@ function harness(): Harness {
|
||||
orgId,
|
||||
agent: { id: agentId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null,
|
||||
taskGoalId: null,
|
||||
taskGoalId: null, taskId: null, sourceThreadId: null,
|
||||
workspace: null,
|
||||
});
|
||||
return {
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("domain services", () => {
|
||||
const connection = createConnection();
|
||||
|
||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58]);
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59]);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ function harness(mode: "ai_ceo" | "ai_cofounder"): Harness {
|
||||
orgId,
|
||||
agent: { id: ceoId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null,
|
||||
taskGoalId: null,
|
||||
taskGoalId: null, taskId: null, sourceThreadId: null,
|
||||
workspace: { id: workspaceId, localPath: workspaceRoot, writeAccess: true },
|
||||
},
|
||||
deps: { db: connection.db, organization, chat: new ChatService(connection.db) },
|
||||
|
||||
@@ -45,7 +45,7 @@ describe("mention resolution", () => {
|
||||
const deps = { db: connection.db, organization, chat: new ChatService(connection.db) };
|
||||
const ctx: AgentToolContext = {
|
||||
orgId, agent: { id: ceoId, modelProviderId: providerId, modelId: "gpt-5.4" },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null,
|
||||
};
|
||||
|
||||
const result = dispatchAgentTool(deps, ctx, call("send_message", {
|
||||
@@ -79,7 +79,7 @@ describe("mention resolution", () => {
|
||||
const deps = { db: connection.db, organization, chat: new ChatService(connection.db) };
|
||||
const ctx: AgentToolContext = {
|
||||
orgId, agent: { id: ceoId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null,
|
||||
};
|
||||
|
||||
dispatchAgentTool(deps, ctx, call("send_message", { channel: "general", body: "Hello team!" }));
|
||||
@@ -113,14 +113,14 @@ describe("mention resolution", () => {
|
||||
const deps = { db: connection.db, organization, chat: new ChatService(connection.db) };
|
||||
const ceoCtx: AgentToolContext = {
|
||||
orgId, agent: { id: ceoId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null,
|
||||
};
|
||||
|
||||
dispatchAgentTool(deps, ceoCtx, call("send_message", { channel: "engineering", body: "@dana check the deployment" }));
|
||||
|
||||
const danaCtx: AgentToolContext = {
|
||||
orgId, agent: { id: engineerId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null, taskGoalId: null, workspace: null,
|
||||
taskProjectId: null, taskGoalId: null, taskId: null, sourceThreadId: null, workspace: null,
|
||||
};
|
||||
const inbox = dispatchAgentTool(deps, danaCtx, call("read_messages", {}));
|
||||
expect(inbox).toContain("check the deployment");
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("org structure", () => {
|
||||
orgId,
|
||||
agent: { id: agentId, modelProviderId: providerId, modelId: "gpt-5.4" },
|
||||
taskProjectId: null,
|
||||
taskGoalId: null,
|
||||
taskGoalId: null, taskId: null, sourceThreadId: null,
|
||||
workspace: null,
|
||||
});
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ function harness(): Harness {
|
||||
orgId,
|
||||
agent: { id: agentId, modelProviderId: null, modelId: null },
|
||||
taskProjectId: null,
|
||||
taskGoalId: null,
|
||||
taskGoalId: null, taskId: null, sourceThreadId: null,
|
||||
workspace: null,
|
||||
});
|
||||
return {
|
||||
|
||||
Reference in new issue
Block a user