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