602 lines
19 KiB
TypeScript
602 lines
19 KiB
TypeScript
import { describe, expect, it } from "bun:test";
|
|
|
|
import {
|
|
createTokenSmoother,
|
|
detectTokenSmoothingChunk,
|
|
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;
|
|
}
|
|
},
|
|
});
|
|
|
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
describe("streamPromptResponse", () => {
|
|
it("does not split short Chinese text only because it has punctuation", () => {
|
|
expect(detectTokenSmoothingChunk("管网压力异常,需要继续分析", "zh")).toBeNull();
|
|
expect(detectTokenSmoothingChunk("管网压力异常需要继续分析。", "zh")).toBeNull();
|
|
});
|
|
|
|
it("does not immediately release a single Chinese character", () => {
|
|
expect(detectTokenSmoothingChunk("管", "zh")).toBeNull();
|
|
});
|
|
|
|
it("keeps non-Chinese text on the word path even with zh locale", () => {
|
|
expect(detectTokenSmoothingChunk("Agent ", "zh")).toBe("Agent ");
|
|
});
|
|
|
|
it("falls back to bounded Chinese chunks when punctuation is absent", () => {
|
|
const content = "管网压力异常需要继续分析东部主干供水走廊和相关阀门状态变化";
|
|
const chunk = detectTokenSmoothingChunk(content, "zh");
|
|
|
|
expect(chunk).not.toBeNull();
|
|
if (!chunk) {
|
|
return;
|
|
}
|
|
expect(content.startsWith(chunk)).toBe(true);
|
|
expect(Array.from(chunk).length).toBeGreaterThanOrEqual(12);
|
|
expect(Array.from(chunk).length).toBeLessThanOrEqual(18);
|
|
});
|
|
|
|
it("can bypass token smoothing when disabled", async () => {
|
|
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
|
const smoother = createTokenSmoother({
|
|
enabled: false,
|
|
delayMs: 20,
|
|
locale: "zh",
|
|
sessionId: "client-session-1",
|
|
write: (event, data) => events.push({ event, data }),
|
|
});
|
|
|
|
smoother.writeToken("第一段");
|
|
smoother.writeToken("第二段");
|
|
await smoother.flush();
|
|
|
|
expect(events.map((item) => item.data.content)).toEqual(["第一段", "第二段"]);
|
|
});
|
|
|
|
it("buffers Chinese single-character deltas until a bounded chunk is available", async () => {
|
|
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
|
const smoother = createTokenSmoother({
|
|
enabled: true,
|
|
delayMs: 1,
|
|
locale: "zh",
|
|
sessionId: "client-session-1",
|
|
write: (event, data) => events.push({ event, data }),
|
|
});
|
|
|
|
smoother.writeToken("管");
|
|
await sleep(8);
|
|
expect(events).toHaveLength(0);
|
|
|
|
const content = "网压力异常需要继续分析东部主干供水走廊和相关阀门状态";
|
|
for (const char of Array.from(content)) {
|
|
smoother.writeToken(char);
|
|
}
|
|
await sleep(20);
|
|
|
|
expect(events.length).toBeGreaterThan(0);
|
|
await smoother.flush();
|
|
expect(events.map((item) => item.data.content).join("")).toBe(`管${content}`);
|
|
});
|
|
|
|
it("smooths long Chinese text without waiting for paragraph-sized buffers", async () => {
|
|
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
|
const smoother = createTokenSmoother({
|
|
enabled: true,
|
|
delayMs: 1,
|
|
locale: "zh",
|
|
sessionId: "client-session-1",
|
|
write: (event, data) => events.push({ event, data }),
|
|
});
|
|
|
|
const content = "管网压力异常需要继续分析东部主干供水走廊和相关阀门状态变化";
|
|
for (const char of Array.from(content)) {
|
|
smoother.writeToken(char);
|
|
}
|
|
await sleep(20);
|
|
|
|
expect(events.length).toBeGreaterThan(0);
|
|
await smoother.flush();
|
|
expect(events.map((item) => item.data.content).join("")).toBe(content);
|
|
});
|
|
|
|
it("flushes smoothed token output before stream completion", async () => {
|
|
const runtime = {
|
|
subscribeEvents: async () =>
|
|
createEventStream([
|
|
{
|
|
type: "message.part.updated",
|
|
properties: {
|
|
sessionID: "runtime-session-1",
|
|
part: {
|
|
id: "text-part-1",
|
|
sessionID: "runtime-session-1",
|
|
messageID: "message-1",
|
|
type: "text",
|
|
text: "",
|
|
time: { start: Date.now() },
|
|
},
|
|
time: Date.now(),
|
|
},
|
|
},
|
|
{
|
|
type: "message.part.delta",
|
|
properties: {
|
|
sessionID: "runtime-session-1",
|
|
partID: "text-part-1",
|
|
field: "text",
|
|
delta: "管网压力异常需要继续分析",
|
|
},
|
|
},
|
|
{
|
|
type: "message.part.delta",
|
|
properties: {
|
|
sessionID: "runtime-session-1",
|
|
partID: "text-part-1",
|
|
field: "text",
|
|
delta: "东部主干供水走廊和相关阀门状态。",
|
|
},
|
|
},
|
|
{
|
|
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: "analyze",
|
|
write: (event, data) => events.push({ event, data }),
|
|
});
|
|
|
|
const doneIndex = events.findIndex((item) => item.event === "done");
|
|
const tokenEvents = events.filter((item) => item.event === "token");
|
|
|
|
expect(doneIndex).toBeGreaterThan(-1);
|
|
expect(tokenEvents.length).toBeGreaterThan(0);
|
|
expect(tokenEvents.every((item) => events.indexOf(item) < doneIndex)).toBe(true);
|
|
expect(tokenEvents.map((item) => item.data.content).join("")).toBe(
|
|
"管网压力异常需要继续分析东部主干供水走廊和相关阀门状态。",
|
|
);
|
|
});
|
|
|
|
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 replies always when approval mode is always", async () => {
|
|
const replies: Array<Record<string, unknown>> = [];
|
|
const runtime = {
|
|
subscribeEvents: async () =>
|
|
createEventStream([
|
|
{
|
|
type: "permission.asked",
|
|
properties: {
|
|
id: "perm-1",
|
|
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",
|
|
write: (event, data) => events.push({ event, data }),
|
|
});
|
|
|
|
expect(replies).toEqual([
|
|
{
|
|
requestId: "perm-1",
|
|
sessionId: "runtime-session-1",
|
|
reply: "always",
|
|
},
|
|
]);
|
|
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: "always",
|
|
});
|
|
});
|
|
|
|
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("maps visual tool calls to UIEnvelope SSE payloads", async () => {
|
|
const runtime = {
|
|
subscribeEvents: async () =>
|
|
createEventStream([
|
|
{
|
|
type: "message.part.updated",
|
|
properties: {
|
|
sessionID: "runtime-session-1",
|
|
part: {
|
|
id: "tool-part-chart",
|
|
sessionID: "runtime-session-1",
|
|
messageID: "message-1",
|
|
type: "tool",
|
|
callID: "call-chart",
|
|
tool: "show_chart",
|
|
state: {
|
|
status: "running",
|
|
input: {
|
|
reason: "展示压力趋势",
|
|
title: "压力趋势",
|
|
chart_type: "line",
|
|
x_data: ["00:00", "01:00"],
|
|
series: [{ name: "P-1", data: [0.4, 0.42] }],
|
|
},
|
|
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: "chart",
|
|
write: (event, data) => events.push({ event, data }),
|
|
});
|
|
|
|
expect(events.find((item) => item.event === "tool_call")?.data).toMatchObject({
|
|
session_id: "client-session-1",
|
|
tool: "show_chart",
|
|
});
|
|
expect(events.find((item) => item.event === "ui_envelope")?.data).toMatchObject({
|
|
session_id: "client-session-1",
|
|
envelope: {
|
|
kind: "chart",
|
|
schemaVersion: "agent-ui@1",
|
|
grammar: "echarts-safe-subset",
|
|
},
|
|
});
|
|
});
|
|
});
|