feat(agent): 完善分析编排与结果传输
Generic Container CI/CD / test-build-publish (push) Failing after 1m20s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Failing after 1m20s

This commit is contained in:
2026-08-26 18:04:51 +08:00
parent a438433068
commit 72ebf4d6c1
33 changed files with 1027 additions and 705 deletions
+50
View File
@@ -0,0 +1,50 @@
import { describe, expect, it } from "bun:test";
import { createActivityTracker } from "../../src/routes/chatActivityTracker.js";
describe("createActivityTracker", () => {
it("groups actions and closes running children with their activity", () => {
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const tracker = createActivityTracker({
clientSessionId: "client-session-1",
write: (event, data) => events.push({ event, data }),
});
tracker.start("activity-1", "准备分析数据", "需要先获取分析输入。");
tracker.upsertAction(
{
id: "tool-1",
tool: "tjwater_cli",
state: {
status: "running",
input: { command: "data list" },
},
} as never,
{ command: "data list" },
);
tracker.finalize("cancelled");
expect(tracker.getCurrentContext()).toEqual({
id: "activity-1",
title: "准备分析数据",
reason: "需要先获取分析输入。",
});
expect(events.at(-1)).toMatchObject({
event: "activity_update",
data: {
session_id: "client-session-1",
activity: {
id: "activity-1",
status: "cancelled",
actions: [
expect.objectContaining({
id: "tool-1",
status: "completed",
target: "data list",
}),
],
},
},
});
});
});
+289 -99
View File
@@ -117,15 +117,27 @@ describe("streamPromptResponse", () => {
});
expect(subscribedDirectory).toBe("/tmp/conversation-workspace-1");
expect(
events
.filter((item) => item.event === "token")
.map((item) => item.data.content)
.join(""),
).toBe("共识别 56 条瓶颈管段,建议优先改造 Top 5。");
expect(events.filter((item) => item.event === "token")).toEqual([
{
event: "token",
data: {
session_id: "client-session-1",
content: "共识别 56 条瓶颈管段,建议优先改造 Top 5。",
},
},
]);
expect(events.filter((item) => item.event === "final_answer")).toEqual([
{
event: "final_answer",
data: {
session_id: "client-session-1",
content: "共识别 56 条瓶颈管段,建议优先改造 Top 5。",
},
},
]);
});
it("streams final_answer deltas while routing commentary to progress", async () => {
it("buffers final_answer deltas and emits one complete answer", async () => {
let messagesCalls = 0;
const runtime = {
subscribeEvents: async () =>
@@ -241,20 +253,20 @@ describe("streamPromptResponse", () => {
write: (event, data) => events.push({ event, data }),
});
expect(
events.filter((item) => item.event === "token").map((item) => item.data.content),
).toEqual(["分析完成,", "结果正常。"]);
expect(events.find((item) => item.event === "token")?.data.content).toBe(
"分析完成,结果正常。",
);
expect(events.filter((item) => item.event === "final_answer")).toEqual([
{
event: "final_answer",
data: {
session_id: "client-session-1",
content: "分析完成,结果正常。",
},
},
]);
expect(messagesCalls).toBe(0);
expect(
events.find(
(item) =>
item.event === "progress" && item.data.id === "commentary-commentary-part",
)?.data,
).toMatchObject({
phase: "commentary",
status: "running",
detail: "我先检查相关数据。",
});
expect(events.some((item) => item.event === "progress")).toBe(false);
expect(
events.some(
(item) => item.event === "token" && item.data.content === "我先检查相关数据。",
@@ -302,18 +314,56 @@ describe("streamPromptResponse", () => {
});
expect(result.failed).toBe(false);
expect(
events
.filter((item) => item.event === "token")
.map((item) => item.data.content)
.join(""),
).toBe("最终分析结果。");
expect(events.find((item) => item.event === "token")?.data.content).toBe(
"最终分析结果。",
);
expect(events.find((item) => item.event === "final_answer")?.data.content).toBe(
"最终分析结果。",
);
});
it("keeps reasoning generic while preserving tool execution details", async () => {
it("groups concrete tool execution under the current activity", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "activity-part-1",
sessionID: "runtime-session-1",
messageID: "assistant-1",
type: "tool",
callID: "activity-call-1",
tool: "activity_update",
state: {
status: "completed",
input: {
title: "检查管网数据",
reason: "需要确认输入数据是否满足瓶颈分析条件。",
todos: [
{
id: "prepare-data",
content: "准备管网数据",
status: "completed",
priority: "high",
},
{
id: "analyze-data",
content: "分析瓶颈管段",
status: "in_progress",
priority: "medium",
},
],
},
output: "活动阶段已更新。",
time: { start: 1, end: 2 },
},
},
time: 2,
},
},
{
type: "message.part.updated",
properties: {
@@ -354,7 +404,6 @@ describe("streamPromptResponse", () => {
status: "error",
input: {
command: "network get-all-pipes-properties --limit 5000",
reason: "尝试突破分页限制",
},
error: "HTTP_422 raw backend payload with trace_id=secret-trace",
time: { start: 1, end: 2 },
@@ -381,16 +430,37 @@ describe("streamPromptResponse", () => {
write: (event, data) => events.push({ event, data }),
});
const reasoningProgress = events.find(
(item) => item.event === "progress" && item.data.id === "reasoning-part-1",
);
const toolProgress = events.find(
(item) => item.event === "progress" && item.data.id === "tool-part-1",
);
expect(reasoningProgress?.data.detail).toBe("分析步骤已整理完成。");
expect(toolProgress?.data.detail).toBe(
"tjwater_cli 调用失败;调用原因:尝试突破分页限制;关键参数:command=network get-all-pipes-properties --limit 5000;错误:HTTP_422 raw backend payload with trace_id=secret-trace",
const activityUpdates = events.filter(
(item) => item.event === "activity_update" &&
(item.data.activity as { id?: string } | undefined)?.id === "activity-part-1",
);
expect(activityUpdates.at(-1)?.data.activity).toMatchObject({
title: "检查管网数据",
reason: "需要确认输入数据是否满足瓶颈分析条件。",
actions: [
expect.objectContaining({
id: "tool-part-1",
tool: "tjwater_cli",
status: "error",
target: "network get-all-pipes-properties --limit 5000",
}),
],
});
expect(activityUpdates[0]?.data.todos).toEqual([
expect.objectContaining({
id: "prepare-data",
content: "准备管网数据",
status: "completed",
priority: "high",
}),
expect.objectContaining({
id: "analyze-data",
content: "分析瓶颈管段",
status: "in_progress",
priority: "medium",
}),
]);
expect(events.some((item) => item.event === "progress")).toBe(false);
});
it("forwards opencode permission requests as SSE payloads", async () => {
@@ -435,6 +505,8 @@ describe("streamPromptResponse", () => {
permission: "bash",
patterns: ["rm *"],
target: "rm tmp.txt",
activity_id: "activity-startup",
reason: "正在理解请求并确定本次分析需要完成的业务步骤。",
always: ["rm *"],
} satisfies Partial<PermissionRequestPayload>);
});
@@ -829,10 +901,35 @@ describe("streamPromptResponse", () => {
).toBe(false);
});
it("forwards todo updates as structured SSE payloads and progress", async () => {
it("forwards todo updates independently from activity progress", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "todo-tool-part",
sessionID: "runtime-session-1",
messageID: "assistant-plan",
type: "tool",
callID: "todo-tool-call",
tool: "todowrite",
state: {
status: "completed",
input: {
todos: [
{ content: "分析水位", status: "completed", priority: "high" },
{ content: "生成建议", status: "in_progress", priority: "medium" },
],
},
output: "计划已更新",
time: { start: 1, end: 2 },
},
},
},
},
{
type: "todo.updated",
properties: {
@@ -863,15 +960,7 @@ describe("streamPromptResponse", () => {
write: (event, data) => events.push({ event, data }),
});
expect(
events.find(
(item) => item.event === "progress" && item.data.id === "todo-progress",
)?.data,
).toMatchObject({
id: "todo-progress",
phase: "planning",
title: "计划进度 1/2",
});
expect(events.some((item) => item.event === "progress")).toBe(false);
expect(events.find((item) => item.event === "todo_update")?.data).toMatchObject({
session_id: "client-session-1",
todos: [
@@ -887,14 +976,29 @@ describe("streamPromptResponse", () => {
}),
],
});
expect(
events.some(
(item) =>
item.event === "activity_update" &&
((item.data.activity as { actions?: Array<{ tool?: string }> } | undefined)
?.actions ?? [])
.some((action) => action.tool === "todowrite"),
),
).toBe(false);
expect(
events.some(
(item) => item.event === "tool_call" && item.data.tool === "todowrite",
),
).toBe(false);
});
it("uses StructuredOutput as the terminal action for models without reliable phases", async () => {
it("buffers the voluntary DeepSeek final answer tool without forcing tool choice", async () => {
const promptCalls: unknown[][] = [];
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
subscribeEvents: async () => ({
async *[Symbol.asyncIterator]() {
yield {
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
@@ -904,7 +1008,32 @@ describe("streamPromptResponse", () => {
messageID: "assistant-final",
type: "tool",
callID: "final-answer-call",
tool: "StructuredOutput",
tool: "final_answer",
state: {
status: "running",
input: { answer: "供水服务分区" },
time: { start: 1 },
},
},
},
};
expect(
events
.filter((item) => item.event === "final_answer")
.map((item) => item.data.content)
.join(""),
).toBe("");
yield {
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "final-answer-part",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "tool",
callID: "final-answer-call",
tool: "final_answer",
state: {
status: "running",
input: { answer: "供水服务分区分析已完成。" },
@@ -912,8 +1041,14 @@ describe("streamPromptResponse", () => {
},
},
},
},
{
};
expect(
events
.filter((item) => item.event === "final_answer")
.map((item) => item.data.content)
.join(""),
).toBe("");
yield {
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
@@ -923,31 +1058,29 @@ describe("streamPromptResponse", () => {
messageID: "assistant-final",
type: "tool",
callID: "final-answer-call",
tool: "StructuredOutput",
tool: "final_answer",
state: {
status: "completed",
input: { answer: "供水服务分区分析已完成。" },
output: "最终回答已提交。",
title: "StructuredOutput",
title: "final_answer",
metadata: {},
time: { start: 1, end: 2 },
},
},
},
},
{
};
yield {
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
},
]),
};
},
}),
prompt: async (...args: unknown[]) => {
promptCalls.push(args);
},
messages: async () => {
throw new Error("StructuredOutput should avoid the message fallback");
},
messages: async () => [],
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const result = await streamPromptResponse({
runtime,
@@ -958,37 +1091,87 @@ describe("streamPromptResponse", () => {
write: (event, data) => events.push({ event, data }),
});
expect(promptCalls[0]?.[3]).toMatchObject({
format: {
type: "json_schema",
schema: {
required: ["answer"],
expect(promptCalls[0]?.[3]).toBeUndefined();
expect(result).toEqual({ aborted: false, failed: false, toolCallCount: 0 });
expect(events.find((item) => item.event === "token")?.data.content).toBe(
"供水服务分区分析已完成。",
);
expect(events.filter((item) => item.event === "final_answer")).toEqual([
{
event: "final_answer",
data: {
session_id: "client-session-1",
content: "供水服务分区分析已完成。",
},
},
});
expect(result).toEqual({ aborted: false, failed: false, toolCallCount: 0 });
expect(
events
.filter((item) => item.event === "token")
.map((item) => item.data.content)
.join(""),
).toBe("供水服务分区分析已完成。");
]);
expect(events.some((item) => item.event === "tool_call")).toBe(false);
expect(events.some((item) => item.event === "done")).toBe(true);
});
it("recovers the final answer from structured message data when tool events are missed", async () => {
it("keeps the pending event read when prompt resolves before final_answer", async () => {
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const runtime = {
subscribeEvents: async () => ({
async *[Symbol.asyncIterator]() {
await new Promise((resolve) => setTimeout(resolve, 0));
yield {
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "final-answer-part",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "tool",
callID: "final-answer-call",
tool: "final_answer",
state: {
status: "completed",
input: { answer: "最终答案不会因事件竞争而丢失。" },
output: "最终回答已提交。",
title: "final_answer",
metadata: {},
time: { start: 1, end: 2 },
},
},
},
};
yield {
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
};
},
}),
prompt: async () => undefined,
messages: async () => [],
} as unknown as OpencodeRuntimeAdapter;
const result = await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "分析管网",
write: (event, data) => events.push({ event, data }),
});
expect(result.failed).toBe(false);
expect(events.find((item) => item.event === "final_answer")?.data.content).toBe(
"最终答案不会因事件竞争而丢失。",
);
expect(events.at(-1)?.event).toBe("done");
});
it("recovers a persisted final_answer tool result from message history", async () => {
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.updated",
properties: {
info: {
id: "assistant-final",
sessionID: "runtime-session-1",
role: "assistant",
},
sessionID: "runtime-session-1",
info: { id: "assistant-final", role: "assistant" },
},
},
{
@@ -999,33 +1182,40 @@ describe("streamPromptResponse", () => {
prompt: async () => undefined,
messages: async () => [
{
info: {
id: "assistant-final",
sessionID: "runtime-session-1",
role: "assistant",
structured: { answer: "已从结构化结果恢复最终回答。" },
},
parts: [],
info: { id: "assistant-final", role: "assistant" },
parts: [
{
id: "final-answer-part",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "tool",
callID: "final-answer-call",
tool: "final_answer",
state: {
status: "completed",
input: { answer: "已从持久化工具结果恢复最终答案。" },
output: "最终回答已提交。",
title: "final_answer",
metadata: {},
time: { start: 1, end: 2 },
},
},
],
},
],
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "恢复最终回答",
model: "deepseek/deepseek-v4-flash",
message: "分析管网",
write: (event, data) => events.push({ event, data }),
});
expect(
events
.filter((item) => item.event === "token")
.map((item) => item.data.content)
.join(""),
).toBe("已从结构化结果恢复最终回答。");
expect(events.find((item) => item.event === "final_answer")?.data.content).toBe(
"已从持久化工具结果恢复最终答案。",
);
});
});
+52 -2
View File
@@ -3,15 +3,40 @@ import { describe, expect, it } from "bun:test";
import {
appendBackendToolArtifact,
cancelBackendTodos,
completeBackendActivities,
completeBackendTodos,
upsertBackendQuestion,
} from "../../src/routes/chatUiState.js";
describe("completeBackendActivities", () => {
it("closes running child actions when the stream terminates", () => {
const activities = completeBackendActivities([
{
id: "activity-1",
status: "running",
startedAt: Date.now() - 100,
actions: [
{
id: "action-1",
status: "running",
startedAt: Date.now() - 50,
},
],
},
], "error") as Array<Record<string, unknown>>;
expect(activities[0]).toMatchObject({
status: "error",
actions: [expect.objectContaining({ status: "error" })],
});
});
});
describe("appendBackendToolArtifact", () => {
it("persists show_chart tool calls as chart artifacts", () => {
const artifacts = appendBackendToolArtifact([], {
session_id: "session-1",
tool: "show_chart",
reason: "测试折线图渲染",
params: {
title: "压力曲线",
chart_type: "line",
@@ -25,7 +50,6 @@ describe("appendBackendToolArtifact", () => {
tool: "show_chart",
kind: "chart",
title: "压力曲线",
description: "测试折线图渲染",
params: {
chart_type: "line",
x_data: ["00:00", "01:00"],
@@ -160,3 +184,29 @@ describe("cancelBackendTodos", () => {
]);
});
});
describe("completeBackendTodos", () => {
it("marks pending and in-progress todos as completed after a successful run", () => {
const completed = completeBackendTodos([
{
sessionId: "session-1",
todos: [
{ id: "todo-1", content: "分析水位", status: "in_progress" },
{ id: "todo-2", content: "生成建议", status: "pending" },
{ id: "todo-3", content: "完成报告", status: "completed" },
],
createdAt: 123,
},
]);
expect(completed).toEqual([
expect.objectContaining({
todos: [
expect.objectContaining({ id: "todo-1", status: "completed" }),
expect.objectContaining({ id: "todo-2", status: "completed" }),
expect.objectContaining({ id: "todo-3", status: "completed" }),
],
}),
]);
});
});
-52
View File
@@ -117,58 +117,6 @@ describe("OpencodeRuntimeAdapter.subscribeEvents", () => {
});
});
describe("OpencodeRuntimeAdapter.prompt", () => {
it("forwards the final answer schema", async () => {
const calls: unknown[] = [];
const client = {
session: {
prompt: async (input: unknown) => {
calls.push(input);
return { data: {} };
},
},
} as unknown as OpencodeClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
ensureClient: async () => client,
}) as OpencodeRuntimeAdapter;
await runtime.prompt(
"session-1",
"分析供水分区",
{ providerID: "deepseek", modelID: "deepseek-v4-flash" },
{
format: {
type: "json_schema",
schema: {
type: "object",
properties: { answer: { type: "string" } },
required: ["answer"],
},
},
},
);
expect(calls).toEqual([
{
sessionID: "session-1",
model: {
providerID: "deepseek",
modelID: "deepseek-v4-flash",
},
format: {
type: "json_schema",
schema: {
type: "object",
properties: { answer: { type: "string" } },
required: ["answer"],
},
},
parts: [{ type: "text", text: "分析供水分区" }],
},
]);
});
});
describe("OpencodeRuntimeAdapter interaction replies", () => {
it("replies to permissions in the conversation workspace directory", async () => {
const calls: unknown[] = [];
+33 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from "bun:test";
import { readFile } from "node:fs/promises";
import { readFile, readdir } from "node:fs/promises";
import sandboxBash from "../../.opencode/tools/bash.js";
import tjwaterCli from "../../.opencode/tools/tjwater_cli.js";
import storeRenderRef, {
@@ -37,6 +37,8 @@ describe("internal OpenCode permissions", () => {
expect(permission.external_directory).toBe("deny");
expect(permission.task).toBe("deny");
expect(permission.question).toBe("allow");
expect(permission.activity_update).toBe("allow");
expect(permission.final_answer).toBe("allow");
expect(permission.todowrite).toBe("allow");
expect(read?.["*"]).toBe("allow");
expect(read?.["data/**"]).toBe("deny");
@@ -56,6 +58,22 @@ describe("internal OpenCode permissions", () => {
expect(bash?.["*data/*"]).toBeUndefined();
expect(bash?.["*logs/*"]).toBeUndefined();
});
it("keeps reason only on the activity grouping tool", async () => {
const toolFiles = (await readdir(".opencode/tools"))
.filter((file) => file.endsWith(".ts"));
const sources = await Promise.all(
toolFiles.map(async (file) => ({
file,
source: await readFile(`.opencode/tools/${file}`, "utf8"),
})),
);
const reasonSchemaFiles = sources
.filter(({ source }) => /reason:\s*tool\.schema/u.test(source))
.map(({ file }) => file);
expect(reasonSchemaFiles).toEqual(["activity_update.ts"]);
});
});
describe("store_render_ref arguments", () => {
@@ -154,6 +172,20 @@ describe("sandbox bash tool", () => {
});
describe("tjwater_cli storage request", () => {
it("keeps command discovery rules in the always-visible tool contract", () => {
const definition = tjwaterCli as unknown as {
description: string;
args: { command: { description?: string } };
};
expect(definition.description).toContain("help");
expect(definition.args.command.description).toContain("禁止类推");
expect(definition.args.command.description).toContain("simulation runs list");
expect(definition.args.command.description).not.toContain(
"示例:'analysis runs list'",
);
});
it("forwards store_result so small workflow inputs can be staged", async () => {
const originalFetch = globalThis.fetch;
let requestBody: unknown;
@@ -168,7 +200,6 @@ describe("tjwater_cli storage request", () => {
await definition.execute(
{
command: "network get-all-reservoirs-properties",
reason: "prepare workflow input",
store_result: true,
},
{ sessionID: "session-test" } as never,