From 6570492daadd763679fe5fabdcd88a3799f66d22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 02:35:12 -0400 Subject: [PATCH] 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 --- .../database/migrations/059-task-handoff.ts | 6 ++ src/main/database/schema.ts | 2 + src/main/services/agent-task-handoff.ts | 73 ++++++++++++++ src/main/services/agent-tools.ts | 9 +- src/main/services/task-execution-service.ts | 6 +- src/main/services/task-prompts.ts | 3 + tests/integration/interactive-lane.test.ts | 6 +- .../thread-participant-wake.test.ts | 2 +- tests/unit/agent-direct-messages.test.ts | 2 +- tests/unit/agent-goal-tools.test.ts | 4 +- tests/unit/agent-messaging.test.ts | 4 +- tests/unit/agent-run-loop.test.ts | 8 +- tests/unit/agent-task-handoff.test.ts | 96 +++++++++++++++++++ tests/unit/agent-workspace-tools.test.ts | 4 +- tests/unit/builtin-skills.test.ts | 2 +- tests/unit/claim-priority.test.ts | 2 +- tests/unit/connector-grants.test.ts | 2 +- tests/unit/domain-services.test.ts | 2 +- tests/unit/execution-modes.test.ts | 2 +- tests/unit/mention-resolution.test.ts | 8 +- tests/unit/org-structure.test.ts | 2 +- tests/unit/skill-grants.test.ts | 2 +- 22 files changed, 219 insertions(+), 28 deletions(-) create mode 100644 src/main/database/migrations/059-task-handoff.ts create mode 100644 src/main/services/agent-task-handoff.ts create mode 100644 tests/unit/agent-task-handoff.test.ts diff --git a/src/main/database/migrations/059-task-handoff.ts b/src/main/database/migrations/059-task-handoff.ts new file mode 100644 index 00000000..16c667e1 --- /dev/null +++ b/src/main/database/migrations/059-task-handoff.ts @@ -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')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index e041bf80..b29baa6c 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -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; diff --git a/src/main/services/agent-task-handoff.ts b/src/main/services/agent-task-handoff.ts new file mode 100644 index 00000000..e9760245 --- /dev/null +++ b/src/main/services/agent-task-handoff.ts @@ -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"); +} diff --git a/src/main/services/agent-tools.ts b/src/main/services/agent-tools.ts index 0ebb08dc..ed1dc628 100644 --- a/src/main/services/agent-tools.ts +++ b/src/main/services/agent-tools.ts @@ -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; diff --git a/src/main/services/task-execution-service.ts b/src/main/services/task-execution-service.ts index b2d976f3..28271085 100644 --- a/src/main/services/task-execution-service.ts +++ b/src/main/services/task-execution-service.ts @@ -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, diff --git a/src/main/services/task-prompts.ts b/src/main/services/task-prompts.ts index 570c46d8..328b9671 100644 --- a/src/main/services/task-prompts.ts +++ b/src/main/services/task-prompts.ts @@ -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( diff --git a/tests/integration/interactive-lane.test.ts b/tests/integration/interactive-lane.test.ts index d5b01246..e4e258f0 100644 --- a/tests/integration/interactive-lane.test.ts +++ b/tests/integration/interactive-lane.test.ts @@ -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(); diff --git a/tests/integration/thread-participant-wake.test.ts b/tests/integration/thread-participant-wake.test.ts index cbe298c3..c803f011 100644 --- a/tests/integration/thread-participant-wake.test.ts +++ b/tests/integration/thread-participant-wake.test.ts @@ -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." })); diff --git a/tests/unit/agent-direct-messages.test.ts b/tests/unit/agent-direct-messages.test.ts index 3d2fc566..9e55a116 100644 --- a/tests/unit/agent-direct-messages.test.ts +++ b/tests/unit/agent-direct-messages.test.ts @@ -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, }, }; diff --git a/tests/unit/agent-goal-tools.test.ts b/tests/unit/agent-goal-tools.test.ts index b537c55e..53d05dc4 100644 --- a/tests/unit/agent-goal-tools.test.ts +++ b/tests/unit/agent-goal-tools.test.ts @@ -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, }; } diff --git a/tests/unit/agent-messaging.test.ts b/tests/unit/agent-messaging.test.ts index 8424e55c..8f4d5318 100644 --- a/tests/unit/agent-messaging.test.ts +++ b/tests/unit/agent-messaging.test.ts @@ -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?" })); diff --git a/tests/unit/agent-run-loop.test.ts b/tests/unit/agent-run-loop.test.ts index c566d21d..9c25d9ee 100644 --- a/tests/unit/agent-run-loop.test.ts +++ b/tests/unit/agent-run-loop.test.ts @@ -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) } }, diff --git a/tests/unit/agent-task-handoff.test.ts b/tests/unit/agent-task-handoff.test.ts new file mode 100644 index 00000000..584d36d9 --- /dev/null +++ b/tests/unit/agent-task-handoff.test.ts @@ -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): 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(); + }); +}); diff --git a/tests/unit/agent-workspace-tools.test.ts b/tests/unit/agent-workspace-tools.test.ts index 7f92e4f0..e2fe5dd5 100644 --- a/tests/unit/agent-workspace-tools.test.ts +++ b/tests/unit/agent-workspace-tools.test.ts @@ -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", { diff --git a/tests/unit/builtin-skills.test.ts b/tests/unit/builtin-skills.test.ts index f51fd901..08eb4891 100644 --- a/tests/unit/builtin-skills.test.ts +++ b/tests/unit/builtin-skills.test.ts @@ -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) }; diff --git a/tests/unit/claim-priority.test.ts b/tests/unit/claim-priority.test.ts index 523631f5..d103a35f 100644 --- a/tests/unit/claim-priority.test.ts +++ b/tests/unit/claim-priority.test.ts @@ -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'", diff --git a/tests/unit/connector-grants.test.ts b/tests/unit/connector-grants.test.ts index b8c2cc4e..bfc177e2 100644 --- a/tests/unit/connector-grants.test.ts +++ b/tests/unit/connector-grants.test.ts @@ -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 { diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 4301c3f8..be28145e 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -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(); }); diff --git a/tests/unit/execution-modes.test.ts b/tests/unit/execution-modes.test.ts index 10f24647..8cd9101e 100644 --- a/tests/unit/execution-modes.test.ts +++ b/tests/unit/execution-modes.test.ts @@ -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) }, diff --git a/tests/unit/mention-resolution.test.ts b/tests/unit/mention-resolution.test.ts index f7b492bc..90371654 100644 --- a/tests/unit/mention-resolution.test.ts +++ b/tests/unit/mention-resolution.test.ts @@ -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"); diff --git a/tests/unit/org-structure.test.ts b/tests/unit/org-structure.test.ts index 413dad6e..bfce06b8 100644 --- a/tests/unit/org-structure.test.ts +++ b/tests/unit/org-structure.test.ts @@ -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, }); diff --git a/tests/unit/skill-grants.test.ts b/tests/unit/skill-grants.test.ts index 0f443f00..a9435e8c 100644 --- a/tests/unit/skill-grants.test.ts +++ b/tests/unit/skill-grants.test.ts @@ -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 {