From 6f3b72628fc5a8a2162ed5d44b21d5c7e895bdc3 Mon Sep 17 00:00:00 2001 From: Huarch Date: Sun, 7 Jun 2026 20:22:05 +0800 Subject: [PATCH] fix(chat): guard abort and early idle races --- src/config.ts | 8 +++++++- src/routes/chatStream.ts | 24 +++++++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/config.ts b/src/config.ts index f5c860b..93ab587 100644 --- a/src/config.ts +++ b/src/config.ts @@ -49,6 +49,8 @@ const envSchema = z OPENCODE_SKILLS_ROOT_DIR: z.string().default("./.opencode/skills"), // client 模式下,目标 opencode server 的基础地址。 OPENCODE_CLIENT_BASE_URL: z.string().url().optional(), + // 旧版 client 模式环境变量名,保留兼容,解析时会映射到 OPENCODE_CLIENT_BASE_URL。 + OPENCODE_BASE_URL: z.string().url().optional(), // tjwater-cli 可执行文件路径。 TJWATER_CLI_PATH: z.string().default("./cli/tjwater-cli"), // TJWater 后端 API 的基础地址。 @@ -122,7 +124,11 @@ const normalizedEnv = { ...process.env, OPENCODE_MODE: process.env.OPENCODE_MODE ?? - (process.env.OPENCODE_CLIENT_BASE_URL ? "client" : "embedded"), + (process.env.OPENCODE_CLIENT_BASE_URL || process.env.OPENCODE_BASE_URL + ? "client" + : "embedded"), + OPENCODE_CLIENT_BASE_URL: + process.env.OPENCODE_CLIENT_BASE_URL ?? process.env.OPENCODE_BASE_URL, }; export const config: AppConfig = envSchema.parse(normalizedEnv); diff --git a/src/routes/chatStream.ts b/src/routes/chatStream.ts index f3ffdef..362b9ad 100644 --- a/src/routes/chatStream.ts +++ b/src/routes/chatStream.ts @@ -323,6 +323,7 @@ export const streamPromptResponse = async ({ 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; @@ -529,6 +530,7 @@ export const streamPromptResponse = async ({ } if (isSkillEvent(event)) { + sawResponseActivity = true; const { name, reason, payload } = extractSkillAuditInfo(event); logDevelopmentDebug("skill event received", { ...debugContext, @@ -552,7 +554,15 @@ export const streamPromptResponse = async ({ }); } + 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) { @@ -591,6 +601,7 @@ export const streamPromptResponse = async ({ } if (event.type === "message.part.updated") { + sawResponseActivity = true; const part = event.properties.part; partTypes.set(part.id, part.type); if (part.type === "text") { @@ -720,6 +731,7 @@ export const streamPromptResponse = async ({ } if (event.type === "todo.updated") { + sawResponseActivity = true; const completed = event.properties.todos.filter( (todo) => todo.status === "completed", ).length; @@ -736,6 +748,7 @@ export const streamPromptResponse = async ({ } if (event.type === "session.error") { + sawResponseActivity = true; logDevelopmentDebug("session error received", { ...debugContext, elapsedMs: Math.max(0, Date.now() - requestStartedAt), @@ -757,6 +770,13 @@ export const streamPromptResponse = async ({ } 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, @@ -832,8 +852,10 @@ export const streamPromptResponse = async ({ return { aborted: false, failed: false, toolCallCount }; } finally { await iterator.return?.(undefined); - if (!promptSettled) { + if (!promptSettled && !aborted) { await promptPromise.catch(() => undefined); + } else if (!promptSettled) { + void promptPromise.catch(() => undefined); } logDevelopmentDebug("chat stream cleanup finished", { ...debugContext,