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
@@ -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