fix(agent): smooth Chinese stream chunks

This commit is contained in:
2026-07-02 20:22:47 +08:00
parent 60b7acd1bf
commit 9a0dea799b
3 changed files with 398 additions and 14 deletions
+164
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from "bun:test";
import {
createTokenSmoother,
detectTokenSmoothingChunk,
streamPromptResponse,
type PermissionRequestPayload,
} from "../../src/routes/chatStream.js";
@@ -14,7 +16,169 @@ const createEventStream = (events: unknown[]) => ({
},
});
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe("streamPromptResponse", () => {
it("detects Chinese punctuation chunks for smooth token output", () => {
expect(detectTokenSmoothingChunk("管网压力异常,需要继续分析", "zh")).toBe(
"管网压力异常,",
);
expect(detectTokenSmoothingChunk("管网压力异常需要继续分析。后续排查阀门", "zh")).toBe(
"管网压力异常需要继续分析。",
);
});
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 punctuation", 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);
for (const char of Array.from("网压力异常,")) {
smoother.writeToken(char);
}
await sleep(20);
expect(events.map((item) => item.data.content).join("")).toBe("管网压力异常,");
await smoother.flush();
});
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 () =>