fix(chat): wire question and todo cards
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
abortAgentChat,
|
||||
forkAgentChat,
|
||||
rejectAgentQuestion,
|
||||
replyAgentPermission,
|
||||
replyAgentQuestion,
|
||||
type StreamEvent,
|
||||
resumeAgentChatStream,
|
||||
streamAgentChat,
|
||||
@@ -218,6 +220,69 @@ describe("streamAgentChat", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses question request, response, and todo update events", async () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
body: makeStream([
|
||||
'event: question_request\ndata: {"session_id":"s1","request_id":"q-1","questions":[{"header":"范围","question":"选择范围","options":[{"label":"城区","description":"中心城区"}],"multiple":false,"custom":true}],"tool":{"message_id":"m1","call_id":"c1"},"created_at":123}\n\n',
|
||||
'event: question_response\ndata: {"session_id":"s1","request_id":"q-1","answers":[["城区","补充说明"]]}\n\n',
|
||||
'event: todo_update\ndata: {"session_id":"s1","todos":[{"id":"t1","content":"分析水位","status":"in_progress","priority":"high","updated_at":456}],"created_at":456}\n\n',
|
||||
]),
|
||||
});
|
||||
|
||||
const events: StreamEvent[] = [];
|
||||
|
||||
await streamAgentChat({
|
||||
message: "hi",
|
||||
onEvent: (event) => events.push(event),
|
||||
});
|
||||
|
||||
expect(events).toEqual([
|
||||
{
|
||||
type: "question_request",
|
||||
sessionId: "s1",
|
||||
requestId: "q-1",
|
||||
questions: [
|
||||
{
|
||||
header: "范围",
|
||||
question: "选择范围",
|
||||
options: [{ label: "城区", description: "中心城区" }],
|
||||
multiple: false,
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
tool: {
|
||||
messageID: "m1",
|
||||
callID: "c1",
|
||||
},
|
||||
createdAt: 123,
|
||||
},
|
||||
{
|
||||
type: "question_response",
|
||||
sessionId: "s1",
|
||||
requestId: "q-1",
|
||||
answers: [["城区", "补充说明"]],
|
||||
rejected: false,
|
||||
},
|
||||
{
|
||||
type: "todo_update",
|
||||
sessionId: "s1",
|
||||
messageId: undefined,
|
||||
todos: [
|
||||
{
|
||||
id: "t1",
|
||||
content: "分析水位",
|
||||
status: "in_progress",
|
||||
priority: "high",
|
||||
createdAt: undefined,
|
||||
updatedAt: 456,
|
||||
},
|
||||
],
|
||||
createdAt: 456,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits error when response is not ok", async () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
@@ -314,6 +379,41 @@ describe("streamAgentChat", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("calls question reply and reject endpoints", async () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 202,
|
||||
text: async () => "",
|
||||
});
|
||||
|
||||
await replyAgentQuestion("s1", "q-1", [["城区"]]);
|
||||
await rejectAgentQuestion("s1", "q-2");
|
||||
|
||||
expect(apiFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/agent/chat/question/q-1/reply"),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
projectHeaderMode: "include",
|
||||
skipAuthRedirect: true,
|
||||
body: JSON.stringify({
|
||||
session_id: "s1",
|
||||
answers: [["城区"]],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(apiFetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/v1/agent/chat/question/q-2/reject"),
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
projectHeaderMode: "include",
|
||||
skipAuthRedirect: true,
|
||||
body: JSON.stringify({
|
||||
session_id: "s1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("calls fork endpoint and returns new session id", async () => {
|
||||
apiFetch.mockResolvedValue({
|
||||
ok: true,
|
||||
|
||||
@@ -8,6 +8,53 @@ export type AgentModel =
|
||||
export type PermissionReply = "once" | "always" | "reject";
|
||||
export type AgentApprovalMode = "request" | "always";
|
||||
|
||||
export type AgentQuestionStatus =
|
||||
| "pending"
|
||||
| "submitting"
|
||||
| "answered"
|
||||
| "rejected"
|
||||
| "error";
|
||||
|
||||
export type AgentQuestionRequest = {
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
questions: Array<{
|
||||
header: string;
|
||||
question: string;
|
||||
options: Array<{
|
||||
label: string;
|
||||
description: string;
|
||||
}>;
|
||||
multiple?: boolean;
|
||||
custom?: boolean;
|
||||
}>;
|
||||
tool?: {
|
||||
messageID: string;
|
||||
callID: string;
|
||||
};
|
||||
createdAt: number;
|
||||
repliedAt?: number;
|
||||
status: AgentQuestionStatus;
|
||||
answers?: string[][];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
export type AgentTodoItem = {
|
||||
id: string;
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed" | "cancelled";
|
||||
priority?: "low" | "medium" | "high";
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
};
|
||||
|
||||
export type AgentTodoUpdate = {
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
todos: AgentTodoItem[];
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
export type StreamEvent =
|
||||
| {
|
||||
type: "state";
|
||||
@@ -64,6 +111,28 @@ export type StreamEvent =
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
reply: PermissionReply;
|
||||
}
|
||||
| {
|
||||
type: "question_request";
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
questions: AgentQuestionRequest["questions"];
|
||||
tool?: AgentQuestionRequest["tool"];
|
||||
createdAt: number;
|
||||
}
|
||||
| {
|
||||
type: "question_response";
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
answers?: string[][];
|
||||
rejected?: boolean;
|
||||
}
|
||||
| {
|
||||
type: "todo_update";
|
||||
sessionId: string;
|
||||
messageId?: string;
|
||||
todos: AgentTodoItem[];
|
||||
createdAt: number;
|
||||
};
|
||||
|
||||
type StreamOptions = {
|
||||
@@ -125,6 +194,80 @@ const resolveToolParams = (
|
||||
return isObjectRecord(params) ? params : {};
|
||||
};
|
||||
|
||||
const normalizeQuestionList = (value: unknown): AgentQuestionRequest["questions"] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.filter(isObjectRecord)
|
||||
.map((question) => ({
|
||||
header: typeof question.header === "string" ? question.header : "",
|
||||
question: typeof question.question === "string" ? question.question : "",
|
||||
options: Array.isArray(question.options)
|
||||
? question.options.filter(isObjectRecord).map((option) => ({
|
||||
label: typeof option.label === "string" ? option.label : "",
|
||||
description:
|
||||
typeof option.description === "string" ? option.description : "",
|
||||
}))
|
||||
: [],
|
||||
multiple: typeof question.multiple === "boolean" ? question.multiple : undefined,
|
||||
custom: typeof question.custom === "boolean" ? question.custom : undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
const normalizeAnswers = (value: unknown): string[][] | undefined => {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
return value.map((answer) =>
|
||||
Array.isArray(answer)
|
||||
? answer.filter((item): item is string => typeof item === "string")
|
||||
: [],
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeQuestionTool = (value: unknown): AgentQuestionRequest["tool"] => {
|
||||
if (!isObjectRecord(value)) return undefined;
|
||||
const messageID =
|
||||
typeof value.messageID === "string"
|
||||
? value.messageID
|
||||
: typeof value.message_id === "string"
|
||||
? value.message_id
|
||||
: undefined;
|
||||
const callID =
|
||||
typeof value.callID === "string"
|
||||
? value.callID
|
||||
: typeof value.call_id === "string"
|
||||
? value.call_id
|
||||
: undefined;
|
||||
return messageID && callID ? { messageID, callID } : undefined;
|
||||
};
|
||||
|
||||
const normalizeTodoStatus = (value: unknown): AgentTodoItem["status"] => {
|
||||
if (value === "in_progress" || value === "completed" || value === "cancelled") {
|
||||
return value;
|
||||
}
|
||||
return "pending";
|
||||
};
|
||||
|
||||
const normalizeTodoPriority = (value: unknown): AgentTodoItem["priority"] => {
|
||||
if (value === "low" || value === "medium" || value === "high") {
|
||||
return value;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeTodos = (value: unknown): AgentTodoItem[] => {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.filter(isObjectRecord).map((todo, index) => ({
|
||||
id:
|
||||
typeof todo.id === "string" && todo.id.trim()
|
||||
? todo.id
|
||||
: `todo-${index}`,
|
||||
content: typeof todo.content === "string" ? todo.content : "",
|
||||
status: normalizeTodoStatus(todo.status),
|
||||
priority: normalizeTodoPriority(todo.priority),
|
||||
createdAt: typeof todo.created_at === "number" ? todo.created_at : undefined,
|
||||
updatedAt: typeof todo.updated_at === "number" ? todo.updated_at : undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
const emitParsedStreamEvent = (
|
||||
event: string,
|
||||
data: string,
|
||||
@@ -158,6 +301,11 @@ const emitParsedStreamEvent = (
|
||||
always?: unknown;
|
||||
created_at?: number;
|
||||
reply?: PermissionReply;
|
||||
questions?: unknown;
|
||||
answers?: unknown;
|
||||
rejected?: boolean;
|
||||
message_id?: string;
|
||||
todos?: unknown;
|
||||
};
|
||||
if (event === "state") {
|
||||
onEvent({
|
||||
@@ -244,6 +392,31 @@ const emitParsedStreamEvent = (
|
||||
requestId: parsed.request_id ?? "",
|
||||
reply: parsed.reply ?? "reject",
|
||||
});
|
||||
} else if (event === "question_request") {
|
||||
onEvent({
|
||||
type: "question_request",
|
||||
sessionId: parsed.session_id ?? "",
|
||||
requestId: parsed.request_id ?? "",
|
||||
questions: normalizeQuestionList(parsed.questions),
|
||||
tool: normalizeQuestionTool(parsed.tool),
|
||||
createdAt: parsed.created_at ?? Date.now(),
|
||||
});
|
||||
} else if (event === "question_response") {
|
||||
onEvent({
|
||||
type: "question_response",
|
||||
sessionId: parsed.session_id ?? "",
|
||||
requestId: parsed.request_id ?? "",
|
||||
answers: normalizeAnswers(parsed.answers),
|
||||
rejected: parsed.rejected === true,
|
||||
});
|
||||
} else if (event === "todo_update") {
|
||||
onEvent({
|
||||
type: "todo_update",
|
||||
sessionId: parsed.session_id ?? "",
|
||||
messageId: parsed.message_id,
|
||||
todos: normalizeTodos(parsed.todos),
|
||||
createdAt: parsed.created_at ?? Date.now(),
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
onEvent({
|
||||
@@ -443,6 +616,60 @@ export const replyAgentPermission = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const replyAgentQuestion = async (
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
answers: string[][],
|
||||
) => {
|
||||
const response = await apiFetch(
|
||||
`${config.AGENT_URL}/api/v1/agent/chat/question/${encodeURIComponent(requestId)}/reply`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
answers,
|
||||
}),
|
||||
projectHeaderMode: "include",
|
||||
userHeaderMode: "include",
|
||||
skipAuthRedirect: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(detail || `question reply failed: ${response.status}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const rejectAgentQuestion = async (
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
) => {
|
||||
const response = await apiFetch(
|
||||
`${config.AGENT_URL}/api/v1/agent/chat/question/${encodeURIComponent(requestId)}/reject`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
}),
|
||||
projectHeaderMode: "include",
|
||||
userHeaderMode: "include",
|
||||
skipAuthRedirect: true,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(detail || `question reject failed: ${response.status}`);
|
||||
}
|
||||
};
|
||||
|
||||
export const forkAgentChat = async (sessionId: string | undefined, keepMessageCount: number) => {
|
||||
const response = await apiFetch(`${config.AGENT_URL}/api/v1/agent/chat/fork`, {
|
||||
method: "POST",
|
||||
|
||||
Reference in New Issue
Block a user