fix(chat): handle question and todo state
This commit is contained in:
@@ -161,4 +161,217 @@ describe("streamPromptResponse", () => {
|
||||
always: ["/tmp"],
|
||||
} satisfies Partial<PermissionRequestPayload>);
|
||||
});
|
||||
|
||||
it("forwards opencode question requests and replies as SSE payloads", async () => {
|
||||
const runtime = {
|
||||
subscribeEvents: async () =>
|
||||
createEventStream([
|
||||
{
|
||||
type: "question.asked",
|
||||
properties: {
|
||||
id: "question-1",
|
||||
sessionID: "runtime-session-1",
|
||||
questions: [
|
||||
{
|
||||
header: "范围",
|
||||
question: "选择分析范围",
|
||||
options: [{ label: "城区", description: "中心城区" }],
|
||||
multiple: false,
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "question.replied",
|
||||
properties: {
|
||||
sessionID: "runtime-session-1",
|
||||
requestID: "question-1",
|
||||
answers: [["城区", "补充说明"]],
|
||||
},
|
||||
},
|
||||
{
|
||||
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: "ask",
|
||||
write: (event, data) => events.push({ event, data }),
|
||||
});
|
||||
|
||||
expect(events.find((item) => item.event === "question_request")?.data).toMatchObject({
|
||||
session_id: "client-session-1",
|
||||
request_id: "question-1",
|
||||
questions: [
|
||||
{
|
||||
header: "范围",
|
||||
question: "选择分析范围",
|
||||
options: [{ label: "城区", description: "中心城区" }],
|
||||
multiple: false,
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(events.find((item) => item.event === "question_response")?.data).toEqual({
|
||||
session_id: "client-session-1",
|
||||
request_id: "question-1",
|
||||
answers: [["城区", "补充说明"]],
|
||||
});
|
||||
});
|
||||
|
||||
it("converts question tool parts into question request SSE payloads", async () => {
|
||||
const runtime = {
|
||||
subscribeEvents: async () =>
|
||||
createEventStream([
|
||||
{
|
||||
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: {
|
||||
status: "running",
|
||||
input: {
|
||||
questions: [
|
||||
{
|
||||
question: "你觉得这个 question 工具好用吗?",
|
||||
header: "测试问题",
|
||||
options: [
|
||||
{
|
||||
label: "非常好用",
|
||||
description: "交互清晰,选项方便",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
time: { start: Date.now() },
|
||||
},
|
||||
},
|
||||
time: Date.now(),
|
||||
},
|
||||
},
|
||||
{
|
||||
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: "ask",
|
||||
write: (event, data) => events.push({ event, data }),
|
||||
});
|
||||
|
||||
expect(events.find((item) => item.event === "question_request")?.data).toMatchObject({
|
||||
session_id: "client-session-1",
|
||||
request_id: "call-1",
|
||||
questions: [
|
||||
{
|
||||
header: "测试问题",
|
||||
question: "你觉得这个 question 工具好用吗?",
|
||||
options: [
|
||||
{
|
||||
label: "非常好用",
|
||||
description: "交互清晰,选项方便",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
tool: {
|
||||
messageID: "message-1",
|
||||
callID: "call-1",
|
||||
},
|
||||
});
|
||||
expect(
|
||||
events.some(
|
||||
(item) => item.event === "tool_call" && item.data.tool === "question",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("forwards todo updates as structured SSE payloads and progress", 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: "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: "plan",
|
||||
write: (event, data) => events.push({ event, data }),
|
||||
});
|
||||
|
||||
expect(
|
||||
events.find(
|
||||
(item) => item.event === "progress" && item.data.id === "todo-progress",
|
||||
)?.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",
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import {
|
||||
cancelBackendTodos,
|
||||
upsertBackendQuestion,
|
||||
} from "../../src/routes/chatUiState.js";
|
||||
|
||||
describe("upsertBackendQuestion", () => {
|
||||
it("replaces a tool-call placeholder with the actionable question request", () => {
|
||||
const questions = upsertBackendQuestion(
|
||||
[
|
||||
{
|
||||
requestId: "call-1",
|
||||
sessionId: "session-1",
|
||||
questions: [
|
||||
{
|
||||
header: "测试问题",
|
||||
question: "你觉得这个 question 工具好用吗?",
|
||||
options: [{ label: "非常好用", description: "交互清晰,选项方便" }],
|
||||
},
|
||||
],
|
||||
tool: { messageID: "message-1", callID: "call-1" },
|
||||
createdAt: 123,
|
||||
status: "pending",
|
||||
},
|
||||
],
|
||||
{
|
||||
session_id: "session-1",
|
||||
request_id: "question-1",
|
||||
questions: [
|
||||
{
|
||||
header: "测试问题",
|
||||
question: "你觉得这个 question 工具好用吗?",
|
||||
options: [{ label: "非常好用", description: "交互清晰,选项方便" }],
|
||||
},
|
||||
],
|
||||
tool: { messageID: "message-1", callID: "call-1" },
|
||||
created_at: 456,
|
||||
},
|
||||
);
|
||||
|
||||
expect(questions).toHaveLength(1);
|
||||
expect(questions[0]).toMatchObject({
|
||||
requestId: "question-1",
|
||||
tool: { callID: "call-1" },
|
||||
status: "pending",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not replace an actionable question request with a later tool-call placeholder", () => {
|
||||
const questions = upsertBackendQuestion(
|
||||
[
|
||||
{
|
||||
requestId: "question-1",
|
||||
sessionId: "session-1",
|
||||
questions: [
|
||||
{
|
||||
header: "测试问题",
|
||||
question: "你觉得这个 question 工具好用吗?",
|
||||
options: [{ label: "非常好用", description: "交互清晰,选项方便" }],
|
||||
},
|
||||
],
|
||||
tool: { messageID: "message-1", callID: "call-1" },
|
||||
createdAt: 123,
|
||||
status: "pending",
|
||||
},
|
||||
],
|
||||
{
|
||||
session_id: "session-1",
|
||||
request_id: "call-1",
|
||||
questions: [
|
||||
{
|
||||
header: "测试问题",
|
||||
question: "你觉得这个 question 工具好用吗?",
|
||||
options: [{ label: "非常好用", description: "交互清晰,选项方便" }],
|
||||
},
|
||||
],
|
||||
tool: { messageID: "message-1", callID: "call-1" },
|
||||
created_at: 456,
|
||||
},
|
||||
);
|
||||
|
||||
expect(questions).toHaveLength(1);
|
||||
expect(questions[0]).toMatchObject({
|
||||
requestId: "question-1",
|
||||
tool: { callID: "call-1" },
|
||||
status: "pending",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelBackendTodos", () => {
|
||||
it("marks pending and in-progress todos as cancelled", () => {
|
||||
const cancelled = cancelBackendTodos([
|
||||
{
|
||||
sessionId: "session-1",
|
||||
todos: [
|
||||
{ id: "todo-1", content: "分析水位", status: "in_progress" },
|
||||
{ id: "todo-2", content: "生成建议", status: "pending" },
|
||||
{ id: "todo-3", content: "完成报告", status: "completed" },
|
||||
],
|
||||
createdAt: 123,
|
||||
},
|
||||
]);
|
||||
|
||||
expect(cancelled).toEqual([
|
||||
expect.objectContaining({
|
||||
todos: [
|
||||
expect.objectContaining({
|
||||
id: "todo-1",
|
||||
status: "cancelled",
|
||||
updatedAt: expect.any(Number),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "todo-2",
|
||||
status: "cancelled",
|
||||
updatedAt: expect.any(Number),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "todo-3",
|
||||
status: "completed",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
|
||||
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
||||
|
||||
const createRuntimeAdapter = (
|
||||
messages: unknown[],
|
||||
calls: {
|
||||
reverted: string[];
|
||||
removed: string[];
|
||||
} = { reverted: [], removed: [] },
|
||||
) =>
|
||||
Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
messages: async () => messages,
|
||||
revertMessage: async (_sessionId: string, messageId: string) => {
|
||||
calls.reverted.push(messageId);
|
||||
},
|
||||
removeMessage: async (_sessionId: string, messageId: string) => {
|
||||
calls.removed.push(messageId);
|
||||
},
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
|
||||
describe("OpencodeRuntimeAdapter.revertToUserMessage", () => {
|
||||
it("skips reverting the first user message when the runtime session is empty", async () => {
|
||||
const calls = { reverted: [] as string[], removed: [] as string[] };
|
||||
const runtime = createRuntimeAdapter([], calls);
|
||||
|
||||
await runtime.revertToUserMessage("session-1", { userOrdinal: 1 });
|
||||
|
||||
expect(calls).toEqual({ reverted: [], removed: [] });
|
||||
});
|
||||
|
||||
it("keeps ordinal mismatches visible when runtime messages exist", async () => {
|
||||
const runtime = createRuntimeAdapter([
|
||||
{ info: { id: "user-1", role: "user" } },
|
||||
{ info: { id: "assistant-1", role: "assistant" } },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
runtime.revertToUserMessage("session-1", { userOrdinal: 2 }),
|
||||
).rejects.toThrow("target user message not found to revert");
|
||||
});
|
||||
|
||||
it("reverts and removes messages from the target user message onward", async () => {
|
||||
const calls = { reverted: [] as string[], removed: [] as string[] };
|
||||
const runtime = createRuntimeAdapter(
|
||||
[
|
||||
{ info: { id: "user-1", role: "user" } },
|
||||
{ info: { id: "assistant-1", role: "assistant" } },
|
||||
{ info: { id: "user-2", role: "user" } },
|
||||
{ info: { id: "assistant-2", role: "assistant" } },
|
||||
],
|
||||
calls,
|
||||
);
|
||||
|
||||
await runtime.revertToUserMessage("session-1", { userOrdinal: 2 });
|
||||
|
||||
expect(calls).toEqual({
|
||||
reverted: ["user-2"],
|
||||
removed: ["assistant-2", "user-2"],
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user