fix(agent): smooth Chinese stream chunks
This commit is contained in:
@@ -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 () =>
|
||||
|
||||
Reference in New Issue
Block a user