import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2"; import { containsForbiddenPersistentContent } from "../utils/persistencePolicy.js"; import { logger } from "../logger.js"; import { type QuestionAnswers } from "../runtime/opencode.js"; export type PermissionRequestPayload = { session_id: string; request_id: string; permission: string; patterns: string[]; target?: string; always: string[]; tool?: { messageID: string; callID: string; }; created_at: number; }; type QuestionOptionPayload = { label: string; description: string; }; type QuestionInfoPayload = { header: string; question: string; options: QuestionOptionPayload[]; multiple?: boolean; custom?: boolean; }; export type QuestionRequestPayload = { session_id: string; request_id: string; questions: QuestionInfoPayload[]; tool?: { messageID: string; callID: string; }; created_at: number; }; export type TodoItemPayload = { id: string; content: string; status: "pending" | "in_progress" | "completed" | "cancelled"; priority?: "low" | "medium" | "high"; created_at?: number; updated_at?: number; }; export type TodoUpdatePayload = { session_id: string; message_id?: string; todos: TodoItemPayload[]; created_at: number; }; const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development"; const toolLabels: Record = { present_view: "展示地图与图表", memory_manager: "记忆写入", session_search: "历史会话检索", skill_manager: "流程沉淀", }; export const logDevelopmentDebug = ( message: string, metadata: Record, ) => { if (!isDevelopmentDebugLoggingEnabled) { return; } logger.info(metadata, message); }; export const getErrorMessage = (error: { name: string; data?: { message?: string }; }) => error.data?.message ?? error.name; export 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); }; export const isObjectRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); export const normalizeToolParams = (value: unknown): Record => { if (isObjectRecord(value)) { return value; } if (typeof value === "string") { try { const parsed = JSON.parse(value) as unknown; return isObjectRecord(parsed) ? parsed : {}; } catch { return {}; } } return {}; }; // New tools use reason; aliases are read-only compatibility for existing events. const reasonKeys = ["reason", "request_reason", "why", "purpose", "rationale"]; export const extractRequestReason = (params: Record) => { for (const key of reasonKeys) { const value = params[key]; if (typeof value === "string") { const normalized = value.trim(); if (normalized) { return normalized; } } } return ""; }; export const isSkillEvent = (event: OpencodeEvent) => event.type.toLowerCase().includes("skill"); export const extractSkillAuditInfo = (event: OpencodeEvent) => { const payload = isObjectRecord(event.properties) ? (event.properties as Record) : {}; 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, }; }; export const hasToolParams = (params: Record) => Object.keys(params).length > 0; export const isSessionEvent = (event: OpencodeEvent, sessionId: string) => "properties" in event && typeof event.properties === "object" && event.properties !== null && "sessionID" in event.properties && event.properties.sessionID === sessionId; export const isPermissionAskedEvent = ( event: OpencodeEvent, ): event is Extract => event.type === "permission.asked"; export const isPermissionV2AskedEvent = ( event: OpencodeEvent, ): event is Extract => event.type === "permission.v2.asked"; export const isPermissionRepliedEvent = ( event: OpencodeEvent, ): event is Extract => event.type === "permission.replied"; export const isPermissionV2RepliedEvent = ( event: OpencodeEvent, ): event is Extract => event.type === "permission.v2.replied"; export const isQuestionAskedEvent = ( event: OpencodeEvent, ): event is Extract => event.type === "question.asked"; export const isQuestionV2AskedEvent = ( event: OpencodeEvent, ): event is Extract => event.type === "question.v2.asked"; export const isQuestionRepliedEvent = ( event: OpencodeEvent, ): event is Extract => event.type === "question.replied"; export const isQuestionV2RepliedEvent = ( event: OpencodeEvent, ): event is Extract => event.type === "question.v2.replied"; export const isQuestionRejectedEvent = ( event: OpencodeEvent, ): event is Extract => event.type === "question.rejected"; export const isQuestionV2RejectedEvent = ( event: OpencodeEvent, ): event is Extract => event.type === "question.v2.rejected"; export const buildPermissionDetail = ( event: Extract, ) => { const patterns = event.properties.patterns.length ? event.properties.patterns.join(", ") : event.properties.permission; return `需要用户确认权限:${event.properties.permission};匹配规则:${patterns}`; }; export const buildPermissionV2Detail = ( event: Extract, ) => { const resources = event.properties.resources.length ? event.properties.resources.join(", ") : event.properties.action; return `需要用户确认权限:${event.properties.action};资源:${resources}`; }; export const normalizeQuestionPayload = ( event: Extract, clientSessionId: string, ): QuestionRequestPayload => ({ session_id: clientSessionId, request_id: event.properties.id, questions: event.properties.questions.map((question) => ({ header: question.header, question: question.question, options: question.options.map((option) => ({ label: option.label, description: option.description, })), multiple: question.multiple, custom: question.custom, })), tool: event.properties.tool, created_at: Date.now(), }); export const normalizeQuestionAnswers = (answers: QuestionAnswers | undefined) => Array.isArray(answers) ? answers.map((answer) => Array.isArray(answer) ? answer.filter((item): item is string => typeof item === "string") : [], ) : []; const questionToolNames = new Set(["question", "request_user_input"]); const normalizeQuestionOptions = (value: unknown): QuestionOptionPayload[] => Array.isArray(value) ? value.filter(isObjectRecord).map((option) => ({ label: typeof option.label === "string" ? option.label : "", description: typeof option.description === "string" ? option.description : "", })).filter((option) => option.label.trim().length > 0) : []; const normalizeToolQuestionInfo = (value: unknown): QuestionInfoPayload | undefined => { if (!isObjectRecord(value) || typeof value.question !== "string") { return undefined; } const question = value.question.trim(); if (!question) { return undefined; } return { header: typeof value.header === "string" && value.header.trim() ? value.header : "补充信息", question, options: normalizeQuestionOptions(value.options), multiple: typeof value.multiple === "boolean" ? value.multiple : undefined, custom: typeof value.custom === "boolean" ? value.custom : undefined, }; }; export const normalizeQuestionToolPayload = ( part: Extract, params: Record, clientSessionId: string, ): QuestionRequestPayload | undefined => { // Native V2 question tools create a separate frm_ request. A call ID is not a form ID. if ("metadata" in part.state && part.state.metadata?.native_form === true) { return undefined; } if (!questionToolNames.has(part.tool)) { return undefined; } const questions = Array.isArray(params.questions) ? params.questions .map(normalizeToolQuestionInfo) .filter((question): question is QuestionInfoPayload => Boolean(question)) : []; if (questions.length === 0) { return undefined; } return { session_id: clientSessionId, request_id: part.callID || part.id, questions, tool: { messageID: part.messageID, callID: part.callID, }, created_at: Date.now(), }; }; export const normalizeTodoStatus = (status: string): TodoItemPayload["status"] => { if (status === "in_progress" || status === "completed" || status === "cancelled") { return status; } return "pending"; }; export const normalizeTodoPriority = ( priority: string, ): TodoItemPayload["priority"] | undefined => { if (priority === "low" || priority === "medium" || priority === "high") { return priority; } return undefined; }; export const collectTextContent = (parts: Part[]) => parts .filter((part): part is Extract => part.type === "text") .map((part) => part.text) .join(""); export const normalizeToolStatus = (status: string) => { if (status === "completed") return "completed"; if (status === "error") return "error"; return "running"; }; /** Browser-visible tool inputs: retain useful values without credentials or large payloads. */ export const sanitizeToolProgressInput = (params: Record): Record => { let remaining = 16_000; let nodes = 512; const visit = (value: unknown, depth: number): unknown => { if (remaining <= 0 || nodes-- <= 0) return "[truncated]"; if (typeof value === "string") { if (containsForbiddenPersistentContent(value)) return "[redacted]"; const limit = Math.min(remaining, 4_000); remaining -= Math.min(value.length, limit); return value.length > limit ? `${value.slice(0, limit)}… [truncated]` : value; } if (value === null || typeof value === "boolean" || typeof value === "number") return value; if (depth >= 5) return "[truncated]"; if (Array.isArray(value)) { const items = value.slice(0, 64).map((item) => visit(item, depth + 1)); if (value.length > 64) items.push("[truncated]"); return items; } if (value && typeof value === "object") { const entries = Object.entries(value).slice(0, 64).map(([rawKey, item]) => { const key = rawKey.slice(0, 100); remaining -= key.length; return [key, /authorization|cookie|password|secret|token|api[_-]?key|credential|headers?/i.test(key) ? "[redacted]" : visit(item, depth + 1)]; }); if (Object.keys(value).length > 64) entries.push(["…", "[truncated]"]); return Object.fromEntries(entries); } return "[unsupported]"; }; return visit(params, 0) as Record; }; 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) => { const ignoredKeys = new Set(reasonKeys); const summary = Object.entries(params) .filter(([key]) => !ignoredKeys.has(key)) .slice(0, 4) .map(([key, value]) => `${key}=${formatProgressValue(value)}`) .join(", "); return summary || "无附加参数"; }; export 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}`; }; export 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 正在拆解问题、梳理执行步骤并判断是否需要调用工具。"; }; export const buildToolProgressDetail = ( tool: string, status: string, params: Record, reason: string, error?: string, output?: string, ) => { const toolName = toolLabels[tool] ?? tool; const reasonText = reason ? `;说明:${containsForbiddenPersistentContent(reason) ? "[redacted]" : reason}` : ""; const paramsText = `;关键参数:${summarizeToolParams(sanitizeToolProgressInput(params))}`; if (status === "error") { const errorText = error ? `;错误:${containsForbiddenPersistentContent(error) ? "[redacted]" : error}` : ""; return `${toolName} 调用失败${reasonText}${paramsText}${errorText}`; } if (status === "completed") { const calculation = calculationProgress(output); if (calculation) return `${toolName}:${calculation.label};计算任务:${calculation.jobId}${reasonText}${paramsText}`; return `${toolName} 已执行完成${reasonText}${paramsText}`; } if (status === "pending") { return `${toolName} 已进入待执行状态${reasonText}${paramsText}`; } return `${toolName} 正在执行${reasonText}${paramsText}`; }; const calculationProgress = (output?: string) => { if (!output || output.length > 100_000) return null; try { const value = JSON.parse(output); if (value?.semantics !== "task" || typeof value.data?.job_id !== "string" || !/^[0-9a-f-]{36}$/i.test(value.data.job_id)) return null; const labels: Record = { queued: "已提交,等待计算", running: "后台计算中", cancel_requested: "已请求取消,等待计算停止", succeeded: "计算已完成", failed: "计算失败", cancelled: "计算已取消", interrupted: "计算已中断", timed_out: "计算执行超时", }; const label = labels[value.data.status]; return label ? { label, jobId: value.data.job_id } : null; } catch { return null; } }; export const getToolProgressTitle = (tool: string, status: string, output?: string) => { const toolName = toolLabels[tool] ?? tool; if (status === "completed") { const calculation = calculationProgress(output); if (calculation) return `${toolName}:${calculation.label}`; } if (status === "completed") return `${toolName} 已完成`; if (status === "error") return `${toolName} 执行失败`; if (status === "pending") return `准备调用 ${toolName}`; return `正在调用 ${toolName}`; };