fix(agent): stabilize sandboxed streaming workflows
This commit is contained in:
@@ -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("已从结构化结果恢复最终回答。");
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -90,6 +90,136 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpencodeRuntimeAdapter.subscribeEvents", () => {
|
||||
it("subscribes to the conversation workspace directory", async () => {
|
||||
const calls: Array<Record<string, unknown> | undefined> = [];
|
||||
const stream = (async function* () {
|
||||
return;
|
||||
})();
|
||||
const client = {
|
||||
event: {
|
||||
subscribe: async (input?: Record<string, unknown>) => {
|
||||
calls.push(input);
|
||||
return { stream };
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
ensureClient: async () => client,
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
|
||||
await expect(
|
||||
runtime.subscribeEvents("/tmp/conversation-workspace-1"),
|
||||
).resolves.toBe(stream);
|
||||
expect(calls).toEqual([
|
||||
{ directory: "/tmp/conversation-workspace-1" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpencodeRuntimeAdapter.prompt", () => {
|
||||
it("forwards the final answer schema", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const client = {
|
||||
session: {
|
||||
prompt: async (input: unknown) => {
|
||||
calls.push(input);
|
||||
return { data: {} };
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
ensureClient: async () => client,
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
|
||||
await runtime.prompt(
|
||||
"session-1",
|
||||
"分析供水分区",
|
||||
{ providerID: "deepseek", modelID: "deepseek-v4-flash" },
|
||||
{
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { answer: { type: "string" } },
|
||||
required: ["answer"],
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
sessionID: "session-1",
|
||||
model: {
|
||||
providerID: "deepseek",
|
||||
modelID: "deepseek-v4-flash",
|
||||
},
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: { answer: { type: "string" } },
|
||||
required: ["answer"],
|
||||
},
|
||||
},
|
||||
parts: [{ type: "text", text: "分析供水分区" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpencodeRuntimeAdapter interaction replies", () => {
|
||||
it("replies to permissions in the conversation workspace directory", async () => {
|
||||
const calls: unknown[] = [];
|
||||
const client = {
|
||||
permission: {
|
||||
reply: async (input: unknown) => {
|
||||
calls.push(input);
|
||||
return { data: true };
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
ensureClient: async () => client,
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
|
||||
await runtime.replyPermission({
|
||||
requestId: "permission-1",
|
||||
sessionId: "session-1",
|
||||
directory: "/tmp/conversation-workspace-1",
|
||||
reply: "once",
|
||||
});
|
||||
|
||||
expect(calls).toEqual([
|
||||
{
|
||||
requestID: "permission-1",
|
||||
directory: "/tmp/conversation-workspace-1",
|
||||
reply: "once",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("fails when OpenCode returns no permission reply data", async () => {
|
||||
const client = {
|
||||
permission: {
|
||||
reply: async () => ({ data: undefined }),
|
||||
},
|
||||
} as unknown as OpencodeClient;
|
||||
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
ensureClient: async () => client,
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
|
||||
await expect(
|
||||
runtime.replyPermission({
|
||||
requestId: "permission-1",
|
||||
directory: "/tmp/conversation-workspace-1",
|
||||
reply: "once",
|
||||
}),
|
||||
).rejects.toThrow("permission.reply returned no data");
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpencodeRuntimeAdapter.createSession", () => {
|
||||
it("creates a real chat session inside a dedicated conversation workspace", async () => {
|
||||
const workspaceRoot = await mkdtemp(join(tmpdir(), "tjwater-conversations-"));
|
||||
@@ -225,11 +355,16 @@ describe("OpencodeRuntimeAdapter.warmup", () => {
|
||||
await runtime.replyQuestion({
|
||||
requestId: "question-1",
|
||||
sessionId: "session-1",
|
||||
directory: "/tmp/conversation-workspace-1",
|
||||
answers: [["继续"]],
|
||||
});
|
||||
|
||||
expect(calls).toEqual([
|
||||
{ requestID: "question-1", answers: [["继续"]] },
|
||||
{
|
||||
requestID: "question-1",
|
||||
directory: "/tmp/conversation-workspace-1",
|
||||
answers: [["继续"]],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user