feat(opencode): migrate agent runtime to v2
This commit is contained in:
+149
-87
@@ -21,20 +21,18 @@ describe("streamPromptResponse", () => {
|
||||
createEventStream([
|
||||
{
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
sessionId: "runtime-session-1",
|
||||
request: {
|
||||
id: "perm-1",
|
||||
sessionID: "runtime-session-1",
|
||||
permission: "bash",
|
||||
patterns: ["rm *"],
|
||||
action: "shell",
|
||||
resources: ["rm *"],
|
||||
metadata: { command: "rm tmp.txt" },
|
||||
always: ["rm *"],
|
||||
save: ["rm *"],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "session.idle",
|
||||
properties: {
|
||||
sessionID: "runtime-session-1",
|
||||
},
|
||||
type: "session.execution.succeeded",
|
||||
sessionId: "runtime-session-1",
|
||||
},
|
||||
]),
|
||||
prompt: async () => undefined,
|
||||
@@ -54,7 +52,7 @@ describe("streamPromptResponse", () => {
|
||||
expect(permissionEvent?.data).toMatchObject({
|
||||
session_id: "client-session-1",
|
||||
request_id: "perm-1",
|
||||
permission: "bash",
|
||||
permission: "shell",
|
||||
patterns: ["rm *"],
|
||||
target: "rm tmp.txt",
|
||||
always: ["rm *"],
|
||||
@@ -68,20 +66,18 @@ describe("streamPromptResponse", () => {
|
||||
createEventStream([
|
||||
{
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
sessionId: "runtime-session-1",
|
||||
request: {
|
||||
id: "perm-1",
|
||||
sessionID: "runtime-session-1",
|
||||
permission: "bash",
|
||||
patterns: ["npm test"],
|
||||
action: "shell",
|
||||
resources: ["npm test"],
|
||||
metadata: { command: "npm test" },
|
||||
always: ["npm test"],
|
||||
save: ["npm test"],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "session.idle",
|
||||
properties: {
|
||||
sessionID: "runtime-session-1",
|
||||
},
|
||||
type: "session.execution.succeeded",
|
||||
sessionId: "runtime-session-1",
|
||||
},
|
||||
]),
|
||||
prompt: async () => undefined,
|
||||
@@ -116,15 +112,15 @@ describe("streamPromptResponse", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards opencode v2 permission requests as SSE payloads", async () => {
|
||||
it("forwards external-directory V2 permission requests as SSE payloads", async () => {
|
||||
const runtime = {
|
||||
subscribeEvents: async () =>
|
||||
createEventStream([
|
||||
{
|
||||
type: "permission.v2.asked",
|
||||
properties: {
|
||||
type: "permission.asked",
|
||||
sessionId: "runtime-session-1",
|
||||
request: {
|
||||
id: "perm-v2-1",
|
||||
sessionID: "runtime-session-1",
|
||||
action: "external_directory",
|
||||
resources: ["/tmp"],
|
||||
save: ["/tmp"],
|
||||
@@ -132,10 +128,8 @@ describe("streamPromptResponse", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "session.idle",
|
||||
properties: {
|
||||
sessionID: "runtime-session-1",
|
||||
},
|
||||
type: "session.execution.succeeded",
|
||||
sessionId: "runtime-session-1",
|
||||
},
|
||||
]),
|
||||
prompt: async () => undefined,
|
||||
@@ -168,9 +162,9 @@ describe("streamPromptResponse", () => {
|
||||
createEventStream([
|
||||
{
|
||||
type: "question.asked",
|
||||
properties: {
|
||||
sessionId: "runtime-session-1",
|
||||
request: {
|
||||
id: "question-1",
|
||||
sessionID: "runtime-session-1",
|
||||
questions: [
|
||||
{
|
||||
header: "范围",
|
||||
@@ -184,17 +178,13 @@ describe("streamPromptResponse", () => {
|
||||
},
|
||||
{
|
||||
type: "question.replied",
|
||||
properties: {
|
||||
sessionID: "runtime-session-1",
|
||||
requestID: "question-1",
|
||||
answers: [["城区", "补充说明"]],
|
||||
},
|
||||
sessionId: "runtime-session-1",
|
||||
requestId: "question-1",
|
||||
answers: [["城区", "补充说明"]],
|
||||
},
|
||||
{
|
||||
type: "session.idle",
|
||||
properties: {
|
||||
sessionID: "runtime-session-1",
|
||||
},
|
||||
type: "session.execution.succeeded",
|
||||
sessionId: "runtime-session-1",
|
||||
},
|
||||
]),
|
||||
prompt: async () => undefined,
|
||||
@@ -230,19 +220,16 @@ describe("streamPromptResponse", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("converts question tool parts into question request SSE payloads", async () => {
|
||||
it("waits for the Form event instead of exposing the question tool call ID", async () => {
|
||||
const runtime = {
|
||||
subscribeEvents: async () =>
|
||||
createEventStream([
|
||||
{
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
sessionID: "runtime-session-1",
|
||||
part: {
|
||||
type: "tool.updated",
|
||||
sessionId: "runtime-session-1",
|
||||
part: {
|
||||
id: "tool-part-1",
|
||||
sessionID: "runtime-session-1",
|
||||
messageID: "message-1",
|
||||
type: "tool",
|
||||
callID: "call-1",
|
||||
tool: "question",
|
||||
state: {
|
||||
@@ -261,17 +248,31 @@ describe("streamPromptResponse", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
time: { start: Date.now() },
|
||||
},
|
||||
},
|
||||
time: Date.now(),
|
||||
},
|
||||
{
|
||||
type: "question.asked",
|
||||
sessionId: "runtime-session-1",
|
||||
request: {
|
||||
id: "frm_1",
|
||||
questions: [
|
||||
{
|
||||
header: "测试问题",
|
||||
question: "你觉得这个 question 工具好用吗?",
|
||||
options: [
|
||||
{
|
||||
label: "非常好用",
|
||||
description: "交互清晰,选项方便",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "session.idle",
|
||||
properties: {
|
||||
sessionID: "runtime-session-1",
|
||||
},
|
||||
type: "session.execution.succeeded",
|
||||
sessionId: "runtime-session-1",
|
||||
},
|
||||
]),
|
||||
prompt: async () => undefined,
|
||||
@@ -289,7 +290,7 @@ describe("streamPromptResponse", () => {
|
||||
|
||||
expect(events.find((item) => item.event === "question_request")?.data).toMatchObject({
|
||||
session_id: "client-session-1",
|
||||
request_id: "call-1",
|
||||
request_id: "frm_1",
|
||||
questions: [
|
||||
{
|
||||
header: "测试问题",
|
||||
@@ -302,11 +303,12 @@ 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",
|
||||
@@ -314,25 +316,32 @@ describe("streamPromptResponse", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("forwards todo updates as structured SSE payloads and progress", async () => {
|
||||
it("forwards normalized V2 text and reasoning deltas", async () => {
|
||||
const runtime = {
|
||||
subscribeEvents: async () =>
|
||||
createEventStream([
|
||||
{
|
||||
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",
|
||||
delta: "分析中",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
type: "session.idle",
|
||||
properties: {
|
||||
sessionID: "runtime-session-1",
|
||||
},
|
||||
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",
|
||||
},
|
||||
]),
|
||||
prompt: async () => undefined,
|
||||
@@ -348,30 +357,83 @@ 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 === "todo-progress",
|
||||
(item) =>
|
||||
item.event === "progress" &&
|
||||
item.data.id === "reasoning-1" &&
|
||||
item.data.status === "completed",
|
||||
)?.data,
|
||||
).toMatchObject({
|
||||
id: "todo-progress",
|
||||
phase: "planning",
|
||||
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",
|
||||
}),
|
||||
],
|
||||
title: "分析规划完成",
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user