344 lines
12 KiB
TypeScript
344 lines
12 KiB
TypeScript
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 });
|
|
});
|
|
});
|