feat(agent): 完善分析编排与结果传输
This commit is contained in:
+289
-99
@@ -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(
|
||||
"已从持久化工具结果恢复最终答案。",
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user