977 lines
32 KiB
TypeScript
977 lines
32 KiB
TypeScript
import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
|
|
|
|
import { writeLlmRequestAuditLog } from "../audit/llmRequestAudit.js";
|
|
import { type SupportedModel } from "../chat/models.js";
|
|
import { logger } from "../logger.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,
|
|
isObjectRecord,
|
|
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 type ApprovalMode = "request" | "always";
|
|
|
|
type StreamPromptOptions = {
|
|
runtime: OpencodeRuntimeAdapter;
|
|
sessionId: string;
|
|
clientSessionId: string;
|
|
message: string;
|
|
model?: SupportedModel;
|
|
approvalMode?: ApprovalMode;
|
|
traceId?: string;
|
|
projectId?: string;
|
|
signal?: AbortSignal;
|
|
write: (event: string, data: Record<string, unknown>) => void;
|
|
};
|
|
|
|
type ProgressStatus = "running" | "completed" | "error";
|
|
|
|
type ProgressPayload = {
|
|
id: string;
|
|
phase: string;
|
|
status: ProgressStatus;
|
|
title: string;
|
|
detail?: string;
|
|
};
|
|
|
|
const getPermissionTarget = (metadata: unknown) => {
|
|
if (!isObjectRecord(metadata)) {
|
|
return undefined;
|
|
}
|
|
for (const key of ["command", "path", "file", "filepath", "directory"]) {
|
|
const value = metadata[key];
|
|
if (typeof value === "string" && value.trim()) {
|
|
return value.trim();
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const toRuntimeModel = (model?: SupportedModel) => {
|
|
if (!model) {
|
|
return undefined;
|
|
}
|
|
const [providerID, modelID] = model.split("/");
|
|
if (!providerID || !modelID) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
providerID,
|
|
modelID,
|
|
};
|
|
};
|
|
|
|
const emitFallbackMessage = async (
|
|
runtime: OpencodeRuntimeAdapter,
|
|
sessionId: string,
|
|
clientSessionId: string,
|
|
write: (event: string, data: Record<string, unknown>) => void,
|
|
) => {
|
|
const messages = await runtime.messages(sessionId);
|
|
const assistantMessage = [...messages]
|
|
.reverse()
|
|
.find((message) => message.info.role === "assistant");
|
|
const parts = assistantMessage?.parts ?? [];
|
|
const text = collectTextContent(parts);
|
|
if (text) {
|
|
write("token", {
|
|
session_id: clientSessionId,
|
|
content: text,
|
|
});
|
|
}
|
|
};
|
|
|
|
export const streamPromptResponse = async ({
|
|
runtime,
|
|
sessionId,
|
|
clientSessionId,
|
|
message,
|
|
model,
|
|
approvalMode = "request",
|
|
traceId,
|
|
projectId,
|
|
signal,
|
|
write,
|
|
}: StreamPromptOptions): Promise<{
|
|
aborted: boolean;
|
|
failed: boolean;
|
|
toolCallCount: number;
|
|
}> => {
|
|
const eventStream = await runtime.subscribeEvents();
|
|
const iterator = eventStream[Symbol.asyncIterator]();
|
|
const requestStartedAt = Date.now();
|
|
const promptStartedAt = Date.now();
|
|
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[]>();
|
|
const reasoningStatuses = new Map<string, "running" | "completed">();
|
|
const toolStatuses = new Map<string, string>();
|
|
let firstSessionEventLogged = false;
|
|
let firstNonStatusEventLogged = false;
|
|
let firstTokenLogged = false;
|
|
let firstReasoningLogged = false;
|
|
let firstToolEventLogged = false;
|
|
let lastSessionStatus: string | null = null;
|
|
let lastSessionStatusMessage: string | null = null;
|
|
let sawResponseActivity = false;
|
|
let emittedText = false;
|
|
let toolCallCount = 0;
|
|
let done = false;
|
|
let promptSettled = false;
|
|
let aborted = signal?.aborted ?? false;
|
|
let failed = false;
|
|
const debugContext = {
|
|
sessionId,
|
|
clientSessionId,
|
|
traceId,
|
|
projectId,
|
|
model: model ?? null,
|
|
};
|
|
|
|
logDevelopmentDebug("chat stream started", {
|
|
...debugContext,
|
|
messageChars: message.length,
|
|
});
|
|
|
|
const abortPromise = signal
|
|
? new Promise<{ type: "abort" }>((resolve) => {
|
|
if (signal.aborted) {
|
|
resolve({ type: "abort" });
|
|
return;
|
|
}
|
|
signal.addEventListener("abort", () => resolve({ type: "abort" }), {
|
|
once: true,
|
|
});
|
|
})
|
|
: null;
|
|
|
|
const emitProgress = ({ id, phase, status, title, detail }: ProgressPayload) => {
|
|
if (status === "running" && finalizedProgressIds.has(id)) {
|
|
return;
|
|
}
|
|
|
|
const now = Date.now();
|
|
const startedAt = progressStartedAtMap.get(id) ?? now;
|
|
if (!progressStartedAtMap.has(id)) {
|
|
progressStartedAtMap.set(id, startedAt);
|
|
}
|
|
|
|
if (status === "running") {
|
|
write("progress", {
|
|
session_id: clientSessionId,
|
|
id,
|
|
phase,
|
|
status,
|
|
title,
|
|
detail,
|
|
started_at: startedAt,
|
|
elapsed_ms: Math.max(0, now - startedAt),
|
|
});
|
|
return;
|
|
}
|
|
|
|
const durationMs = Math.max(0, now - startedAt);
|
|
finalizedProgressIds.add(id);
|
|
progressStartedAtMap.delete(id);
|
|
write("progress", {
|
|
session_id: clientSessionId,
|
|
id,
|
|
phase,
|
|
status,
|
|
title,
|
|
detail,
|
|
started_at: startedAt,
|
|
ended_at: now,
|
|
duration_ms: durationMs,
|
|
});
|
|
};
|
|
|
|
emitProgress({
|
|
id: "request-received",
|
|
phase: "start",
|
|
status: "running",
|
|
title: "已收到请求,正在启动 Agent 分析",
|
|
detail: "已接收用户消息,正在建立会话并准备进入分析、规划和工具调用阶段。",
|
|
});
|
|
|
|
const promptPromise = runtime
|
|
.prompt(sessionId, message, toRuntimeModel(model))
|
|
.then(() => {
|
|
promptSettled = true;
|
|
logDevelopmentDebug("runtime.prompt resolved", {
|
|
...debugContext,
|
|
elapsedMs: Math.max(0, Date.now() - promptStartedAt),
|
|
});
|
|
})
|
|
.catch((error: unknown) => {
|
|
promptSettled = true;
|
|
logDevelopmentDebug("runtime.prompt failed", {
|
|
...debugContext,
|
|
elapsedMs: Math.max(0, Date.now() - promptStartedAt),
|
|
error: getUnknownErrorMessage(error),
|
|
});
|
|
throw error;
|
|
});
|
|
|
|
logDevelopmentDebug("runtime.prompt dispatched", {
|
|
...debugContext,
|
|
});
|
|
|
|
try {
|
|
while (!done) {
|
|
if (signal?.aborted) {
|
|
aborted = true;
|
|
logDevelopmentDebug("chat stream noticed abort signal", {
|
|
...debugContext,
|
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
});
|
|
break;
|
|
}
|
|
|
|
const nextEvent = iterator
|
|
.next()
|
|
.then((result) => ({ type: "event" as const, result }));
|
|
const nextPrompt = promptSettled
|
|
? null
|
|
: promptPromise.then(
|
|
() => ({ type: "prompt" as const }),
|
|
(error: unknown) => ({ type: "prompt-error" as const, error }),
|
|
);
|
|
const next = await Promise.race(
|
|
[
|
|
...(nextPrompt ? [nextEvent, nextPrompt] : [nextEvent]),
|
|
...(abortPromise ? [abortPromise] : []),
|
|
],
|
|
);
|
|
|
|
if (next.type === "abort") {
|
|
aborted = true;
|
|
break;
|
|
}
|
|
|
|
if (next.type === "prompt-error") {
|
|
throw next.error;
|
|
}
|
|
if (next.type === "prompt") {
|
|
continue;
|
|
}
|
|
if (next.result.done) {
|
|
break;
|
|
}
|
|
|
|
const event = next.result.value as OpencodeEvent;
|
|
if (!isSessionEvent(event, sessionId)) {
|
|
continue;
|
|
}
|
|
|
|
if (!firstSessionEventLogged) {
|
|
firstSessionEventLogged = true;
|
|
logDevelopmentDebug("first session event received", {
|
|
...debugContext,
|
|
eventType: event.type,
|
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
|
|
});
|
|
}
|
|
|
|
if (event.type === "session.status") {
|
|
const nextStatus = event.properties.status.type;
|
|
const nextStatusMessage =
|
|
"message" in event.properties.status &&
|
|
typeof event.properties.status.message === "string"
|
|
? event.properties.status.message
|
|
: null;
|
|
if (
|
|
nextStatus !== lastSessionStatus ||
|
|
nextStatusMessage !== lastSessionStatusMessage
|
|
) {
|
|
lastSessionStatus = nextStatus;
|
|
lastSessionStatusMessage = nextStatusMessage;
|
|
logDevelopmentDebug("session status updated", {
|
|
...debugContext,
|
|
status: nextStatus,
|
|
statusMessage: nextStatusMessage,
|
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
});
|
|
}
|
|
emitProgress({
|
|
id: "session-status",
|
|
phase: "session",
|
|
status: event.properties.status.type === "idle" ? "completed" : "running",
|
|
title:
|
|
event.properties.status.type === "retry"
|
|
? `模型请求重试中:${event.properties.status.message}`
|
|
: event.properties.status.type === "busy"
|
|
? "Agent 正在处理请求"
|
|
: "Agent 已空闲",
|
|
detail: buildSessionStatusDetail(event.properties.status),
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (!firstNonStatusEventLogged) {
|
|
firstNonStatusEventLogged = true;
|
|
logDevelopmentDebug("first non-status session event received", {
|
|
...debugContext,
|
|
eventType: event.type,
|
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
|
|
});
|
|
}
|
|
|
|
if (isPermissionAskedEvent(event)) {
|
|
sawResponseActivity = true;
|
|
logDevelopmentDebug("permission request received", {
|
|
...debugContext,
|
|
requestId: event.properties.id,
|
|
permission: event.properties.permission,
|
|
patterns: event.properties.patterns,
|
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
});
|
|
emitProgress({
|
|
id: `permission-${event.properties.id}`,
|
|
phase: "permission",
|
|
status: approvalMode === "always" ? "completed" : "running",
|
|
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
|
|
detail:
|
|
approvalMode === "always"
|
|
? "当前批准模式为始终允许,已自动允许本次权限请求。"
|
|
: buildPermissionDetail(event),
|
|
});
|
|
if (approvalMode === "always") {
|
|
await runtime.replyPermission({
|
|
requestId: event.properties.id,
|
|
sessionId,
|
|
reply: "always",
|
|
});
|
|
write("permission_response", {
|
|
session_id: clientSessionId,
|
|
request_id: event.properties.id,
|
|
reply: "always" satisfies PermissionReply,
|
|
});
|
|
continue;
|
|
}
|
|
write("permission_request", {
|
|
session_id: clientSessionId,
|
|
request_id: event.properties.id,
|
|
permission: event.properties.permission,
|
|
patterns: event.properties.patterns,
|
|
target: getPermissionTarget(event.properties.metadata),
|
|
always: event.properties.always,
|
|
tool: event.properties.tool,
|
|
created_at: Date.now(),
|
|
} satisfies PermissionRequestPayload);
|
|
continue;
|
|
}
|
|
|
|
if (isPermissionV2AskedEvent(event)) {
|
|
sawResponseActivity = true;
|
|
logDevelopmentDebug("permission v2 request received", {
|
|
...debugContext,
|
|
requestId: event.properties.id,
|
|
action: event.properties.action,
|
|
resources: event.properties.resources,
|
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
});
|
|
emitProgress({
|
|
id: `permission-${event.properties.id}`,
|
|
phase: "permission",
|
|
status: approvalMode === "always" ? "completed" : "running",
|
|
title: approvalMode === "always" ? "已自动允许权限请求" : "等待权限确认",
|
|
detail:
|
|
approvalMode === "always"
|
|
? "当前批准模式为始终允许,已自动允许本次权限请求。"
|
|
: buildPermissionV2Detail(event),
|
|
});
|
|
if (approvalMode === "always") {
|
|
await runtime.replyPermission({
|
|
requestId: event.properties.id,
|
|
sessionId,
|
|
reply: "always",
|
|
});
|
|
write("permission_response", {
|
|
session_id: clientSessionId,
|
|
request_id: event.properties.id,
|
|
reply: "always" satisfies PermissionReply,
|
|
});
|
|
continue;
|
|
}
|
|
write("permission_request", {
|
|
session_id: clientSessionId,
|
|
request_id: event.properties.id,
|
|
permission: event.properties.action,
|
|
patterns: event.properties.resources,
|
|
target: getPermissionTarget(event.properties.metadata),
|
|
always: event.properties.save ?? [],
|
|
tool: undefined,
|
|
created_at: Date.now(),
|
|
} satisfies PermissionRequestPayload);
|
|
continue;
|
|
}
|
|
|
|
if (isPermissionRepliedEvent(event)) {
|
|
sawResponseActivity = true;
|
|
logDevelopmentDebug("permission request replied", {
|
|
...debugContext,
|
|
requestId: event.properties.requestID,
|
|
reply: event.properties.reply,
|
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
});
|
|
emitProgress({
|
|
id: `permission-${event.properties.requestID}`,
|
|
phase: "permission",
|
|
status: event.properties.reply === "reject" ? "error" : "completed",
|
|
title:
|
|
event.properties.reply === "reject"
|
|
? "权限请求已拒绝"
|
|
: "权限请求已允许",
|
|
detail:
|
|
event.properties.reply === "always"
|
|
? "已允许本次请求,并记住同类权限。"
|
|
: event.properties.reply === "once"
|
|
? "已允许本次请求。"
|
|
: "已拒绝本次请求。",
|
|
});
|
|
write("permission_response", {
|
|
session_id: clientSessionId,
|
|
request_id: event.properties.requestID,
|
|
reply: event.properties.reply satisfies PermissionReply,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (isPermissionV2RepliedEvent(event)) {
|
|
sawResponseActivity = true;
|
|
logDevelopmentDebug("permission v2 request replied", {
|
|
...debugContext,
|
|
requestId: event.properties.requestID,
|
|
reply: event.properties.reply,
|
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
});
|
|
emitProgress({
|
|
id: `permission-${event.properties.requestID}`,
|
|
phase: "permission",
|
|
status: event.properties.reply === "reject" ? "error" : "completed",
|
|
title:
|
|
event.properties.reply === "reject"
|
|
? "权限请求已拒绝"
|
|
: "权限请求已允许",
|
|
detail:
|
|
event.properties.reply === "always"
|
|
? "已允许本次请求,并记住同类权限。"
|
|
: event.properties.reply === "once"
|
|
? "已允许本次请求。"
|
|
: "已拒绝本次请求。",
|
|
});
|
|
write("permission_response", {
|
|
session_id: clientSessionId,
|
|
request_id: event.properties.requestID,
|
|
reply: event.properties.reply satisfies PermissionReply,
|
|
});
|
|
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);
|
|
logDevelopmentDebug("skill event received", {
|
|
...debugContext,
|
|
skill: name,
|
|
reason: reason || null,
|
|
payloadKeys: Object.keys(payload).slice(0, 8),
|
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
});
|
|
void writeLlmRequestAuditLog({
|
|
kind: "skill",
|
|
sessionId: sessionId,
|
|
clientSessionId,
|
|
traceId,
|
|
projectId,
|
|
target: name,
|
|
reason,
|
|
reasonProvided: Boolean(reason),
|
|
payload,
|
|
}).catch((error) => {
|
|
logger.warn({ err: error }, "failed to write skill audit log");
|
|
});
|
|
}
|
|
|
|
if (event.type === "message.updated") {
|
|
if (event.properties.info.role === "assistant") {
|
|
sawResponseActivity = true;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (event.type === "message.part.delta" && event.properties.field === "text") {
|
|
sawResponseActivity = true;
|
|
const partType = partTypes.get(event.properties.partID);
|
|
if (partType === "text") {
|
|
if (!firstTokenLogged) {
|
|
firstTokenLogged = true;
|
|
logDevelopmentDebug("first response token emitted", {
|
|
...debugContext,
|
|
partId: event.properties.partID,
|
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
|
|
});
|
|
}
|
|
emittedText = true;
|
|
write("token", {
|
|
session_id: clientSessionId,
|
|
content: event.properties.delta,
|
|
});
|
|
} else if (partType === "reasoning") {
|
|
if (!firstReasoningLogged) {
|
|
firstReasoningLogged = true;
|
|
logDevelopmentDebug("first reasoning delta received", {
|
|
...debugContext,
|
|
partId: event.properties.partID,
|
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
|
|
});
|
|
}
|
|
const pending = reasoningDeltas.get(event.properties.partID) ?? [];
|
|
pending.push(event.properties.delta);
|
|
reasoningDeltas.set(event.properties.partID, pending);
|
|
} else if (!partType) {
|
|
const pending = pendingPartTextDeltas.get(event.properties.partID) ?? [];
|
|
pending.push(event.properties.delta);
|
|
pendingPartTextDeltas.set(event.properties.partID, pending);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (event.type === "message.part.updated") {
|
|
sawResponseActivity = true;
|
|
const part = event.properties.part;
|
|
partTypes.set(part.id, part.type);
|
|
if (part.type === "text") {
|
|
const pending = pendingPartTextDeltas.get(part.id) ?? [];
|
|
pendingPartTextDeltas.delete(part.id);
|
|
for (const content of pending) {
|
|
emittedText = true;
|
|
write("token", {
|
|
session_id: clientSessionId,
|
|
content,
|
|
});
|
|
}
|
|
} else if (part.type === "reasoning") {
|
|
const pending = pendingPartTextDeltas.get(part.id) ?? [];
|
|
if (pending.length > 0) {
|
|
const existing = reasoningDeltas.get(part.id) ?? [];
|
|
reasoningDeltas.set(part.id, existing.concat(pending));
|
|
}
|
|
pendingPartTextDeltas.delete(part.id);
|
|
const reasoningStatus = part.time.end ? "completed" : "running";
|
|
if (reasoningStatuses.get(part.id) !== reasoningStatus) {
|
|
reasoningStatuses.set(part.id, reasoningStatus);
|
|
logDevelopmentDebug("reasoning part status changed", {
|
|
...debugContext,
|
|
partId: part.id,
|
|
status: reasoningStatus,
|
|
chunkCount: (reasoningDeltas.get(part.id) ?? []).length,
|
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
});
|
|
}
|
|
const reasoningDetail = buildReasoningProgressDetail(
|
|
reasoningDeltas.get(part.id) ?? [],
|
|
part.time.end,
|
|
);
|
|
emitProgress({
|
|
id: part.id,
|
|
phase: "planning",
|
|
status: part.time.end ? "completed" : "running",
|
|
title: part.time.end ? "分析规划完成" : "正在规划分析步骤",
|
|
detail: reasoningDetail,
|
|
});
|
|
}
|
|
if (part.type === "tool") {
|
|
if (!firstToolEventLogged) {
|
|
firstToolEventLogged = true;
|
|
logDevelopmentDebug("first tool event received", {
|
|
...debugContext,
|
|
partId: part.id,
|
|
tool: part.tool,
|
|
status: part.state.status,
|
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
|
|
});
|
|
}
|
|
const toolParams = normalizeToolParams(part.state.input);
|
|
const reason = extractRequestReason(toolParams);
|
|
const isToolFinalState =
|
|
part.state.status === "completed" || part.state.status === "error";
|
|
const nextToolStatus = String(part.state.status);
|
|
|
|
if (toolStatuses.get(part.id) !== nextToolStatus) {
|
|
toolStatuses.set(part.id, nextToolStatus);
|
|
logDevelopmentDebug("tool part status changed", {
|
|
...debugContext,
|
|
partId: part.id,
|
|
tool: part.tool,
|
|
status: nextToolStatus,
|
|
reason: reason || null,
|
|
inputKeys: Object.keys(toolParams).slice(0, 8),
|
|
error:
|
|
part.state.status === "error" ? (part.state.error ?? "unknown") : null,
|
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
});
|
|
}
|
|
|
|
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",
|
|
status: normalizeToolStatus(part.state.status),
|
|
title: getToolProgressTitle(part.tool, part.state.status),
|
|
detail: buildToolProgressDetail(
|
|
part.tool,
|
|
part.state.status,
|
|
toolParams,
|
|
reason,
|
|
part.state.status === "error" ? part.state.error : undefined,
|
|
),
|
|
});
|
|
if (
|
|
!emittedToolParts.has(part.id) &&
|
|
(hasToolParams(toolParams) || isToolFinalState)
|
|
) {
|
|
emittedToolParts.add(part.id);
|
|
toolCallCount += 1;
|
|
if (!reason) {
|
|
logger.warn(
|
|
{
|
|
tool: part.tool,
|
|
sessionId: sessionId,
|
|
clientSessionId,
|
|
},
|
|
"llm tool request missing reason",
|
|
);
|
|
}
|
|
void writeLlmRequestAuditLog({
|
|
kind: "tool",
|
|
sessionId: sessionId,
|
|
clientSessionId,
|
|
traceId,
|
|
projectId,
|
|
target: part.tool,
|
|
reason,
|
|
reasonProvided: Boolean(reason),
|
|
payload: toolParams,
|
|
}).catch((error) => {
|
|
logger.warn({ err: error }, "failed to write tool audit log");
|
|
});
|
|
write("tool_call", {
|
|
session_id: clientSessionId,
|
|
tool: part.tool,
|
|
params: toolParams,
|
|
reason,
|
|
});
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (event.type === "todo.updated") {
|
|
sawResponseActivity = true;
|
|
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 === 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;
|
|
}
|
|
|
|
if (event.type === "session.error") {
|
|
sawResponseActivity = true;
|
|
logDevelopmentDebug("session error received", {
|
|
...debugContext,
|
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
error: event.properties.error
|
|
? getErrorMessage(event.properties.error)
|
|
: "opencode session error",
|
|
});
|
|
write("error", {
|
|
session_id: clientSessionId,
|
|
message: event.properties.error
|
|
? getErrorMessage(event.properties.error)
|
|
: "opencode session error",
|
|
detail: event.properties.error?.name,
|
|
total_duration_ms: Math.max(0, Date.now() - requestStartedAt),
|
|
});
|
|
failed = true;
|
|
done = true;
|
|
continue;
|
|
}
|
|
|
|
if (event.type === "session.idle") {
|
|
if (!sawResponseActivity) {
|
|
logDevelopmentDebug("ignoring session idle before response activity", {
|
|
...debugContext,
|
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
});
|
|
continue;
|
|
}
|
|
logDevelopmentDebug("session idle received", {
|
|
...debugContext,
|
|
emittedText,
|
|
toolCallCount,
|
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
});
|
|
emitProgress({
|
|
id: "session-status",
|
|
phase: "session",
|
|
status: "completed",
|
|
title: "Agent 已完成处理",
|
|
detail: "当前会话已无待执行任务,正在收尾并准备返回最终结果。",
|
|
});
|
|
done = true;
|
|
}
|
|
}
|
|
|
|
if (aborted) {
|
|
logDevelopmentDebug("chat stream aborting session", {
|
|
...debugContext,
|
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
});
|
|
await runtime.abortSession(sessionId).catch((error) => {
|
|
logger.warn({ sessionId: sessionId, err: error }, "failed to abort opencode session");
|
|
});
|
|
await runtime.waitForSessionIdle(sessionId).catch((error) => {
|
|
logger.warn(
|
|
{ sessionId: sessionId, err: error },
|
|
"failed while waiting for aborted opencode session to become idle",
|
|
);
|
|
});
|
|
return { aborted: true, failed: false, toolCallCount };
|
|
}
|
|
|
|
if (failed) {
|
|
return { aborted: false, failed: true, toolCallCount };
|
|
}
|
|
|
|
await promptPromise;
|
|
if (!emittedText) {
|
|
logDevelopmentDebug("no streamed text emitted, falling back to messages()", {
|
|
...debugContext,
|
|
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
|
});
|
|
await emitFallbackMessage(runtime, sessionId, clientSessionId, write);
|
|
}
|
|
emitProgress({
|
|
id: "request-received",
|
|
phase: "start",
|
|
status: "completed",
|
|
title: "请求处理完成",
|
|
detail: "本次请求的分析、工具执行和结果整理流程已经完成。",
|
|
});
|
|
emitProgress({
|
|
id: "request-completed",
|
|
phase: "complete",
|
|
status: "completed",
|
|
title: "分析完成",
|
|
detail: emittedText
|
|
? "最终回答已生成并推送到前端。"
|
|
: "已完成分析,并通过兜底消息补发最终回答内容。",
|
|
});
|
|
write("done", {
|
|
session_id: clientSessionId,
|
|
total_duration_ms: Math.max(0, Date.now() - requestStartedAt),
|
|
});
|
|
logDevelopmentDebug("chat stream completed", {
|
|
...debugContext,
|
|
emittedText,
|
|
toolCallCount,
|
|
totalDurationMs: Math.max(0, Date.now() - requestStartedAt),
|
|
});
|
|
return { aborted: false, failed: false, toolCallCount };
|
|
} finally {
|
|
await iterator.return?.(undefined);
|
|
if (!promptSettled && !aborted) {
|
|
await promptPromise.catch(() => undefined);
|
|
} else if (!promptSettled) {
|
|
void promptPromise.catch(() => undefined);
|
|
}
|
|
logDevelopmentDebug("chat stream cleanup finished", {
|
|
...debugContext,
|
|
promptSettled,
|
|
totalDurationMs: Math.max(0, Date.now() - requestStartedAt),
|
|
});
|
|
}
|
|
};
|