From d8cfaf3d8e440e745b97005e6b61d64d8f816596 Mon Sep 17 00:00:00 2001 From: modusensus Date: Thu, 12 Nov 2026 14:08:51 +0800 Subject: [PATCH] =?UTF-8?q?fix(llm):=20=E5=90=8E=E5=8F=B0=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E7=BB=9F=E4=B8=80=E5=B8=A6=E5=9B=BA=E5=AE=9A=20sessio?= =?UTF-8?q?n=20id=EF=BC=8COpenCode=20=E8=B7=AF=E7=94=B1=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=20400=20(#668)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- dsh-mneme/CHANGELOG.md | 6 +++ dsh-mneme/lib/api.js | 5 ++- dsh-mneme/lib/dream.js | 3 +- dsh-mneme/lib/dream/sleep.js | 3 +- dsh-mneme/lib/image-describe.js | 5 ++- dsh-mneme/lib/index.js | 5 ++- dsh-mneme/lib/llm-session.js | 33 +++++++++++++++ dsh-mneme/lib/pre-inject-gate.js | 3 +- dsh-mneme/lib/summarize.js | 5 ++- dsh-mneme/src/api.js | 5 ++- dsh-mneme/src/dream.js | 3 +- dsh-mneme/src/dream/sleep.js | 3 +- dsh-mneme/src/image-describe.js | 5 ++- dsh-mneme/src/index.js | 5 ++- dsh-mneme/src/llm-session.js | 33 +++++++++++++++ dsh-mneme/src/pre-inject-gate.js | 3 +- dsh-mneme/src/summarize.js | 5 ++- dsh-mneme/test/llm-session.test.js | 67 ++++++++++++++++++++++++++++++ 18 files changed, 175 insertions(+), 22 deletions(-) create mode 100644 dsh-mneme/lib/llm-session.js create mode 100644 dsh-mneme/src/llm-session.js create mode 100644 dsh-mneme/test/llm-session.test.js diff --git a/dsh-mneme/CHANGELOG.md b/dsh-mneme/CHANGELOG.md index f60e00e1..55d799ed 100644 --- a/dsh-mneme/CHANGELOG.md +++ b/dsh-mneme/CHANGELOG.md @@ -2,6 +2,12 @@ ## [Unreleased] +- **后台 LLM 调用一个 session id 都没带,走 OpenCode 路由必然 400(#668):七个调用点统一补上固定后台会话标识**。插件自己的后台调用(整理、蒸馏、实体抽取、注入门控、图像描述、连通性探测)此前只传 `provider`/`model`/`messages`,`sessionId` 全程缺席。这在多数网关上无声无息,但 OpenCode 适配器是硬要求:pi-ai 的 `dist/providers/opencode-headers.js` 里 `withSessionHeader` 只在 `options.sessionId` 存在时才注入 `x-opencode-session` 头,缺了就直接 400。上报者(#668)说的正是这条。 + - **一处收口,不散点补**:新增 `src/llm-session.js`,`BACKGROUND_SESSION_ID = "dsh-mneme"` 与 `withBackgroundSession(options)` 两个导出。七个点全部经它包一层——`src/dream.js` 与 `src/dream/sleep.js` 各有本地 `streamText`,改单点即覆盖全部 7 个 dream/sleep 相位;另五处是 `src/index.js`、`src/summarize.js`、`src/pre-inject-gate.js`、`src/api.js`、`src/image-describe.js`。 + - **固定标识而不是按 run id 派生**:按 run id 会让网关把每次整理当成一个新会话,限流按会话计数时反而更容易撞墙,排障时也分不清请求来自哪一层。固定成一个,既满足头部要求,也让网关侧能一眼认出「这是插件的后台流量」。 + - **不覆盖宿主已经给的真会话**:`options.sessionId` 已存在时原样返回,不覆盖。注入链路上宿主可能带着真实用户会话进来,那时把 id 换成插件标识等于伪造来源。 + - **测试**:`test/llm-session.test.js` 6 条(填 id 且不改入参、已给真 id 时不覆盖、容忍 undefined/null、七个调用点源码各含预期 needle、`src` 全域不得再出现裸 `.stream(options)` 或 `.stream({ ...route,`、常量值就是 `"dsh-mneme"` 且长度 1–64 符合网关约束)。变异自证五轮各自变红:撤掉 `src/dream.js` 接线、把 helper 改成恒等返回、撤掉 `src/image-describe.js` 接线、改成无条件覆盖 sessionId、撤掉 `src/summarize.js` 接线。 + ## [0.14.0-beta.3] - 2026-11-12 - **情绪页白屏(beta.1 起的回归,beta.2 仍在):点开「情感」页整块面板变空白,且此后每次打开都空白**。根因是 `lib/client.js` 的 `AffectivePanel` 里一行 `formatRelativeTime(it.lastSeen)` 漏传第二个参数 `t`——该函数内部要调 `t("memory.time.now")`,缺参就抛 `t is not a function`,而这行跑在 render 里,React 整棵树卸载,日志里什么都不留。引入提交是 #619(`244da66d`),`v0.14.0-beta.1` 与 `v0.14.0-beta.2` 都带它;只有库里存在带 `lastSeen` 的情绪边时才触发,所以不是每个用户都会遇到。修法是补上 `, t`,并加一条静态断言:扫出所有 `formatRelativeTime(` 调用点,逐个做括号配对、数顶层逗号,实参不足 2 个就红。全库只有这一处漏传(`formatDate`/`formatDateShort` 本来只收一个参数,扫描器报的几处是误报)。变异自证:撤掉修复后断言变红并直接指出 `:8168 (argc=1)`。 diff --git a/dsh-mneme/lib/api.js b/dsh-mneme/lib/api.js index b050eab8..cc5dc37c 100644 --- a/dsh-mneme/lib/api.js +++ b/dsh-mneme/lib/api.js @@ -7,6 +7,7 @@ import { timingSafeEqual, randomBytes } from "node:crypto"; import { TYPES } from "./store.js"; import { TYPE_FILE, renderMirrorText, renderFileHeader, parseHumanEdits, MIRROR_READONLY_TYPES } from "./mirror.js"; import { langOf } from "./lang.js"; +import { withBackgroundSession } from "./llm-session.js"; // issue #607:面板空态要显示「能直接粘贴就跑」的命令,而面板 bundle 拿不到 // CLI 入口路径(独立 bundle 不能 import src)——所以由这条路由把命令算好发给它。 import { choroCliCommandFrom } from "./wiki/cli-hint.js"; @@ -827,7 +828,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = const attempt = async (effort) => { streamError = ""; let reply = ""; - for await (const chunk of llm.stream({ + for await (const chunk of llm.stream(withBackgroundSession({ provider, model, purpose: "dsh-mneme-connectivity-test", @@ -847,7 +848,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } } ] - })) { + }))) { if (chunk.type === "text-delta" && typeof chunk.text === "string") reply += chunk.text; if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) { streamError = describeStreamFailure(chunk.reason); diff --git a/dsh-mneme/lib/dream.js b/dsh-mneme/lib/dream.js index 6fdad61e..16c1a4cd 100644 --- a/dsh-mneme/lib/dream.js +++ b/dsh-mneme/lib/dream.js @@ -11,6 +11,7 @@ import { scopeKeyOf } from "./scope.js"; import { isInPeakWindow, nextOffPeakAt } from "./peak-hours.js"; import { createHash, randomUUID } from "node:crypto"; import { STR, langOf } from "./lang.js"; +import { withBackgroundSession } from "./llm-session.js"; // 巩固读的是行池,而动行的写手不经汇流口:applyDecisions 归档败者直接走 // service.setArchived(dream/decisions.js),所以「不许改写外来行」这条契约要在 // 每个池子各挡一遍。清单与静态锁见 share-imported-guard.test.js。 @@ -289,7 +290,7 @@ function buildRecordReceipts({ runId, committed, snapshot, policyEpoch }) { */ async function streamText(ctx, options, onUsage, onStreamError) { let text = ""; - for await (const chunk of ctx.llm.stream(options)) { + for await (const chunk of ctx.llm.stream(withBackgroundSession(options))) { if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text; // DSH 的 StreamChunk 契约把用量嵌在 chunk.usage(TokenUsage:inputTokens / // outputTokens / …),chunk 顶层没有 token 字段——把整个 chunk 当用量对象交给 diff --git a/dsh-mneme/lib/dream/sleep.js b/dsh-mneme/lib/dream/sleep.js index 784e1166..32abef2c 100644 --- a/dsh-mneme/lib/dream/sleep.js +++ b/dsh-mneme/lib/dream/sleep.js @@ -1,4 +1,5 @@ import { STR, langOf } from "../lang.js"; +import { withBackgroundSession } from "../llm-session.js"; // System-level sleep (v0.4.0): an idle-triggered, LLM-assisted deep pass over // the memory store. Four independent, fail-safe phases: // 1. conflict resolution — high-similarity same-type pairs are either parked @@ -86,7 +87,7 @@ function parseJsonArray(text) { async function streamText(ctx, options, onUsage, onStreamError) { if (!ctx?.llm?.stream) return undefined; let text = ""; - for await (const chunk of ctx.llm.stream(options)) { + for await (const chunk of ctx.llm.stream(withBackgroundSession(options))) { if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text; // DSH 的 StreamChunk 契约把用量嵌在 chunk.usage(TokenUsage:inputTokens / // outputTokens / totalTokens);兼容直接平铺在 chunk 上的旧形态。归一放在 diff --git a/dsh-mneme/lib/image-describe.js b/dsh-mneme/lib/image-describe.js index 047eb4a2..b7706f82 100644 --- a/dsh-mneme/lib/image-describe.js +++ b/dsh-mneme/lib/image-describe.js @@ -25,6 +25,7 @@ // 对话正文挤出窗口,那是拿主产物换辅产物。 import { STR, langOf } from "./lang.js"; +import { withBackgroundSession } from "./llm-session.js"; import { imageLabel } from "./attachment-text.js"; /** 单张图描述的字数天花板(字符)。 */ @@ -92,7 +93,7 @@ export async function describeImages(ctx, route, config, blocks, options = {}) { if (out.has(key)) continue; try { let text = ""; - const stream = ctx.llm.stream({ + const stream = ctx.llm.stream(withBackgroundSession({ provider: route.provider, model: route.model, purpose: "image-description", @@ -108,7 +109,7 @@ export async function describeImages(ctx, route, config, blocks, options = {}) { } ], ...(options.signal ? { signal: options.signal } : {}) - }); + })); for await (const chunk of stream) { if (chunk?.type === "text-delta") text += chunk.text ?? chunk.delta ?? ""; if (chunk?.type === "finish") { diff --git a/dsh-mneme/lib/index.js b/dsh-mneme/lib/index.js index f627bde9..6f0edeb6 100644 --- a/dsh-mneme/lib/index.js +++ b/dsh-mneme/lib/index.js @@ -53,6 +53,7 @@ import { createVectorIndex } from "./vector-index.js"; import { createSemantic } from "./semantic.js"; import { Config, applyLightModePreset, injectChildEnabled } from "./config.js"; import { langOf } from "./lang.js"; +import { withBackgroundSession } from "./llm-session.js"; import { extractEntities } from "./entities/extractor.js"; import { createProfileExtractor } from "./profile-extract.js"; import { mkdirSync } from "node:fs"; @@ -149,12 +150,12 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, serv } }; return (async () => { - for await (const chunk of llm.stream({ + for await (const chunk of llm.stream(withBackgroundSession({ ...route, maxTokens: 4096, ...(withEffort && effort ? { reasoningEffort: effort } : {}), messages - })) { + }))) { if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text; // DSH 的 StreamChunk 契约把用量嵌在 chunk.usage(TokenUsage);兼容直接 // 平铺在 chunk 上的旧形态。归一放在这里,下面只需认平铺形状——与 diff --git a/dsh-mneme/lib/llm-session.js b/dsh-mneme/lib/llm-session.js new file mode 100644 index 00000000..1bc7b2ce --- /dev/null +++ b/dsh-mneme/lib/llm-session.js @@ -0,0 +1,33 @@ +/** + * 后台 LLM 调用的会话标识(issue #668)。 + * + * 主对话之所以能走网关,是因为 `@deepseek-ai/dsh-agent-loop` 在请求里带了 + * `sessionId: this.session.id`;而 pi-ai 的 opencode / opencode-go 适配器 + * (`dist/providers/opencode-headers.js` 的 `withSessionHeader`)**只在 + * `options.sessionId` 存在时**才注入 `x-opencode-session` 头,缺了就被网关 + * 直接 400 `MissingSessionID` 拒绝。 + * + * 插件这一侧有 7 处后台调用:总结提炼、记忆巩固、sleep 各相位、实体抽取、 + * 注入门控、连通性探测、图像描述。它们都没有真实会话——不是「忘了传」, + * 而是本来就不属于任何用户会话(审计行 `session_key` 也一直是 NULL)。 + * 于是传一个稳定的固定标识:只服务网关路由,不参与会话归属、不影响谁能看到 + * 什么、也不改变记忆的作用域判定。 + * + * 为什么不按 run id 派生:那会让网关把同一批后台调用当成 N 个不同会话, + * 既拿不到路由复用的好处,也让排障时「这是插件干的」和「这是某个用户在聊」 + * 混在一起。固定值一眼能分辨来源。 + */ +export const BACKGROUND_SESSION_ID = "dsh-mneme"; + +/** + * 给一份 GenerateOptions 补上后台会话标识。 + * + * 已自带 `sessionId` 的调用方保持原值不动(宿主若哪天替我们传了真实会话, + * 那个更精确,不该被覆盖);其余情况补 `BACKGROUND_SESSION_ID`。 + * 纯函数,不修改入参。 + */ +export function withBackgroundSession(options) { + if (!options || typeof options !== "object") return options; + if (options.sessionId) return options; + return { ...options, sessionId: BACKGROUND_SESSION_ID }; +} diff --git a/dsh-mneme/lib/pre-inject-gate.js b/dsh-mneme/lib/pre-inject-gate.js index 57e87586..f86ea50a 100644 --- a/dsh-mneme/lib/pre-inject-gate.js +++ b/dsh-mneme/lib/pre-inject-gate.js @@ -49,6 +49,7 @@ // 一条指令型记忆可诱导模型标记全池 → 整个记忆块静默失明。该形态本帧退回观察档 // 原样注入并 warn;判定结果仍在缓存与审计里(n_flagged == n_pool 即此形态)。 import { STR, langOf } from "./lang.js"; +import { withBackgroundSession } from "./llm-session.js"; export const GATE_TRIGGER_SOURCE = "preInjectGate"; export const GATE_OPERATION_TYPE = "pre_inject_gate"; @@ -236,7 +237,7 @@ export function createPreInjectGate({ llm, agentDefaultModel, service, config, l const abort = new AbortController(); const consume = (async () => { try { - for await (const chunk of llm.stream({ ...route, maxTokens: JUDGE_MAX_TOKENS, messages, signal: abort.signal })) { + for await (const chunk of llm.stream(withBackgroundSession({ ...route, maxTokens: JUDGE_MAX_TOKENS, messages, signal: abort.signal }))) { if (chunk.type === "text-delta" && typeof chunk.text === "string") text = (text ?? "") + chunk.text; if (chunk.type === "usage") { const u = chunk.usage ?? chunk; diff --git a/dsh-mneme/lib/summarize.js b/dsh-mneme/lib/summarize.js index 5d1de83b..65d71e90 100644 --- a/dsh-mneme/lib/summarize.js +++ b/dsh-mneme/lib/summarize.js @@ -1,5 +1,6 @@ import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm"; import { STR, langOf } from "./lang.js"; +import { withBackgroundSession } from "./llm-session.js"; // Issue #315:蒸馏链路的 effort 降级复用巩固侧的 withEffortFallback(拒绝 // 重试一次不带 effort 字段)与 describeStreamFailure(流失败原因归一)—— // 三条链路同一降级语义,不另造第二份实现。 @@ -997,9 +998,9 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = const baseDelayMs = config.distillRateLimitBaseDelayMs ?? 1000; // 每次尝试独立拼 effort 字段:降级重试(withEffort=false)必须真的 // 不带 reasoningEffort,不能复用带字段的同一 options 对象。 - const callOptions = withEffort && summarizeEffort + const callOptions = withBackgroundSession(withEffort && summarizeEffort ? { ...options, reasoningEffort: summarizeEffort } - : options; + : options); for (let attempt = 0; ; attempt++) { // 每次尝试重置审计状态:effort 拒收/429 的失败 attempt 会把 status 置 // error,若后续重试成功,审计必须记录最终成功而不是残留第一次的失败 diff --git a/dsh-mneme/src/api.js b/dsh-mneme/src/api.js index b050eab8..cc5dc37c 100644 --- a/dsh-mneme/src/api.js +++ b/dsh-mneme/src/api.js @@ -7,6 +7,7 @@ import { timingSafeEqual, randomBytes } from "node:crypto"; import { TYPES } from "./store.js"; import { TYPE_FILE, renderMirrorText, renderFileHeader, parseHumanEdits, MIRROR_READONLY_TYPES } from "./mirror.js"; import { langOf } from "./lang.js"; +import { withBackgroundSession } from "./llm-session.js"; // issue #607:面板空态要显示「能直接粘贴就跑」的命令,而面板 bundle 拿不到 // CLI 入口路径(独立 bundle 不能 import src)——所以由这条路由把命令算好发给它。 import { choroCliCommandFrom } from "./wiki/cli-hint.js"; @@ -827,7 +828,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = const attempt = async (effort) => { streamError = ""; let reply = ""; - for await (const chunk of llm.stream({ + for await (const chunk of llm.stream(withBackgroundSession({ provider, model, purpose: "dsh-mneme-connectivity-test", @@ -847,7 +848,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic = source: { kind: "plugin:dsh-mneme", plugin: "dsh-mneme" } } ] - })) { + }))) { if (chunk.type === "text-delta" && typeof chunk.text === "string") reply += chunk.text; if (chunk.type === "finish" && (chunk.reason?.kind === "error" || chunk.reason?.kind === "aborted")) { streamError = describeStreamFailure(chunk.reason); diff --git a/dsh-mneme/src/dream.js b/dsh-mneme/src/dream.js index 6fdad61e..16c1a4cd 100644 --- a/dsh-mneme/src/dream.js +++ b/dsh-mneme/src/dream.js @@ -11,6 +11,7 @@ import { scopeKeyOf } from "./scope.js"; import { isInPeakWindow, nextOffPeakAt } from "./peak-hours.js"; import { createHash, randomUUID } from "node:crypto"; import { STR, langOf } from "./lang.js"; +import { withBackgroundSession } from "./llm-session.js"; // 巩固读的是行池,而动行的写手不经汇流口:applyDecisions 归档败者直接走 // service.setArchived(dream/decisions.js),所以「不许改写外来行」这条契约要在 // 每个池子各挡一遍。清单与静态锁见 share-imported-guard.test.js。 @@ -289,7 +290,7 @@ function buildRecordReceipts({ runId, committed, snapshot, policyEpoch }) { */ async function streamText(ctx, options, onUsage, onStreamError) { let text = ""; - for await (const chunk of ctx.llm.stream(options)) { + for await (const chunk of ctx.llm.stream(withBackgroundSession(options))) { if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text; // DSH 的 StreamChunk 契约把用量嵌在 chunk.usage(TokenUsage:inputTokens / // outputTokens / …),chunk 顶层没有 token 字段——把整个 chunk 当用量对象交给 diff --git a/dsh-mneme/src/dream/sleep.js b/dsh-mneme/src/dream/sleep.js index 784e1166..32abef2c 100644 --- a/dsh-mneme/src/dream/sleep.js +++ b/dsh-mneme/src/dream/sleep.js @@ -1,4 +1,5 @@ import { STR, langOf } from "../lang.js"; +import { withBackgroundSession } from "../llm-session.js"; // System-level sleep (v0.4.0): an idle-triggered, LLM-assisted deep pass over // the memory store. Four independent, fail-safe phases: // 1. conflict resolution — high-similarity same-type pairs are either parked @@ -86,7 +87,7 @@ function parseJsonArray(text) { async function streamText(ctx, options, onUsage, onStreamError) { if (!ctx?.llm?.stream) return undefined; let text = ""; - for await (const chunk of ctx.llm.stream(options)) { + for await (const chunk of ctx.llm.stream(withBackgroundSession(options))) { if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text; // DSH 的 StreamChunk 契约把用量嵌在 chunk.usage(TokenUsage:inputTokens / // outputTokens / totalTokens);兼容直接平铺在 chunk 上的旧形态。归一放在 diff --git a/dsh-mneme/src/image-describe.js b/dsh-mneme/src/image-describe.js index 047eb4a2..b7706f82 100644 --- a/dsh-mneme/src/image-describe.js +++ b/dsh-mneme/src/image-describe.js @@ -25,6 +25,7 @@ // 对话正文挤出窗口,那是拿主产物换辅产物。 import { STR, langOf } from "./lang.js"; +import { withBackgroundSession } from "./llm-session.js"; import { imageLabel } from "./attachment-text.js"; /** 单张图描述的字数天花板(字符)。 */ @@ -92,7 +93,7 @@ export async function describeImages(ctx, route, config, blocks, options = {}) { if (out.has(key)) continue; try { let text = ""; - const stream = ctx.llm.stream({ + const stream = ctx.llm.stream(withBackgroundSession({ provider: route.provider, model: route.model, purpose: "image-description", @@ -108,7 +109,7 @@ export async function describeImages(ctx, route, config, blocks, options = {}) { } ], ...(options.signal ? { signal: options.signal } : {}) - }); + })); for await (const chunk of stream) { if (chunk?.type === "text-delta") text += chunk.text ?? chunk.delta ?? ""; if (chunk?.type === "finish") { diff --git a/dsh-mneme/src/index.js b/dsh-mneme/src/index.js index f627bde9..6f0edeb6 100644 --- a/dsh-mneme/src/index.js +++ b/dsh-mneme/src/index.js @@ -53,6 +53,7 @@ import { createVectorIndex } from "./vector-index.js"; import { createSemantic } from "./semantic.js"; import { Config, applyLightModePreset, injectChildEnabled } from "./config.js"; import { langOf } from "./lang.js"; +import { withBackgroundSession } from "./llm-session.js"; import { extractEntities } from "./entities/extractor.js"; import { createProfileExtractor } from "./profile-extract.js"; import { mkdirSync } from "node:fs"; @@ -149,12 +150,12 @@ export function createEntityStreamAdapter({ llm, agentDefaultModel, logger, serv } }; return (async () => { - for await (const chunk of llm.stream({ + for await (const chunk of llm.stream(withBackgroundSession({ ...route, maxTokens: 4096, ...(withEffort && effort ? { reasoningEffort: effort } : {}), messages - })) { + }))) { if (chunk.type === "text-delta" && typeof chunk.text === "string") text += chunk.text; // DSH 的 StreamChunk 契约把用量嵌在 chunk.usage(TokenUsage);兼容直接 // 平铺在 chunk 上的旧形态。归一放在这里,下面只需认平铺形状——与 diff --git a/dsh-mneme/src/llm-session.js b/dsh-mneme/src/llm-session.js new file mode 100644 index 00000000..1bc7b2ce --- /dev/null +++ b/dsh-mneme/src/llm-session.js @@ -0,0 +1,33 @@ +/** + * 后台 LLM 调用的会话标识(issue #668)。 + * + * 主对话之所以能走网关,是因为 `@deepseek-ai/dsh-agent-loop` 在请求里带了 + * `sessionId: this.session.id`;而 pi-ai 的 opencode / opencode-go 适配器 + * (`dist/providers/opencode-headers.js` 的 `withSessionHeader`)**只在 + * `options.sessionId` 存在时**才注入 `x-opencode-session` 头,缺了就被网关 + * 直接 400 `MissingSessionID` 拒绝。 + * + * 插件这一侧有 7 处后台调用:总结提炼、记忆巩固、sleep 各相位、实体抽取、 + * 注入门控、连通性探测、图像描述。它们都没有真实会话——不是「忘了传」, + * 而是本来就不属于任何用户会话(审计行 `session_key` 也一直是 NULL)。 + * 于是传一个稳定的固定标识:只服务网关路由,不参与会话归属、不影响谁能看到 + * 什么、也不改变记忆的作用域判定。 + * + * 为什么不按 run id 派生:那会让网关把同一批后台调用当成 N 个不同会话, + * 既拿不到路由复用的好处,也让排障时「这是插件干的」和「这是某个用户在聊」 + * 混在一起。固定值一眼能分辨来源。 + */ +export const BACKGROUND_SESSION_ID = "dsh-mneme"; + +/** + * 给一份 GenerateOptions 补上后台会话标识。 + * + * 已自带 `sessionId` 的调用方保持原值不动(宿主若哪天替我们传了真实会话, + * 那个更精确,不该被覆盖);其余情况补 `BACKGROUND_SESSION_ID`。 + * 纯函数,不修改入参。 + */ +export function withBackgroundSession(options) { + if (!options || typeof options !== "object") return options; + if (options.sessionId) return options; + return { ...options, sessionId: BACKGROUND_SESSION_ID }; +} diff --git a/dsh-mneme/src/pre-inject-gate.js b/dsh-mneme/src/pre-inject-gate.js index 57e87586..f86ea50a 100644 --- a/dsh-mneme/src/pre-inject-gate.js +++ b/dsh-mneme/src/pre-inject-gate.js @@ -49,6 +49,7 @@ // 一条指令型记忆可诱导模型标记全池 → 整个记忆块静默失明。该形态本帧退回观察档 // 原样注入并 warn;判定结果仍在缓存与审计里(n_flagged == n_pool 即此形态)。 import { STR, langOf } from "./lang.js"; +import { withBackgroundSession } from "./llm-session.js"; export const GATE_TRIGGER_SOURCE = "preInjectGate"; export const GATE_OPERATION_TYPE = "pre_inject_gate"; @@ -236,7 +237,7 @@ export function createPreInjectGate({ llm, agentDefaultModel, service, config, l const abort = new AbortController(); const consume = (async () => { try { - for await (const chunk of llm.stream({ ...route, maxTokens: JUDGE_MAX_TOKENS, messages, signal: abort.signal })) { + for await (const chunk of llm.stream(withBackgroundSession({ ...route, maxTokens: JUDGE_MAX_TOKENS, messages, signal: abort.signal }))) { if (chunk.type === "text-delta" && typeof chunk.text === "string") text = (text ?? "") + chunk.text; if (chunk.type === "usage") { const u = chunk.usage ?? chunk; diff --git a/dsh-mneme/src/summarize.js b/dsh-mneme/src/summarize.js index 5d1de83b..65d71e90 100644 --- a/dsh-mneme/src/summarize.js +++ b/dsh-mneme/src/summarize.js @@ -1,5 +1,6 @@ import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm"; import { STR, langOf } from "./lang.js"; +import { withBackgroundSession } from "./llm-session.js"; // Issue #315:蒸馏链路的 effort 降级复用巩固侧的 withEffortFallback(拒绝 // 重试一次不带 effort 字段)与 describeStreamFailure(流失败原因归一)—— // 三条链路同一降级语义,不另造第二份实现。 @@ -997,9 +998,9 @@ export function createSummarizer(ctx, service, config, deps = {}, sessionGate = const baseDelayMs = config.distillRateLimitBaseDelayMs ?? 1000; // 每次尝试独立拼 effort 字段:降级重试(withEffort=false)必须真的 // 不带 reasoningEffort,不能复用带字段的同一 options 对象。 - const callOptions = withEffort && summarizeEffort + const callOptions = withBackgroundSession(withEffort && summarizeEffort ? { ...options, reasoningEffort: summarizeEffort } - : options; + : options); for (let attempt = 0; ; attempt++) { // 每次尝试重置审计状态:effort 拒收/429 的失败 attempt 会把 status 置 // error,若后续重试成功,审计必须记录最终成功而不是残留第一次的失败 diff --git a/dsh-mneme/test/llm-session.test.js b/dsh-mneme/test/llm-session.test.js new file mode 100644 index 00000000..ed064878 --- /dev/null +++ b/dsh-mneme/test/llm-session.test.js @@ -0,0 +1,67 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { BACKGROUND_SESSION_ID, withBackgroundSession } from "../src/llm-session.js"; + +// Issue #668:pi-ai 的 opencode / opencode-go 适配器只在 options.sessionId 存在时 +// 才注入 x-opencode-session 头,插件侧后台调用一个都没传,于是走这些路由的用户 +// 「记忆 → 状态」页全是 400 MissingSessionID。这组用例锁的是「每一处后台调用都 +// 真的走到了补 sessionId 的那条路」,而不是只锁那个小函数本身——只测函数的话, +// 把某个调用点的 withBackgroundSession 删掉,测试照样全绿。 + +test("withBackgroundSession fills the id and never mutates its input", () => { + const input = { provider: "p", model: "m" }; + const out = withBackgroundSession(input); + assert.equal(out.sessionId, BACKGROUND_SESSION_ID); + assert.equal(input.sessionId, undefined, "must not mutate the caller's object"); + assert.deepEqual({ ...out, sessionId: undefined }, { ...input, sessionId: undefined }); +}); + +test("withBackgroundSession keeps a real session id the host already gave", () => { + const out = withBackgroundSession({ sessionId: "sess-real", provider: "p" }); + assert.equal(out.sessionId, "sess-real", "a real conversation id is more precise than the plugin's"); + assert.equal(out.provider, "p"); +}); + +test("withBackgroundSession tolerates junk input", () => { + assert.equal(withBackgroundSession(undefined), undefined); + assert.equal(withBackgroundSession(null), null); +}); + +// 下面四条是「接线」锁:直接读源码,确认每个后台 LLM 调用点的 stream 参数都被 +// withBackgroundSession 包住了。删掉任何一处都会红。 +test("every background llm.stream call site passes a session id (#668)", async () => { + const { readFileSync } = await import("node:fs"); + const root = new URL("../src/", import.meta.url); + const sites = [ + ["dream.js", "ctx.llm.stream(withBackgroundSession(options))"], + ["dream/sleep.js", "ctx.llm.stream(withBackgroundSession(options))"], + ["index.js", "llm.stream(withBackgroundSession({"], + ["pre-inject-gate.js", "llm.stream(withBackgroundSession({ ...route, maxTokens: JUDGE_MAX_TOKENS"], + ["api.js", "llm.stream(withBackgroundSession({"], + ["image-describe.js", "ctx.llm.stream(withBackgroundSession({"], + ["summarize.js", "withBackgroundSession(withEffort && summarizeEffort"] + ]; + for (const [file, needle] of sites) { + const src = readFileSync(new URL(file, root), "utf8"); + assert.ok(src.includes(needle), `${file} 的 llm.stream 调用必须补 sessionId(#668)`); + } +}); + +test("no bare llm.stream(options) survives anywhere in src", async () => { + const { readFileSync } = await import("node:fs"); + const root = new URL("../src/", import.meta.url); + const files = ["dream.js", "dream/sleep.js", "index.js", "pre-inject-gate.js", "api.js", "image-describe.js", "summarize.js"]; + for (const file of files) { + const src = readFileSync(new URL(file, root), "utf8"); + // 允许 withBackgroundSession(...) 包裹,也允许测试替身之外的无参形态; + // 这里只禁「直接把一份裸 options 丢进 stream」这一种回归形态。 + assert.ok(!/\.stream\(\s*options\s*\)/.test(src), `${file} 里不该再出现裸 ctx.llm.stream(options)`); + assert.ok(!/\.stream\(\{\s*\.\.\.route,/.test(src), `${file} 里不该再出现裸 llm.stream({ ...route》)`); + } +}); + +test("the background id is a stable, greppable constant", () => { + assert.equal(BACKGROUND_SESSION_ID, "dsh-mneme"); + assert.ok(BACKGROUND_SESSION_ID.length > 0 && BACKGROUND_SESSION_ID.length <= 64); +});