feat(agent): add controlled task execution
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import {
|
||||
agentTriggerContextSchema,
|
||||
buildAgentTriggerPrompt,
|
||||
} from "../../src/routes/agentTrigger.js";
|
||||
|
||||
const trigger = {
|
||||
schema_version: "agent-trigger@1",
|
||||
kind: "event",
|
||||
trigger_id: "peripheral.work_order.created:project-1:50000000-0000-4000-8000-000000000001:1",
|
||||
occurred_at: "2026-09-22T10:00:00+08:00",
|
||||
source: "peripheral_sample",
|
||||
subject_type: "work_order",
|
||||
subject_id: "50000000-0000-4000-8000-000000000001",
|
||||
workflow: "water_quality_complaint",
|
||||
payload: {
|
||||
title: "居民反映自来水有异味",
|
||||
summary: "演示小区多户居民反映晨间用水有异味。",
|
||||
location: "演示小区",
|
||||
priority: "high",
|
||||
scenario_code: "WQ-SECONDARY-SUPPLY-001",
|
||||
origin: "synthetic",
|
||||
},
|
||||
} as const;
|
||||
|
||||
describe("agent event trigger", () => {
|
||||
it("accepts the water-quality work-order contract and builds a bounded prompt", () => {
|
||||
const parsed = agentTriggerContextSchema.parse(trigger);
|
||||
const prompt = buildAgentTriggerPrompt(parsed);
|
||||
|
||||
expect(prompt).toContain("水质监测点");
|
||||
expect(prompt).toContain("二次供水");
|
||||
expect(prompt).toContain("合成演练数据");
|
||||
expect(prompt).toContain("疑似二次供水环节问题(待现场核验)");
|
||||
expect(prompt).toContain(trigger.trigger_id);
|
||||
});
|
||||
|
||||
it("rejects a different scenario instead of routing arbitrary work orders", () => {
|
||||
expect(agentTriggerContextSchema.safeParse({
|
||||
...trigger,
|
||||
payload: { ...trigger.payload, scenario_code: "UNKNOWN" },
|
||||
}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { createExecutionPlanSnapshot, executionPlanNeedsTaskPublish, executionPlanSchema, readExecutionPlans } from "../../src/routes/executionPlan.js";
|
||||
import { streamPromptResponse } from "../../src/routes/chatStream.js";
|
||||
import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
||||
|
||||
const plan = {
|
||||
title: "水质调查", reason: "收到投诉",
|
||||
nodes: [
|
||||
{ id: "upstream", title: "上游监测", status: "completed", owner: "agent", detail: "合成数据", evidence: ["场景输入:上游指标正常"] },
|
||||
{ id: "sample", title: "现场采样", status: "waiting", owner: "human", detail: "等待现场记录", evidence: [] },
|
||||
],
|
||||
edges: [{ from: "upstream", to: "sample", label: "上游正常" }],
|
||||
};
|
||||
|
||||
describe("execution plans", () => {
|
||||
it("rejects missing dependencies, cycles, duplicate IDs and unsupported completion", () => {
|
||||
for (const invalid of [
|
||||
{ ...plan, edges: [{ from: "unknown", to: "sample", label: "" }] },
|
||||
{ ...plan, edges: [...plan.edges, { from: "sample", to: "upstream", label: "" }] },
|
||||
{ ...plan, nodes: [plan.nodes[0], plan.nodes[0]] },
|
||||
{ ...plan, nodes: [{ ...plan.nodes[0], evidence: [] }, plan.nodes[1]] },
|
||||
{
|
||||
...plan,
|
||||
nodes: [
|
||||
{ ...plan.nodes[0], status: "waiting", detail: "等待数据", evidence: [] },
|
||||
{ ...plan.nodes[1], status: "completed", evidence: ["evidence-2"] },
|
||||
],
|
||||
},
|
||||
]) expect(executionPlanSchema.safeParse(invalid).success).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves completed evidence and versions across persisted UI messages and active snapshots", () => {
|
||||
const first = createExecutionPlanSnapshot(plan, [], "session-1");
|
||||
const messages = [{ parts: [{ type: "data-execution_plan", data: first }] }, { executionPlans: [first] }];
|
||||
expect(readExecutionPlans(messages)).toHaveLength(1);
|
||||
const next = createExecutionPlanSnapshot({ ...plan, reason: "等待人工补充" }, messages, "session-1");
|
||||
expect(next.revision).toBe(2);
|
||||
expect(next.nodes[1].status).toBe("waiting");
|
||||
expect(() => createExecutionPlanSnapshot({ ...plan, nodes: [plan.nodes[1]], edges: [] }, messages, "session-1")).toThrow("Keep previous nodes");
|
||||
expect(() => createExecutionPlanSnapshot({ ...plan, nodes: [{ ...plan.nodes[0], status: "running" }, plan.nodes[1]] }, messages, "session-1")).toThrow("Keep completed evidence");
|
||||
});
|
||||
|
||||
it("publishes only successful validated tool completions, once per call", async () => {
|
||||
const part = (status: string) => ({ type: "message.part.updated", properties: { sessionID: "runtime", part: {
|
||||
id: "plan-call", messageID: "assistant", sessionID: "runtime", type: "tool", tool: "publish_execution_plan",
|
||||
callID: "call-1", state: { status, input: plan, output: JSON.stringify({ plan_revision: 1, report_revision: null }), title: "plan", time: { start: 1, end: 2 } }
|
||||
} } });
|
||||
const events = [part("running"), part("completed"), part("completed"), { type: "session.idle", properties: { sessionID: "runtime" } }];
|
||||
const runtime = {
|
||||
subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { for (const event of events) yield event; } }),
|
||||
prompt: async () => undefined, messages: async () => []
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
const published: unknown[] = [];
|
||||
await streamPromptResponse({ runtime, sessionId: "runtime", clientSessionId: "session-1", message: "investigate", write(event, data) { if (event === "execution_plan") published.push(data); } });
|
||||
expect(published).toHaveLength(1);
|
||||
expect(published[0]).toMatchObject({ session_id: "session-1", server_published: true, nodes: plan.nodes });
|
||||
});
|
||||
|
||||
it("falls back to route publication for a completed legacy local-only plan tool", async () => {
|
||||
const completed = { type: "message.part.updated", properties: { sessionID: "runtime", part: {
|
||||
id: "plan-call", messageID: "assistant", sessionID: "runtime", type: "tool", tool: "publish_execution_plan",
|
||||
callID: "call-1", state: { status: "completed", input: plan, output: JSON.stringify(plan), title: "plan", time: { start: 1, end: 2 } }
|
||||
} } };
|
||||
const runtime = {
|
||||
subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { yield completed; yield { type: "session.idle", properties: { sessionID: "runtime" } }; } }),
|
||||
prompt: async () => undefined, messages: async () => []
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
const published: Record<string, unknown>[] = [];
|
||||
|
||||
await streamPromptResponse({ runtime, sessionId: "runtime", clientSessionId: "session-1", message: "investigate", write(event, data) { if (event === "execution_plan") published.push(data); } });
|
||||
|
||||
expect(published).toHaveLength(1);
|
||||
expect(published[0]?.server_published).toBeUndefined();
|
||||
expect(executionPlanNeedsTaskPublish(published[0])).toBe(true);
|
||||
});
|
||||
|
||||
it("does not publish a failed tool plan or duplicate an already persisted plan", async () => {
|
||||
const failed = { type: "message.part.updated", properties: { sessionID: "runtime", part: {
|
||||
id: "plan-call", messageID: "assistant", sessionID: "runtime", type: "tool", tool: "publish_execution_plan",
|
||||
callID: "call-1", state: { status: "error", input: plan, error: "server rejected plan", time: { start: 1, end: 2 } }
|
||||
} } };
|
||||
const runtime = {
|
||||
subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { yield failed; yield { type: "session.idle", properties: { sessionID: "runtime" } }; } }),
|
||||
prompt: async () => undefined, messages: async () => []
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
const published: unknown[] = [];
|
||||
|
||||
await streamPromptResponse({ runtime, sessionId: "runtime", clientSessionId: "session-1", message: "investigate", write(event, data) { if (event === "execution_plan") published.push(data); } });
|
||||
|
||||
expect(published).toEqual([]);
|
||||
expect(executionPlanNeedsTaskPublish({ server_published: true })).toBe(false);
|
||||
expect(executionPlanNeedsTaskPublish(plan)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import express from "express";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { request as httpRequest, type Server } from "node:http";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { buildChatRouter } from "../../src/routes/chat.js";
|
||||
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
|
||||
import { toActorKey, toProjectKey } from "../../src/utils/fileStore.js";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
describe("chat session HTTP isolation", () => {
|
||||
let metadataDir: string;
|
||||
let server: Server;
|
||||
|
||||
beforeEach(async () => {
|
||||
metadataDir = await mkdtemp(join(tmpdir(), "tjwater-route-sessions-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
if (server) {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
await rm(metadataDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
it("hides task sessions from ordinary history and scopes every list to the verified user and project", async () => {
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
const headers = new Headers(init?.headers);
|
||||
const authorization = headers.get("authorization");
|
||||
const projectId = headers.get("x-project-id");
|
||||
const userId = authorization === "Bearer token-a" ? "user-a" : "user-b";
|
||||
return Response.json({
|
||||
user_id: userId,
|
||||
project_id: projectId,
|
||||
network: "supply",
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const sessionMetadataStore = new SessionMetadataStore(metadataDir);
|
||||
await sessionMetadataStore.initialize();
|
||||
const ownerContext = {
|
||||
actorKey: toActorKey("product:user-a"),
|
||||
projectId: "10000000-0000-4000-8000-000000000001",
|
||||
projectKey: toProjectKey("product:10000000-0000-4000-8000-000000000001"),
|
||||
userId: "user-a",
|
||||
};
|
||||
await sessionMetadataStore.ensure({
|
||||
...ownerContext,
|
||||
sessionId: "ordinary-session",
|
||||
});
|
||||
await sessionMetadataStore.ensure({
|
||||
...ownerContext,
|
||||
sessionId: "execution-session",
|
||||
purpose: "task_execution",
|
||||
taskId: "50000000-0000-4000-8000-000000000001",
|
||||
requestId: "60000000-0000-4000-8000-000000000001",
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api/v1/agent/chat", buildChatRouter({
|
||||
frontendActionCoordinator: {} as never,
|
||||
learningOrchestrator: {} as never,
|
||||
memoryStore: {} as never,
|
||||
resultReferenceResolver: {} as never,
|
||||
runtime: {} as never,
|
||||
sessionBridge: {} as never,
|
||||
sessionMetadataStore,
|
||||
sessionTranscriptStore: {} as never,
|
||||
sessionUiStateStore: {} as never,
|
||||
}));
|
||||
server = app.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("expected HTTP server address");
|
||||
|
||||
const ownedOrdinary = await getJson(address.port, "/api/v1/agent/chat/sessions", "token-a", ownerContext.projectId);
|
||||
const ownedExecution = await getJson(address.port, "/api/v1/agent/chat/sessions?purpose=task_execution", "token-a", ownerContext.projectId);
|
||||
const otherUser = await getJson(address.port, "/api/v1/agent/chat/sessions?purpose=task_execution", "token-b", ownerContext.projectId);
|
||||
const otherProject = await getJson(address.port, "/api/v1/agent/chat/sessions?purpose=task_execution", "token-a", "10000000-0000-4000-8000-000000000002");
|
||||
|
||||
expect(ownedOrdinary.body.sessions.map((item: { id: string }) => item.id)).toEqual(["ordinary-session"]);
|
||||
expect(ownedExecution.body.sessions.map((item: { id: string }) => item.id)).toEqual(["execution-session"]);
|
||||
expect(otherUser.body.sessions).toEqual([]);
|
||||
expect(otherProject.body.sessions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
const getJson = (
|
||||
port: number,
|
||||
path: string,
|
||||
token: string,
|
||||
projectId: string,
|
||||
) => new Promise<{ status: number; body: Record<string, any> }>((resolve, reject) => {
|
||||
const request = httpRequest({
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
path,
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-Project-Id": projectId,
|
||||
},
|
||||
}, (response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
||||
response.on("end", () => {
|
||||
resolve({
|
||||
status: response.statusCode ?? 0,
|
||||
body: JSON.parse(Buffer.concat(chunks).toString("utf8")),
|
||||
});
|
||||
});
|
||||
});
|
||||
request.on("error", reject);
|
||||
request.end();
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import type { UIMessage } from "ai";
|
||||
|
||||
import {
|
||||
buildFailedTaskExecutionPlan,
|
||||
toCollaborationMessages,
|
||||
} from "../../src/routes/chatStreamRoute.js";
|
||||
|
||||
describe("task collaboration history", () => {
|
||||
it("persists only user and assistant text instead of execution trajectory", () => {
|
||||
const messages = [
|
||||
{ id: "user-1", role: "user", parts: [{ type: "text", text: "为什么这样判断?" }] },
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "data-progress", data: { title: "查询 SCADA" } },
|
||||
{ type: "text", text: "因为上游数据正常,而投诉集中在同一小区。" },
|
||||
{ type: "data-tool_call", data: { tool: "query_operational_task_data" } },
|
||||
],
|
||||
},
|
||||
] as unknown as UIMessage[];
|
||||
|
||||
expect(toCollaborationMessages(messages)).toEqual([
|
||||
{ id: "user-1", role: "user", parts: [{ type: "text", text: "为什么这样判断?" }] },
|
||||
{ id: "assistant-1", role: "assistant", parts: [{ type: "text", text: "因为上游数据正常,而投诉集中在同一小区。" }] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("failed task execution plan", () => {
|
||||
it("turns active work into an explicit failed task artifact", () => {
|
||||
const plan = buildFailedTaskExecutionPlan([{
|
||||
title: "调查",
|
||||
reason: "开始",
|
||||
nodes: [
|
||||
{ id: "READ", title: "读取", status: "completed", owner: "agent", detail: "完成", evidence: ["evidence-1"], action_refs: [] },
|
||||
{ id: "ANALYZE", title: "分析", status: "running", owner: "agent", detail: "分析中", evidence: [], action_refs: [] },
|
||||
{ id: "SAMPLE", title: "采样", status: "waiting", owner: "human", detail: "等待现场", evidence: [], action_refs: [] },
|
||||
],
|
||||
edges: [{ from: "READ", to: "ANALYZE", label: "数据已读取" }],
|
||||
}]);
|
||||
|
||||
expect(plan.nodes).toEqual([
|
||||
{ id: "READ", title: "读取", status: "completed", owner: "agent", detail: "完成", evidence: ["evidence-1"], action_refs: [] },
|
||||
expect.objectContaining({ id: "ANALYZE", status: "failed" }),
|
||||
expect.objectContaining({ id: "SAMPLE", status: "cancelled" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user