1032 lines
33 KiB
TypeScript
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("已从结构化结果恢复最终回答。");
|
|
});
|
|
|
|
});
|