feat(agent): add controlled task execution
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
|
||||
import {
|
||||
bindOperationalTaskCredential,
|
||||
humanTaskEvidenceCommandSchema,
|
||||
OperationalTaskClient,
|
||||
proposedTaskActionSchema,
|
||||
releaseOperationalTaskCredential,
|
||||
} from "../../src/product/operational-task-client.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const taskId = "50000000-0000-4000-8000-000000000001";
|
||||
const runId = "55000000-0000-4000-8000-000000000001";
|
||||
const executionRequestId = "60000000-0000-4000-8000-000000000001";
|
||||
const run = {
|
||||
run_id: runId,
|
||||
task_id: taskId,
|
||||
generation: 1,
|
||||
execution_request_id: executionRequestId,
|
||||
status: "running",
|
||||
revision: 2,
|
||||
agent_session_id: "session-1",
|
||||
lease_expires_at: "2026-09-23T01:01:00Z",
|
||||
heartbeat_at: "2026-09-23T01:00:00Z",
|
||||
started_at: "2026-09-23T01:00:00Z",
|
||||
finished_at: null,
|
||||
failure_code: null,
|
||||
failure_detail: null,
|
||||
dispatch_attempts: 0,
|
||||
next_dispatch_at: "2026-09-23T01:00:00Z",
|
||||
created_at: "2026-09-23T01:00:00Z",
|
||||
updated_at: "2026-09-23T01:00:00Z",
|
||||
};
|
||||
|
||||
const task = {
|
||||
task_id: taskId,
|
||||
task_type: "event_investigation",
|
||||
workflow: "water_quality_complaint",
|
||||
lifecycle_status: "open",
|
||||
active_run: run,
|
||||
status: "running",
|
||||
revision: 2,
|
||||
execution_request_id: executionRequestId,
|
||||
title: "水质投诉调查",
|
||||
summary: "调查投诉",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
releaseOperationalTaskCredential("runtime-1");
|
||||
});
|
||||
|
||||
describe("OperationalTaskClient", () => {
|
||||
it("matches proposed action payloads to the generated Server contract", () => {
|
||||
const common = {
|
||||
request_id: "60000000-0000-4000-8000-000000000001",
|
||||
kind: "create_work_order" as const,
|
||||
reason: "需要现场采样",
|
||||
evidence_refs: [],
|
||||
node_id: "field_sampling",
|
||||
};
|
||||
expect(proposedTaskActionSchema.safeParse({
|
||||
...common,
|
||||
payload: {
|
||||
title: "现场采样",
|
||||
description: "采集二次供水出水和末端水样",
|
||||
work_order_type: "water_quality_sampling",
|
||||
priority: "urgent",
|
||||
location: "联调演示小区",
|
||||
},
|
||||
}).success).toBe(true);
|
||||
expect(proposedTaskActionSchema.safeParse({
|
||||
...common,
|
||||
payload: {
|
||||
title: "现场采样",
|
||||
summary: "字段名不属于后端合同",
|
||||
priority: "high",
|
||||
},
|
||||
}).success).toBe(false);
|
||||
expect(proposedTaskActionSchema.safeParse({
|
||||
...common,
|
||||
kind: "issue_command",
|
||||
payload: {
|
||||
title: "现场采样",
|
||||
description: "错误动作类型不得复用工单载荷",
|
||||
work_order_type: "water_quality_sampling",
|
||||
},
|
||||
}).success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts only explicit human input on the collaboration evidence path", () => {
|
||||
const evidence = {
|
||||
request_id: "60000000-0000-4000-8000-000000000001",
|
||||
evidence: {
|
||||
evidence_id: "human-confirmation-1",
|
||||
kind: "human_input",
|
||||
summary: "现场人员确认已完成采样。",
|
||||
source_label: "调度员补充",
|
||||
observed_at: "2026-09-23T01:00:00Z",
|
||||
},
|
||||
};
|
||||
expect(humanTaskEvidenceCommandSchema.safeParse(evidence).success).toBe(true);
|
||||
expect(humanTaskEvidenceCommandSchema.safeParse({
|
||||
...evidence,
|
||||
evidence: { ...evidence.evidence, kind: "system_data" },
|
||||
}).success).toBe(false);
|
||||
});
|
||||
|
||||
it("uses an in-memory credential and converts plans to the server contract", async () => {
|
||||
let request: Request | undefined;
|
||||
globalThis.fetch = (async (input, init) => {
|
||||
request = new Request(input, init);
|
||||
if (request.url.endsWith(`/runs/${runId}/claim`)) return Response.json(run);
|
||||
return Response.json({
|
||||
task,
|
||||
plan_revision: 1,
|
||||
report_revision: null,
|
||||
});
|
||||
}) as typeof fetch;
|
||||
bindOperationalTaskCredential("runtime-1", {
|
||||
authorization: "Bearer transient-secret",
|
||||
projectId: "project-1",
|
||||
});
|
||||
const client = new OperationalTaskClient("http://server.test");
|
||||
await client.claimTask("runtime-1", taskId, runId, {
|
||||
requestId: crypto.randomUUID(), expectedRevision: 1, sourceSessionId: "session-1",
|
||||
});
|
||||
await client.publishArtifacts("runtime-1", taskId, {
|
||||
requestId: executionRequestId,
|
||||
sourceSessionId: "session-1",
|
||||
executionPlan: {
|
||||
title: "调查",
|
||||
reason: "更新",
|
||||
nodes: [{ id: "READ", title: "读取", owner: "agent", status: "completed", detail: "完成", evidence: ["evidence-1"] }],
|
||||
edges: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(request?.headers.get("authorization")).toBe("Bearer transient-secret");
|
||||
expect(request?.headers.get("x-project-id")).toBe("project-1");
|
||||
const body = await request?.json() as Record<string, unknown>;
|
||||
expect(body).toMatchObject({
|
||||
request_id: "60000000-0000-4000-8000-000000000001",
|
||||
source_session_id: "session-1",
|
||||
run_id: runId,
|
||||
plan: {
|
||||
schema: "execution-plan@1",
|
||||
nodes: [{ evidence_refs: ["evidence-1"] }],
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify(body)).not.toContain("transient-secret");
|
||||
});
|
||||
|
||||
it("fails closed without a current product credential", async () => {
|
||||
const client = new OperationalTaskClient("http://server.test");
|
||||
expect(client.getAgentContext("runtime-1", "50000000-0000-4000-8000-000000000001"))
|
||||
.rejects.toThrow("current product credential");
|
||||
});
|
||||
|
||||
it("preserves the Server problem code for lease authority failures", async () => {
|
||||
let call = 0;
|
||||
globalThis.fetch = (async (_input, _init) => {
|
||||
call += 1;
|
||||
if (call === 1) return Response.json(run);
|
||||
return Response.json({
|
||||
type: "https://tjwater.example/problems/lease-expired",
|
||||
title: "Conflict",
|
||||
status: 409,
|
||||
detail: "Agent lease has expired",
|
||||
code: "LEASE_EXPIRED",
|
||||
}, { status: 409 });
|
||||
}) as typeof fetch;
|
||||
bindOperationalTaskCredential("runtime-1", {
|
||||
authorization: "Bearer transient-secret",
|
||||
projectId: "project-1",
|
||||
});
|
||||
const client = new OperationalTaskClient("http://server.test");
|
||||
await client.claimTask("runtime-1", taskId, runId, {
|
||||
requestId: crypto.randomUUID(), expectedRevision: 1, sourceSessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(client.publishArtifacts(
|
||||
"runtime-1",
|
||||
taskId,
|
||||
{
|
||||
requestId: "60000000-0000-4000-8000-000000000001",
|
||||
sourceSessionId: "session-1",
|
||||
report: {
|
||||
title: "失败",
|
||||
current_conclusion: "租约已失效",
|
||||
conclusion_boundary: "未继续写回",
|
||||
evidence_refs: [],
|
||||
recommendations: [],
|
||||
pending_items: [],
|
||||
},
|
||||
},
|
||||
)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
code: "LEASE_EXPIRED",
|
||||
message: "Agent lease has expired",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the persisted bounded data snapshot with its evidence id", async () => {
|
||||
const urls: string[] = [];
|
||||
globalThis.fetch = (async (input) => {
|
||||
const url = String(input);
|
||||
urls.push(url);
|
||||
return urls.length === 1
|
||||
? Response.json({
|
||||
evidence_id: "scada_readings:sha256:abc",
|
||||
kind: "system_data",
|
||||
summary: "scada_readings 数据快照",
|
||||
source_label: "scada_readings",
|
||||
source_ref: "scada_readings:sha256:abc",
|
||||
observed_at: "2026-09-23T01:00:00Z",
|
||||
})
|
||||
: Response.json({
|
||||
source: "scada_readings",
|
||||
source_ref: "scada_readings:sha256:abc",
|
||||
queried_at: "2026-09-23T01:00:00Z",
|
||||
origin: "system_data",
|
||||
data: { items: [{ value: 0.42 }] },
|
||||
truncated: false,
|
||||
});
|
||||
}) as typeof fetch;
|
||||
bindOperationalTaskCredential("runtime-1", {
|
||||
authorization: "Bearer transient-secret",
|
||||
projectId: "project-1",
|
||||
});
|
||||
const client = new OperationalTaskClient("http://server.test");
|
||||
|
||||
const result = await client.queryData("runtime-1", "50000000-0000-4000-8000-000000000001", {
|
||||
source: "scada_readings",
|
||||
device_ids: ["device-1"],
|
||||
start: "2026-09-23T00:00:00Z",
|
||||
end: "2026-09-23T01:00:00Z",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
evidence: { evidence_id: "scada_readings:sha256:abc" },
|
||||
snapshot: { origin: "system_data", truncated: false, data: { items: [{ value: 0.42 }] } },
|
||||
});
|
||||
expect(urls[1]).toContain("/evidence/scada_readings%3Asha256%3Aabc/snapshot");
|
||||
});
|
||||
|
||||
it("keeps the current task revision for heartbeats after an action-only response", async () => {
|
||||
const requestBodies: Record<string, unknown>[] = [];
|
||||
let call = 0;
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
if (init?.body) requestBodies.push(JSON.parse(String(init.body)) as Record<string, unknown>);
|
||||
call += 1;
|
||||
if (call === 1) return Response.json(run);
|
||||
if (call === 2) return Response.json({
|
||||
action_id: "70000000-0000-4000-8000-000000000001",
|
||||
approval_status: "pending",
|
||||
execution_status: "not_submitted",
|
||||
});
|
||||
return Response.json({ ...run, revision: 3 });
|
||||
}) as typeof fetch;
|
||||
bindOperationalTaskCredential("runtime-1", {
|
||||
authorization: "Bearer transient-secret",
|
||||
projectId: "project-1",
|
||||
});
|
||||
const client = new OperationalTaskClient("http://server.test");
|
||||
|
||||
await client.claimTask("runtime-1", taskId, runId, {
|
||||
requestId: "60000000-0000-4000-8000-000000000002",
|
||||
expectedRevision: 1,
|
||||
sourceSessionId: "session-1",
|
||||
});
|
||||
await client.proposeAction("runtime-1", "50000000-0000-4000-8000-000000000001", {
|
||||
request_id: "60000000-0000-4000-8000-000000000003",
|
||||
kind: "create_work_order",
|
||||
reason: "需要现场采样",
|
||||
payload: {
|
||||
title: "现场采样",
|
||||
description: "采集水样",
|
||||
work_order_type: "water_quality_sampling",
|
||||
priority: "urgent",
|
||||
},
|
||||
evidence_refs: [],
|
||||
});
|
||||
await client.heartbeatTask(
|
||||
"runtime-1",
|
||||
taskId,
|
||||
runId,
|
||||
"session-1",
|
||||
);
|
||||
|
||||
expect(requestBodies[2]).toMatchObject({
|
||||
expected_revision: 2,
|
||||
source_session_id: "session-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes artifact publication and heartbeat revision updates", async () => {
|
||||
const requestBodies: Record<string, unknown>[] = [];
|
||||
let releaseArtifact: (() => void) | undefined;
|
||||
const artifactGate = new Promise<void>((resolve) => { releaseArtifact = resolve; });
|
||||
let call = 0;
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
if (init?.body) requestBodies.push(JSON.parse(String(init.body)) as Record<string, unknown>);
|
||||
call += 1;
|
||||
if (call === 1) return Response.json(run);
|
||||
if (call === 2) {
|
||||
await artifactGate;
|
||||
return Response.json({
|
||||
task: { ...task, active_run: { ...run, revision: 3 } },
|
||||
plan_revision: 1,
|
||||
report_revision: null,
|
||||
});
|
||||
}
|
||||
return Response.json({ ...run, revision: 4 });
|
||||
}) as typeof fetch;
|
||||
bindOperationalTaskCredential("runtime-1", {
|
||||
authorization: "Bearer transient-secret",
|
||||
projectId: "project-1",
|
||||
});
|
||||
const client = new OperationalTaskClient("http://server.test");
|
||||
await client.claimTask("runtime-1", taskId, runId, {
|
||||
requestId: executionRequestId,
|
||||
expectedRevision: 1,
|
||||
sourceSessionId: "session-1",
|
||||
});
|
||||
|
||||
const publication = client.publishArtifacts("runtime-1", taskId, {
|
||||
requestId: "60000000-0000-4000-8000-000000000002",
|
||||
sourceSessionId: "session-1",
|
||||
executionPlan: {
|
||||
title: "调查计划",
|
||||
reason: "核实告警",
|
||||
nodes: [],
|
||||
edges: [],
|
||||
},
|
||||
});
|
||||
const heartbeat = client.heartbeatTask("runtime-1", taskId, runId, "session-1");
|
||||
releaseArtifact?.();
|
||||
await Promise.all([publication, heartbeat]);
|
||||
|
||||
expect(requestBodies[2]).toMatchObject({ expected_revision: 3 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user