Files
TJWaterAgent/tests/routes/chatStream.test.ts
T

1032 lines
33 KiB
TypeScript

import { describe, expect, it } from "bun:test";
import {
streamPromptResponse,
type PermissionRequestPayload,
} from "../../src/routes/chatStream.js";
import { type OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
const createEventStream = (events: unknown[]) => ({
async *[Symbol.asyncIterator]() {
for (const event of events) {
yield event;
}
},
});
describe("streamPromptResponse", () => {
it("emits only the final assistant text after tool-driven intermediate messages", async () => {
let subscribedDirectory: string | undefined;
const runtime = {
subscribeEvents: async (directory?: string) => {
subscribedDirectory = directory;
return createEventStream([
{
type: "message.part.delta",
properties: {
sessionID: "runtime-session-1",
messageID: "assistant-intermediate",
partID: "text-part-intermediate",
field: "text",
delta: "正在加载工作流并尝试分页参数。",
},
},
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "text-part-intermediate",
sessionID: "runtime-session-1",
messageID: "assistant-intermediate",
type: "text",
text: "正在加载工作流并尝试分页参数。",
time: { start: 1, end: 2 },
},
time: 2,
},
},
{
type: "message.part.delta",
properties: {
sessionID: "runtime-session-1",
messageID: "assistant-final",
partID: "text-part-final",
field: "text",
delta: "共识别 56 条瓶颈管段,建议优先改造 Top 5。",
},
},
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "text-part-final",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "text",
text: "共识别 56 条瓶颈管段,建议优先改造 Top 5。",
time: { start: 3, end: 4 },
},
time: 4,
},
},
{
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
},
]);
},
prompt: async () => undefined,
messages: async () => [
{
info: { id: "assistant-intermediate", role: "assistant" },
parts: [
{
id: "text-part-intermediate",
sessionID: "runtime-session-1",
messageID: "assistant-intermediate",
type: "text",
text: "正在加载工作流并尝试分页参数。",
},
],
},
{
info: { id: "assistant-final", role: "assistant" },
parts: [
{
id: "text-part-final",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "text",
text: "共识别 56 条瓶颈管段,建议优先改造 Top 5。",
},
],
},
],
} 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: "分析管网瓶颈",
workspaceRoot: "/tmp/conversation-workspace-1",
write: (event, data) => events.push({ event, data }),
});
expect(subscribedDirectory).toBe("/tmp/conversation-workspace-1");
expect(
events
.filter((item) => item.event === "token")
.map((item) => item.data.content)
.join(""),
).toBe("共识别 56 条瓶颈管段,建议优先改造 Top 5。");
});
it("streams final_answer deltas while routing commentary to progress", async () => {
let messagesCalls = 0;
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "commentary-part",
sessionID: "runtime-session-1",
messageID: "assistant-commentary",
type: "text",
text: "",
metadata: { openai: { phase: "commentary" } },
time: { start: 1 },
},
},
},
{
type: "message.part.delta",
properties: {
sessionID: "runtime-session-1",
messageID: "assistant-commentary",
partID: "commentary-part",
field: "text",
delta: "我先检查相关数据。",
},
},
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "commentary-part",
sessionID: "runtime-session-1",
messageID: "assistant-commentary",
type: "text",
text: "我先检查相关数据。",
metadata: { openai: { phase: "commentary" } },
time: { start: 1, end: 2 },
},
},
},
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "final-part",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "text",
text: "",
metadata: { openai: { phase: "final_answer" } },
time: { start: 3 },
},
},
},
{
type: "message.part.delta",
properties: {
sessionID: "runtime-session-1",
messageID: "assistant-final",
partID: "final-part",
field: "text",
delta: "分析完成,",
},
},
{
type: "message.part.delta",
properties: {
sessionID: "runtime-session-1",
messageID: "assistant-final",
partID: "final-part",
field: "text",
delta: "结果正常。",
},
},
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "final-part",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "text",
text: "分析完成,结果正常。",
metadata: { openai: { phase: "final_answer" } },
time: { start: 3, end: 4 },
},
},
},
{
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
},
]),
prompt: async () => undefined,
messages: async () => {
messagesCalls += 1;
return [];
},
} 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: "分析管网",
write: (event, data) => events.push({ event, data }),
});
expect(
events.filter((item) => item.event === "token").map((item) => item.data.content),
).toEqual(["分析完成,", "结果正常。"]);
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 === "token" && item.data.content === "我先检查相关数据。",
),
).toBe(false);
});
it("uses the final text event cache when the messages lookup fails", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "text-part-final",
sessionID: "runtime-session-1",
messageID: "assistant-final",
type: "text",
text: "最终分析结果。",
time: { start: 1, end: 2 },
},
time: 2,
},
},
{
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
},
]),
prompt: async () => undefined,
messages: async () => {
throw new Error("transient messages lookup failure");
},
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
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
.filter((item) => item.event === "token")
.map((item) => item.data.content)
.join(""),
).toBe("最终分析结果。");
});
it("keeps reasoning generic while preserving tool execution details", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "reasoning-part-1",
sessionID: "runtime-session-1",
messageID: "assistant-1",
type: "reasoning",
text: "内部推理:尝试 limit=5000 并读取临时路径。",
time: { start: 1, end: 2 },
},
time: 2,
},
},
{
type: "message.part.delta",
properties: {
sessionID: "runtime-session-1",
messageID: "assistant-1",
partID: "reasoning-part-1",
field: "text",
delta: "内部推理:尝试 limit=5000 并读取临时路径。",
},
},
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "tool-part-1",
sessionID: "runtime-session-1",
messageID: "assistant-1",
type: "tool",
callID: "call-1",
tool: "tjwater_cli",
state: {
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 },
},
},
time: 2,
},
},
{
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
},
]),
prompt: async () => undefined,
messages: async () => [],
} 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: "分析管网瓶颈",
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",
);
});
it("forwards opencode permission requests as SSE payloads", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "permission.asked",
properties: {
id: "perm-1",
sessionID: "runtime-session-1",
permission: "bash",
patterns: ["rm *"],
metadata: { command: "rm tmp.txt" },
always: ["rm *"],
},
},
{
type: "session.idle",
properties: {
sessionID: "runtime-session-1",
},
},
]),
prompt: async () => undefined,
messages: async () => [],
} 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: "delete temp",
write: (event, data) => events.push({ event, data }),
});
const permissionEvent = events.find((item) => item.event === "permission_request");
expect(permissionEvent?.data).toMatchObject({
session_id: "client-session-1",
request_id: "perm-1",
permission: "bash",
patterns: ["rm *"],
target: "rm tmp.txt",
always: ["rm *"],
} satisfies Partial<PermissionRequestPayload>);
});
it("auto approves an allowlisted low-risk permission once", async () => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "permission.asked",
properties: {
id: "perm-1",
sessionID: "runtime-session-1",
permission: "show_chart",
patterns: ["*"],
metadata: {},
always: ["*"],
},
},
{
type: "session.idle",
properties: {
sessionID: "runtime-session-1",
},
},
]),
prompt: async () => undefined,
messages: async () => [],
replyPermission: async (options: Record<string, unknown>) => {
replies.push(options);
},
} 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: "run tests",
approvalMode: "auto",
workspaceRoot: "/tmp/conversation-workspace-1",
write: (event, data) => events.push({ event, data }),
});
expect(replies).toEqual([
{
requestId: "perm-1",
sessionId: "runtime-session-1",
directory: "/tmp/conversation-workspace-1",
reply: "once",
},
]);
expect(events.some((item) => item.event === "permission_request")).toBe(false);
expect(events.find((item) => item.event === "permission_response")?.data).toEqual({
session_id: "client-session-1",
request_id: "perm-1",
reply: "once",
});
});
it("keeps high-risk permissions interactive in auto mode", async () => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "permission.asked",
properties: {
id: "perm-auto-bash",
sessionID: "runtime-session-1",
permission: "bash",
patterns: ["npm test"],
metadata: { command: "npm test" },
always: ["npm test"],
},
},
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
]),
prompt: async () => undefined,
messages: async () => [],
replyPermission: async (options: Record<string, unknown>) => replies.push(options),
} 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: "run tests",
approvalMode: "auto",
write: (event, data) => events.push({ event, data }),
});
expect(replies).toEqual([]);
expect(events.find((item) => item.event === "permission_request")?.data).toMatchObject({
request_id: "perm-auto-bash",
permission: "bash",
});
});
it("approves every OpenCode ask once in always mode", async () => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "permission.asked",
properties: {
id: "perm-always-bash",
sessionID: "runtime-session-1",
permission: "bash",
patterns: ["npm test"],
metadata: { command: "npm test" },
always: ["npm test"],
},
},
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
]),
prompt: async () => undefined,
messages: async () => [],
replyPermission: async (options: Record<string, unknown>) => replies.push(options),
} 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: "run tests",
approvalMode: "always",
workspaceRoot: "/tmp/conversation-workspace-1",
write: (event, data) => events.push({ event, data }),
});
expect(replies).toEqual([
{
requestId: "perm-always-bash",
sessionId: "runtime-session-1",
directory: "/tmp/conversation-workspace-1",
reply: "once",
},
]);
expect(events.some((item) => item.event === "permission_request")).toBe(false);
});
it("rejects recursive force removal even in always mode", async () => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "permission.asked",
properties: {
id: "perm-always-rm-rf",
sessionID: "runtime-session-1",
permission: "bash",
patterns: ["/bin/rm -rf ./target"],
metadata: { command: "/bin/rm -rf ./target" },
always: ["/bin/rm -rf ./target"],
},
},
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
]),
prompt: async () => undefined,
messages: async () => [],
replyPermission: async (options: Record<string, unknown>) => replies.push(options),
} 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: "delete recursively",
approvalMode: "always",
workspaceRoot: "/tmp/conversation-workspace-1",
write: (event, data) => events.push({ event, data }),
});
expect(replies).toEqual([
{
requestId: "perm-always-rm-rf",
sessionId: "runtime-session-1",
directory: "/tmp/conversation-workspace-1",
reply: "reject",
},
]);
expect(events.some((item) => item.event === "permission_request")).toBe(false);
expect(events.find((item) => item.event === "permission_response")?.data).toEqual({
session_id: "client-session-1",
request_id: "perm-always-rm-rf",
reply: "reject",
});
});
it("forwards opencode v2 permission requests as SSE payloads", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "permission.v2.asked",
properties: {
id: "perm-v2-1",
sessionID: "runtime-session-1",
action: "external_directory",
resources: ["/tmp"],
save: ["/tmp"],
metadata: { path: "/tmp" },
},
},
{
type: "session.idle",
properties: {
sessionID: "runtime-session-1",
},
},
]),
prompt: async () => undefined,
messages: async () => [],
} 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: "read /tmp",
write: (event, data) => events.push({ event, data }),
});
const permissionEvent = events.find((item) => item.event === "permission_request");
expect(permissionEvent?.data).toMatchObject({
session_id: "client-session-1",
request_id: "perm-v2-1",
permission: "external_directory",
patterns: ["/tmp"],
target: "/tmp",
always: ["/tmp"],
} satisfies Partial<PermissionRequestPayload>);
});
it("forwards opencode question requests and replies as SSE payloads", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "question.asked",
properties: {
id: "question-1",
sessionID: "runtime-session-1",
questions: [
{
header: "范围",
question: "选择分析范围",
options: [{ label: "城区", description: "中心城区" }],
multiple: false,
custom: true,
},
],
},
},
{
type: "question.replied",
properties: {
sessionID: "runtime-session-1",
requestID: "question-1",
answers: [["城区", "补充说明"]],
},
},
{
type: "session.idle",
properties: {
sessionID: "runtime-session-1",
},
},
]),
prompt: async () => undefined,
messages: async () => [],
} 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: "ask",
write: (event, data) => events.push({ event, data }),
});
expect(events.find((item) => item.event === "question_request")?.data).toMatchObject({
session_id: "client-session-1",
request_id: "question-1",
questions: [
{
header: "范围",
question: "选择分析范围",
options: [{ label: "城区", description: "中心城区" }],
multiple: false,
custom: true,
},
],
});
expect(events.find((item) => item.event === "question_response")?.data).toEqual({
session_id: "client-session-1",
request_id: "question-1",
answers: [["城区", "补充说明"]],
});
});
it("converts question tool parts into question request SSE payloads", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.part.updated",
properties: {
sessionID: "runtime-session-1",
part: {
id: "tool-part-1",
sessionID: "runtime-session-1",
messageID: "message-1",
type: "tool",
callID: "call-1",
tool: "question",
state: {
status: "running",
input: {
questions: [
{
question: "你觉得这个 question 工具好用吗?",
header: "测试问题",
options: [
{
label: "非常好用",
description: "交互清晰,选项方便",
},
],
},
],
},
time: { start: Date.now() },
},
},
time: Date.now(),
},
},
{
type: "session.idle",
properties: {
sessionID: "runtime-session-1",
},
},
]),
prompt: async () => undefined,
messages: async () => [],
} 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: "ask",
write: (event, data) => events.push({ event, data }),
});
expect(events.find((item) => item.event === "question_request")?.data).toMatchObject({
session_id: "client-session-1",
request_id: "call-1",
questions: [
{
header: "测试问题",
question: "你觉得这个 question 工具好用吗?",
options: [
{
label: "非常好用",
description: "交互清晰,选项方便",
},
],
},
],
tool: {
messageID: "message-1",
callID: "call-1",
},
});
expect(
events.some(
(item) => item.event === "tool_call" && item.data.tool === "question",
),
).toBe(false);
});
it("forwards todo updates as structured SSE payloads and progress", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "todo.updated",
properties: {
sessionID: "runtime-session-1",
todos: [
{ content: "分析水位", status: "completed", priority: "high" },
{ content: "生成建议", status: "in_progress", priority: "medium" },
],
},
},
{
type: "session.idle",
properties: {
sessionID: "runtime-session-1",
},
},
]),
prompt: async () => undefined,
messages: async () => [],
} 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: "plan",
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.find((item) => item.event === "todo_update")?.data).toMatchObject({
session_id: "client-session-1",
todos: [
expect.objectContaining({
content: "分析水位",
status: "completed",
priority: "high",
}),
expect.objectContaining({
content: "生成建议",
status: "in_progress",
priority: "medium",
}),
],
});
});
it("uses StructuredOutput as the terminal action for models without reliable phases", async () => {
const promptCalls: unknown[][] = [];
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
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: "StructuredOutput",
state: {
status: "running",
input: { answer: "供水服务分区分析已完成。" },
time: { start: 1 },
},
},
},
},
{
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: "StructuredOutput",
state: {
status: "completed",
input: { answer: "供水服务分区分析已完成。" },
output: "最终回答已提交。",
title: "StructuredOutput",
metadata: {},
time: { start: 1, end: 2 },
},
},
},
},
{
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");
},
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
const result = await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "分析供水服务分区",
model: "deepseek/deepseek-v4-flash",
write: (event, data) => events.push({ event, data }),
});
expect(promptCalls[0]?.[3]).toMatchObject({
format: {
type: "json_schema",
schema: {
required: ["answer"],
},
},
});
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 () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "message.updated",
properties: {
info: {
id: "assistant-final",
sessionID: "runtime-session-1",
role: "assistant",
},
},
},
{
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
},
]),
prompt: async () => undefined,
messages: async () => [
{
info: {
id: "assistant-final",
sessionID: "runtime-session-1",
role: "assistant",
structured: { answer: "已从结构化结果恢复最终回答。" },
},
parts: [],
},
],
} 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",
write: (event, data) => events.push({ event, data }),
});
expect(
events
.filter((item) => item.event === "token")
.map((item) => item.data.content)
.join(""),
).toBe("已从结构化结果恢复最终回答。");
});
});