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
+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(
"已从持久化工具结果恢复最终答案。",
);
});
});