fix(agent): stabilize sandboxed streaming workflows

This commit is contained in:
2026-08-26 12:26:29 +08:00
parent 80cfc1f2ab
commit 4ec010455b
12 changed files with 916 additions and 34 deletions
+290 -3
View File
@@ -16,9 +16,11 @@ const createEventStream = (events: unknown[]) => ({
describe("streamPromptResponse", () => {
it("emits only the final assistant text after tool-driven intermediate messages", async () => {
let subscribedDirectory: string | undefined;
const runtime = {
subscribeEvents: async () =>
createEventStream([
subscribeEvents: async (directory?: string) => {
subscribedDirectory = directory;
return createEventStream([
{
type: "message.part.delta",
properties: {
@@ -73,7 +75,8 @@ describe("streamPromptResponse", () => {
type: "session.idle",
properties: { sessionID: "runtime-session-1" },
},
]),
]);
},
prompt: async () => undefined,
messages: async () => [
{
@@ -109,9 +112,11 @@ describe("streamPromptResponse", () => {
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")
@@ -120,6 +125,143 @@ describe("streamPromptResponse", () => {
).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 () =>
@@ -334,6 +476,7 @@ describe("streamPromptResponse", () => {
clientSessionId: "client-session-1",
message: "run tests",
approvalMode: "auto",
workspaceRoot: "/tmp/conversation-workspace-1",
write: (event, data) => events.push({ event, data }),
});
@@ -341,6 +484,7 @@ describe("streamPromptResponse", () => {
{
requestId: "perm-1",
sessionId: "runtime-session-1",
directory: "/tmp/conversation-workspace-1",
reply: "once",
},
]);
@@ -422,6 +566,7 @@ describe("streamPromptResponse", () => {
clientSessionId: "client-session-1",
message: "run tests",
approvalMode: "always",
workspaceRoot: "/tmp/conversation-workspace-1",
write: (event, data) => events.push({ event, data }),
});
@@ -429,6 +574,7 @@ describe("streamPromptResponse", () => {
{
requestId: "perm-always-bash",
sessionId: "runtime-session-1",
directory: "/tmp/conversation-workspace-1",
reply: "once",
},
]);
@@ -465,6 +611,7 @@ describe("streamPromptResponse", () => {
clientSessionId: "client-session-1",
message: "delete recursively",
approvalMode: "always",
workspaceRoot: "/tmp/conversation-workspace-1",
write: (event, data) => events.push({ event, data }),
});
@@ -472,6 +619,7 @@ describe("streamPromptResponse", () => {
{
requestId: "perm-always-rm-rf",
sessionId: "runtime-session-1",
directory: "/tmp/conversation-workspace-1",
reply: "reject",
},
]);
@@ -741,4 +889,143 @@ describe("streamPromptResponse", () => {
});
});
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("已从结构化结果恢复最终回答。");
});
});