fix(opencode): restore stable v1 runtime

This commit is contained in:
2026-08-05 17:59:35 +08:00
parent 764a1f4e82
commit 1407dd3bbe
33 changed files with 1106 additions and 1668 deletions
+87 -149
View File
@@ -21,18 +21,20 @@ describe("streamPromptResponse", () => {
createEventStream([
{
type: "permission.asked",
sessionId: "runtime-session-1",
request: {
properties: {
id: "perm-1",
action: "shell",
resources: ["rm *"],
sessionID: "runtime-session-1",
permission: "bash",
patterns: ["rm *"],
metadata: { command: "rm tmp.txt" },
save: ["rm *"],
always: ["rm *"],
},
},
{
type: "session.execution.succeeded",
sessionId: "runtime-session-1",
type: "session.idle",
properties: {
sessionID: "runtime-session-1",
},
},
]),
prompt: async () => undefined,
@@ -52,7 +54,7 @@ describe("streamPromptResponse", () => {
expect(permissionEvent?.data).toMatchObject({
session_id: "client-session-1",
request_id: "perm-1",
permission: "shell",
permission: "bash",
patterns: ["rm *"],
target: "rm tmp.txt",
always: ["rm *"],
@@ -66,18 +68,20 @@ describe("streamPromptResponse", () => {
createEventStream([
{
type: "permission.asked",
sessionId: "runtime-session-1",
request: {
properties: {
id: "perm-1",
action: "shell",
resources: ["npm test"],
sessionID: "runtime-session-1",
permission: "bash",
patterns: ["npm test"],
metadata: { command: "npm test" },
save: ["npm test"],
always: ["npm test"],
},
},
{
type: "session.execution.succeeded",
sessionId: "runtime-session-1",
type: "session.idle",
properties: {
sessionID: "runtime-session-1",
},
},
]),
prompt: async () => undefined,
@@ -112,15 +116,15 @@ describe("streamPromptResponse", () => {
});
});
it("forwards external-directory V2 permission requests as SSE payloads", async () => {
it("forwards opencode v2 permission requests as SSE payloads", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "permission.asked",
sessionId: "runtime-session-1",
request: {
type: "permission.v2.asked",
properties: {
id: "perm-v2-1",
sessionID: "runtime-session-1",
action: "external_directory",
resources: ["/tmp"],
save: ["/tmp"],
@@ -128,8 +132,10 @@ describe("streamPromptResponse", () => {
},
},
{
type: "session.execution.succeeded",
sessionId: "runtime-session-1",
type: "session.idle",
properties: {
sessionID: "runtime-session-1",
},
},
]),
prompt: async () => undefined,
@@ -162,9 +168,9 @@ describe("streamPromptResponse", () => {
createEventStream([
{
type: "question.asked",
sessionId: "runtime-session-1",
request: {
properties: {
id: "question-1",
sessionID: "runtime-session-1",
questions: [
{
header: "范围",
@@ -178,13 +184,17 @@ describe("streamPromptResponse", () => {
},
{
type: "question.replied",
sessionId: "runtime-session-1",
requestId: "question-1",
answers: [["城区", "补充说明"]],
properties: {
sessionID: "runtime-session-1",
requestID: "question-1",
answers: [["城区", "补充说明"]],
},
},
{
type: "session.execution.succeeded",
sessionId: "runtime-session-1",
type: "session.idle",
properties: {
sessionID: "runtime-session-1",
},
},
]),
prompt: async () => undefined,
@@ -220,16 +230,19 @@ describe("streamPromptResponse", () => {
});
});
it("waits for the Form event instead of exposing the question tool call ID", async () => {
it("converts question tool parts into question request SSE payloads", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "tool.updated",
sessionId: "runtime-session-1",
part: {
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: {
@@ -248,31 +261,17 @@ describe("streamPromptResponse", () => {
},
],
},
time: { start: Date.now() },
},
},
},
{
type: "question.asked",
sessionId: "runtime-session-1",
request: {
id: "frm_1",
questions: [
{
header: "测试问题",
question: "你觉得这个 question 工具好用吗?",
options: [
{
label: "非常好用",
description: "交互清晰,选项方便",
},
],
},
],
time: Date.now(),
},
},
{
type: "session.execution.succeeded",
sessionId: "runtime-session-1",
type: "session.idle",
properties: {
sessionID: "runtime-session-1",
},
},
]),
prompt: async () => undefined,
@@ -290,7 +289,7 @@ describe("streamPromptResponse", () => {
expect(events.find((item) => item.event === "question_request")?.data).toMatchObject({
session_id: "client-session-1",
request_id: "frm_1",
request_id: "call-1",
questions: [
{
header: "测试问题",
@@ -303,12 +302,11 @@ describe("streamPromptResponse", () => {
],
},
],
tool: {
messageID: "message-1",
callID: "call-1",
},
});
expect(
events.some(
(item) => item.event === "question_request" && item.data.request_id === "call-1",
),
).toBe(false);
expect(
events.some(
(item) => item.event === "tool_call" && item.data.tool === "question",
@@ -316,32 +314,25 @@ describe("streamPromptResponse", () => {
).toBe(false);
});
it("forwards normalized V2 text and reasoning deltas", async () => {
it("forwards todo updates as structured SSE payloads and progress", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "reasoning.updated",
sessionId: "runtime-session-1",
partId: "reasoning-1",
delta: "分析中",
completed: false,
type: "todo.updated",
properties: {
sessionID: "runtime-session-1",
todos: [
{ content: "分析水位", status: "completed", priority: "high" },
{ content: "生成建议", status: "in_progress", priority: "medium" },
],
},
},
{
type: "reasoning.updated",
sessionId: "runtime-session-1",
partId: "reasoning-1",
completed: true,
},
{
type: "text.delta",
sessionId: "runtime-session-1",
partId: "text-1",
delta: "完成",
},
{
type: "session.execution.succeeded",
sessionId: "runtime-session-1",
type: "session.idle",
properties: {
sessionID: "runtime-session-1",
},
},
]),
prompt: async () => undefined,
@@ -357,83 +348,30 @@ describe("streamPromptResponse", () => {
write: (event, data) => events.push({ event, data }),
});
expect(events.find((item) => item.event === "token")?.data).toEqual({
session_id: "client-session-1",
content: "完成",
});
expect(
events.find(
(item) =>
item.event === "progress" &&
item.data.id === "reasoning-1" &&
item.data.status === "completed",
(item) => item.event === "progress" && item.data.id === "todo-progress",
)?.data,
).toMatchObject({
id: "todo-progress",
phase: "planning",
title: "分析规划完成",
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("retains the first runtime event when prompt resolves before delivery", async () => {
const runtime = {
subscribeEvents: async () => ({
async *[Symbol.asyncIterator]() {
await Promise.resolve();
yield {
type: "text.delta",
sessionId: "runtime-session-1",
partId: "text-1",
delta: "首个事件",
};
yield {
type: "session.execution.succeeded",
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: "race",
write: (event, data) => events.push({ event, data }),
});
expect(events.find((item) => item.event === "token")?.data.content).toBe("首个事件");
expect(events.find((item) => item.event === "done")?.data.session_id).toBe(
"client-session-1",
);
});
it("terminates the stream when V2 reports execution failure", async () => {
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "session.execution.failed",
sessionId: "runtime-session-1",
error: { name: "ExecutionError", data: { message: "执行失败" } },
},
]),
prompt: async () => undefined,
messages: async () => [],
} 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: "fail",
write: (event, data) => events.push({ event, data }),
});
expect(events.find((item) => item.event === "error")?.data.message).toBe("执行失败");
expect(result.failed).toBe(true);
});
});