fix(chat): handle question and todo state
This commit is contained in:
+173
-277
@@ -2,7 +2,56 @@ import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
|
||||
|
||||
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
|
||||
import { logger } from "../logger.js";
|
||||
import { type PermissionReply, type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import {
|
||||
type PermissionReply,
|
||||
type OpencodeRuntimeAdapter,
|
||||
} from "../runtime/opencode.js";
|
||||
import {
|
||||
buildPermissionDetail,
|
||||
buildPermissionV2Detail,
|
||||
buildReasoningProgressDetail,
|
||||
buildSessionStatusDetail,
|
||||
buildToolProgressDetail,
|
||||
collectTextContent,
|
||||
extractRequestReason,
|
||||
extractSkillAuditInfo,
|
||||
getErrorMessage,
|
||||
getToolProgressTitle,
|
||||
getUnknownErrorMessage,
|
||||
hasToolParams,
|
||||
isPermissionAskedEvent,
|
||||
isPermissionRepliedEvent,
|
||||
isPermissionV2AskedEvent,
|
||||
isPermissionV2RepliedEvent,
|
||||
isQuestionAskedEvent,
|
||||
isQuestionRejectedEvent,
|
||||
isQuestionRepliedEvent,
|
||||
isQuestionV2AskedEvent,
|
||||
isQuestionV2RejectedEvent,
|
||||
isQuestionV2RepliedEvent,
|
||||
isSessionEvent,
|
||||
isSkillEvent,
|
||||
logDevelopmentDebug,
|
||||
normalizeQuestionAnswers,
|
||||
normalizeQuestionPayload,
|
||||
normalizeQuestionToolPayload,
|
||||
normalizeTodoPriority,
|
||||
normalizeTodoStatus,
|
||||
normalizeToolParams,
|
||||
normalizeToolStatus,
|
||||
type PermissionRequestPayload,
|
||||
type QuestionRequestPayload,
|
||||
type TodoItemPayload,
|
||||
type TodoUpdatePayload,
|
||||
} from "./chatStreamEvents.js";
|
||||
|
||||
export {
|
||||
collectTextContent,
|
||||
type PermissionRequestPayload,
|
||||
type QuestionRequestPayload,
|
||||
type TodoItemPayload,
|
||||
type TodoUpdatePayload,
|
||||
} from "./chatStreamEvents.js";
|
||||
|
||||
export const supportedModels = [
|
||||
"deepseek/deepseek-v4-flash",
|
||||
@@ -35,124 +84,6 @@ type ProgressPayload = {
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
export type PermissionRequestPayload = {
|
||||
session_id: string;
|
||||
request_id: string;
|
||||
permission: string;
|
||||
patterns: string[];
|
||||
metadata: Record<string, unknown>;
|
||||
always: string[];
|
||||
tool?: {
|
||||
messageID: string;
|
||||
callID: string;
|
||||
};
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
|
||||
|
||||
const toolLabels: Record<string, string> = {
|
||||
memory_manager: "记忆写入",
|
||||
session_search: "历史会话检索",
|
||||
skill_manager: "流程沉淀",
|
||||
locate_features: "地图定位",
|
||||
view_history: "历史数据面板",
|
||||
view_scada: "SCADA 面板",
|
||||
show_chart: "图表渲染",
|
||||
render_junctions: "节点渲染",
|
||||
};
|
||||
|
||||
const logDevelopmentDebug = (
|
||||
message: string,
|
||||
metadata: Record<string, unknown>,
|
||||
) => {
|
||||
if (!isDevelopmentDebugLoggingEnabled) {
|
||||
return;
|
||||
}
|
||||
logger.info(metadata, message);
|
||||
};
|
||||
|
||||
const getErrorMessage = (error: {
|
||||
name: string;
|
||||
data?: { message?: string };
|
||||
}) => error.data?.message ?? error.name;
|
||||
|
||||
const getUnknownErrorMessage = (error: unknown) => {
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"name" in error &&
|
||||
typeof error.name === "string"
|
||||
) {
|
||||
const maybeData = "data" in error ? error.data : undefined;
|
||||
return getErrorMessage({
|
||||
name: error.name,
|
||||
data:
|
||||
typeof maybeData === "object" && maybeData !== null && "message" in maybeData
|
||||
? { message: typeof maybeData.message === "string" ? maybeData.message : undefined }
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
};
|
||||
|
||||
const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const normalizeToolParams = (value: unknown): Record<string, unknown> => {
|
||||
if (isObjectRecord(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown;
|
||||
return isObjectRecord(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
const extractRequestReason = (params: Record<string, unknown>) => {
|
||||
const candidates = ["reason", "request_reason", "why", "purpose", "rationale"];
|
||||
for (const key of candidates) {
|
||||
const value = params[key];
|
||||
if (typeof value === "string") {
|
||||
const normalized = value.trim();
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
const isSkillEvent = (event: OpencodeEvent) => event.type.toLowerCase().includes("skill");
|
||||
|
||||
const extractSkillAuditInfo = (event: OpencodeEvent) => {
|
||||
const payload = isObjectRecord(event.properties)
|
||||
? (event.properties as Record<string, unknown>)
|
||||
: {};
|
||||
const candidateName =
|
||||
typeof payload.skill === "string"
|
||||
? payload.skill
|
||||
: typeof payload.skillName === "string"
|
||||
? payload.skillName
|
||||
: typeof payload.name === "string"
|
||||
? payload.name
|
||||
: event.type;
|
||||
const reason = extractRequestReason(payload);
|
||||
return {
|
||||
name: candidateName,
|
||||
reason,
|
||||
payload,
|
||||
};
|
||||
};
|
||||
|
||||
const hasToolParams = (params: Record<string, unknown>) =>
|
||||
Object.keys(params).length > 0;
|
||||
|
||||
const toRuntimeModel = (model?: SupportedModel) => {
|
||||
if (!model) {
|
||||
return undefined;
|
||||
@@ -167,55 +98,6 @@ const toRuntimeModel = (model?: SupportedModel) => {
|
||||
};
|
||||
};
|
||||
|
||||
const isSessionEvent = (event: OpencodeEvent, sessionId: string) =>
|
||||
"properties" in event &&
|
||||
typeof event.properties === "object" &&
|
||||
event.properties !== null &&
|
||||
"sessionID" in event.properties &&
|
||||
event.properties.sessionID === sessionId;
|
||||
|
||||
const isPermissionAskedEvent = (
|
||||
event: OpencodeEvent,
|
||||
): event is Extract<OpencodeEvent, { type: "permission.asked" }> =>
|
||||
event.type === "permission.asked";
|
||||
|
||||
const isPermissionV2AskedEvent = (
|
||||
event: OpencodeEvent,
|
||||
): event is Extract<OpencodeEvent, { type: "permission.v2.asked" }> =>
|
||||
event.type === "permission.v2.asked";
|
||||
|
||||
const isPermissionRepliedEvent = (
|
||||
event: OpencodeEvent,
|
||||
): event is Extract<OpencodeEvent, { type: "permission.replied" }> =>
|
||||
event.type === "permission.replied";
|
||||
|
||||
const isPermissionV2RepliedEvent = (
|
||||
event: OpencodeEvent,
|
||||
): event is Extract<OpencodeEvent, { type: "permission.v2.replied" }> =>
|
||||
event.type === "permission.v2.replied";
|
||||
|
||||
const buildPermissionDetail = (event: Extract<OpencodeEvent, { type: "permission.asked" }>) => {
|
||||
const patterns = event.properties.patterns.length
|
||||
? event.properties.patterns.join(", ")
|
||||
: event.properties.permission;
|
||||
return `需要用户确认权限:${event.properties.permission};匹配规则:${patterns}`;
|
||||
};
|
||||
|
||||
const buildPermissionV2Detail = (
|
||||
event: Extract<OpencodeEvent, { type: "permission.v2.asked" }>,
|
||||
) => {
|
||||
const resources = event.properties.resources.length
|
||||
? event.properties.resources.join(", ")
|
||||
: event.properties.action;
|
||||
return `需要用户确认权限:${event.properties.action};资源:${resources}`;
|
||||
};
|
||||
|
||||
export const collectTextContent = (parts: Part[]) =>
|
||||
parts
|
||||
.filter((part): part is Extract<Part, { type: "text" }> => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("");
|
||||
|
||||
const emitFallbackMessage = async (
|
||||
runtime: OpencodeRuntimeAdapter,
|
||||
sessionId: string,
|
||||
@@ -236,111 +118,6 @@ const emitFallbackMessage = async (
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeToolStatus = (status: string) => {
|
||||
if (status === "completed") return "completed";
|
||||
if (status === "error") return "error";
|
||||
return "running";
|
||||
};
|
||||
|
||||
const formatProgressValue = (value: unknown): string => {
|
||||
if (typeof value === "string") {
|
||||
return value.length > 120 ? `${value.slice(0, 117)}...` : value;
|
||||
}
|
||||
if (
|
||||
typeof value === "number" ||
|
||||
typeof value === "boolean" ||
|
||||
value === null ||
|
||||
value === undefined
|
||||
) {
|
||||
return String(value);
|
||||
}
|
||||
try {
|
||||
const serialized = JSON.stringify(value);
|
||||
return serialized.length > 120 ? `${serialized.slice(0, 117)}...` : serialized;
|
||||
} catch {
|
||||
return "[unserializable]";
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeProgressText = (chunks: string[]) => chunks.join("").replace(/\s+/g, " ").trim();
|
||||
|
||||
const truncateProgressText = (text: string, maxLength: number) =>
|
||||
text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
|
||||
|
||||
const summarizeToolParams = (params: Record<string, unknown>) => {
|
||||
const ignoredKeys = new Set(["reason", "request_reason", "why", "purpose", "rationale"]);
|
||||
const summary = Object.entries(params)
|
||||
.filter(([key]) => !ignoredKeys.has(key))
|
||||
.slice(0, 4)
|
||||
.map(([key, value]) => `${key}=${formatProgressValue(value)}`)
|
||||
.join(", ");
|
||||
|
||||
return summary || "无附加参数";
|
||||
};
|
||||
|
||||
const buildSessionStatusDetail = (status: { type: string; message?: string }) => {
|
||||
if (status.type === "retry") {
|
||||
return status.message
|
||||
? `模型请求需要重试,原因:${status.message}`
|
||||
: "模型请求正在重试,等待下一次响应。";
|
||||
}
|
||||
if (status.type === "busy") {
|
||||
return status.message
|
||||
? `Agent 正在处理中:${status.message}`
|
||||
: "Agent 正在执行推理、工具调用或结果整理。";
|
||||
}
|
||||
if (status.type === "idle") {
|
||||
return status.message
|
||||
? `Agent 已空闲:${status.message}`
|
||||
: "当前会话暂时没有待处理任务。";
|
||||
}
|
||||
return status.message ? `会话状态更新:${status.message}` : `会话状态更新:${status.type}`;
|
||||
};
|
||||
|
||||
const buildReasoningProgressDetail = (chunks: string[], ended?: string | number | Date | null) => {
|
||||
const reasoningText = truncateProgressText(normalizeProgressText(chunks), 800);
|
||||
if (ended) {
|
||||
return reasoningText
|
||||
? `推理过程:${reasoningText}`
|
||||
: "当前推理阶段已完成,Agent 将继续输出答案或进入工具执行。";
|
||||
}
|
||||
return reasoningText
|
||||
? `正在推理:${reasoningText}`
|
||||
: "Agent 正在拆解问题、梳理执行步骤并判断是否需要调用工具。";
|
||||
};
|
||||
|
||||
const buildToolProgressDetail = (
|
||||
tool: string,
|
||||
status: string,
|
||||
params: Record<string, unknown>,
|
||||
reason: string,
|
||||
error?: string,
|
||||
) => {
|
||||
const toolName = toolLabels[tool] ?? tool;
|
||||
const reasonText = reason ? `;调用原因:${reason}` : "";
|
||||
const paramsText = `;关键参数:${summarizeToolParams(params)}`;
|
||||
|
||||
if (status === "error") {
|
||||
const errorText = error ? `;错误:${error}` : "";
|
||||
return `${toolName} 调用失败${reasonText}${paramsText}${errorText}`;
|
||||
}
|
||||
if (status === "completed") {
|
||||
return `${toolName} 已执行完成${reasonText}${paramsText}`;
|
||||
}
|
||||
if (status === "pending") {
|
||||
return `${toolName} 已进入待执行状态${reasonText}${paramsText}`;
|
||||
}
|
||||
return `${toolName} 正在执行${reasonText}${paramsText}`;
|
||||
};
|
||||
|
||||
const getToolProgressTitle = (tool: string, status: string) => {
|
||||
const toolName = toolLabels[tool] ?? tool;
|
||||
if (status === "completed") return `${toolName} 已完成`;
|
||||
if (status === "error") return `${toolName} 执行失败`;
|
||||
if (status === "pending") return `准备调用 ${toolName}`;
|
||||
return `正在调用 ${toolName}`;
|
||||
};
|
||||
|
||||
export const streamPromptResponse = async ({
|
||||
runtime,
|
||||
sessionId,
|
||||
@@ -364,6 +141,8 @@ export const streamPromptResponse = async ({
|
||||
const progressStartedAtMap = new Map<string, number>();
|
||||
const finalizedProgressIds = new Set<string>();
|
||||
const emittedToolParts = new Set<string>();
|
||||
const emittedQuestionToolParts = new Set<string>();
|
||||
const emittedQuestionRequestIds = new Set<string>();
|
||||
const partTypes = new Map<string, Part["type"]>();
|
||||
const pendingPartTextDeltas = new Map<string, string[]>();
|
||||
const reasoningDeltas = new Map<string, string[]>();
|
||||
@@ -734,6 +513,76 @@ export const streamPromptResponse = async ({
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isQuestionAskedEvent(event) || isQuestionV2AskedEvent(event)) {
|
||||
sawResponseActivity = true;
|
||||
logDevelopmentDebug("question request received", {
|
||||
...debugContext,
|
||||
requestId: event.properties.id,
|
||||
questionCount: event.properties.questions.length,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
emitProgress({
|
||||
id: `question-${event.properties.id}`,
|
||||
phase: "question",
|
||||
status: "running",
|
||||
title: "等待用户补充信息",
|
||||
detail: event.properties.questions
|
||||
.map((question) => question.question)
|
||||
.join("\n"),
|
||||
});
|
||||
const payload = normalizeQuestionPayload(event, clientSessionId);
|
||||
emittedQuestionRequestIds.add(payload.request_id);
|
||||
write("question_request", payload);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isQuestionRepliedEvent(event) || isQuestionV2RepliedEvent(event)) {
|
||||
sawResponseActivity = true;
|
||||
logDevelopmentDebug("question request replied", {
|
||||
...debugContext,
|
||||
requestId: event.properties.requestID,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
emitProgress({
|
||||
id: `question-${event.properties.requestID}`,
|
||||
phase: "question",
|
||||
status: "completed",
|
||||
title: "已收到补充信息",
|
||||
detail: normalizeQuestionAnswers(event.properties.answers)
|
||||
.map((answer) => answer.join("、"))
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
});
|
||||
write("question_response", {
|
||||
session_id: clientSessionId,
|
||||
request_id: event.properties.requestID,
|
||||
answers: normalizeQuestionAnswers(event.properties.answers),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isQuestionRejectedEvent(event) || isQuestionV2RejectedEvent(event)) {
|
||||
sawResponseActivity = true;
|
||||
logDevelopmentDebug("question request rejected", {
|
||||
...debugContext,
|
||||
requestId: event.properties.requestID,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
emitProgress({
|
||||
id: `question-${event.properties.requestID}`,
|
||||
phase: "question",
|
||||
status: "completed",
|
||||
title: "已跳过补充信息",
|
||||
detail: "用户选择跳过本次补充信息。",
|
||||
});
|
||||
write("question_response", {
|
||||
session_id: clientSessionId,
|
||||
request_id: event.properties.requestID,
|
||||
rejected: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isSkillEvent(event)) {
|
||||
sawResponseActivity = true;
|
||||
const { name, reason, payload } = extractSkillAuditInfo(event);
|
||||
@@ -882,6 +731,36 @@ export const streamPromptResponse = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const questionToolPayload = normalizeQuestionToolPayload(
|
||||
part,
|
||||
toolParams,
|
||||
clientSessionId,
|
||||
);
|
||||
if (questionToolPayload) {
|
||||
if (!emittedQuestionToolParts.has(part.id)) {
|
||||
emittedQuestionToolParts.add(part.id);
|
||||
emittedQuestionRequestIds.add(questionToolPayload.request_id);
|
||||
logDevelopmentDebug("question tool request received", {
|
||||
...debugContext,
|
||||
requestId: questionToolPayload.request_id,
|
||||
tool: part.tool,
|
||||
questionCount: questionToolPayload.questions.length,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
});
|
||||
emitProgress({
|
||||
id: `question-${questionToolPayload.request_id}`,
|
||||
phase: "question",
|
||||
status: "running",
|
||||
title: "等待用户补充信息",
|
||||
detail: questionToolPayload.questions
|
||||
.map((question) => question.question)
|
||||
.join("\n"),
|
||||
});
|
||||
write("question_request", questionToolPayload);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
emitProgress({
|
||||
id: part.id,
|
||||
phase: "tool",
|
||||
@@ -937,18 +816,35 @@ export const streamPromptResponse = async ({
|
||||
|
||||
if (event.type === "todo.updated") {
|
||||
sawResponseActivity = true;
|
||||
const completed = event.properties.todos.filter(
|
||||
const todos = event.properties.todos as Array<{
|
||||
content: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
}>;
|
||||
const normalizedTodos = todos.map((todo, index) => ({
|
||||
id: `todo-${index}-${todo.content.slice(0, 24)}`,
|
||||
content: todo.content,
|
||||
status: normalizeTodoStatus(todo.status),
|
||||
priority: normalizeTodoPriority(todo.priority),
|
||||
updated_at: Date.now(),
|
||||
}));
|
||||
const completed = todos.filter(
|
||||
(todo) => todo.status === "completed",
|
||||
).length;
|
||||
emitProgress({
|
||||
id: "todo-progress",
|
||||
phase: "planning",
|
||||
status: completed === event.properties.todos.length ? "completed" : "running",
|
||||
title: `计划进度 ${completed}/${event.properties.todos.length}`,
|
||||
detail: event.properties.todos
|
||||
status: completed === todos.length ? "completed" : "running",
|
||||
title: `计划进度 ${completed}/${todos.length}`,
|
||||
detail: todos
|
||||
.map((todo) => `${todo.status}: ${todo.content}`)
|
||||
.join("\n"),
|
||||
});
|
||||
write("todo_update", {
|
||||
session_id: clientSessionId,
|
||||
todos: normalizedTodos,
|
||||
created_at: Date.now(),
|
||||
} satisfies TodoUpdatePayload);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user