From 30713c8de9a3755c8e9ca65ffc5e2d48b9a875b6 Mon Sep 17 00:00:00 2001 From: xuzhiqin1990 Date: Sat, 19 Sep 2026 19:52:54 +0800 Subject: [PATCH 01/85] fix(providers): raise default model call timeout to 300s The OpenAI-compatible provider defaulted to a 120s request timeout, which caused frequent timeouts during evaluation runs where model responses legitimately take longer. Raise the default to 300s. The MEMMY_AGENT_OPENAI_COMPAT_TIMEOUT_S env override is unchanged; only the fallback default changes. Update the two default-timeout assertions accordingly. Co-Authored-By: Claude Opus 4.8 (1M context) --- App/memmy-agent/src/providers/openai-compat-provider.ts | 2 +- App/memmy-agent/tests/providers/openai-compat-timeout.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/App/memmy-agent/src/providers/openai-compat-provider.ts b/App/memmy-agent/src/providers/openai-compat-provider.ts index 26ee43559..f940cbb1e 100644 --- a/App/memmy-agent/src/providers/openai-compat-provider.ts +++ b/App/memmy-agent/src/providers/openai-compat-provider.ts @@ -51,7 +51,7 @@ const THINKING_STYLE_MAP: Record Record> = const GATEWAY_REASONING_STYLE_MAP: Record Record> = { reasoning_effort: (effort) => ({ reasoning: { effort } }), }; -const OPENAI_COMPAT_REQUEST_TIMEOUT_S = 120.0; +const OPENAI_COMPAT_REQUEST_TIMEOUT_S = 300.0; export const RESPONSES_FAILURE_THRESHOLD = 3; export const RESPONSES_PROBE_INTERVAL_S = 300; diff --git a/App/memmy-agent/tests/providers/openai-compat-timeout.test.ts b/App/memmy-agent/tests/providers/openai-compat-timeout.test.ts index 03d2bd9aa..ca8d940e6 100644 --- a/App/memmy-agent/tests/providers/openai-compat-timeout.test.ts +++ b/App/memmy-agent/tests/providers/openai-compat-timeout.test.ts @@ -28,7 +28,7 @@ describe("OpenAI-compatible local timeout hints", () => { expect((provider as any).client).toBeNull(); const client = await provider.ensureClient(); - expect(client.timeout).toBe(120_000); + expect(client.timeout).toBe(300_000); expect(client.fetchOptions).toBeUndefined(); }); @@ -37,7 +37,7 @@ describe("OpenAI-compatible local timeout hints", () => { const client = await provider.ensureClient(); - expect(client.timeout).toBe(120_000); + expect(client.timeout).toBe(300_000); expect(client.fetchOptions.keepalive).toBe(false); }); From a88abc5816273086a9d071b55c8f87963f66da54 Mon Sep 17 00:00:00 2001 From: Daoji Wang <627665797@qq.com> Date: Mon, 14 Sep 2026 19:38:18 +0800 Subject: [PATCH 02/85] feat(models): add Custom protocols, thinking budget, and image declarations Expose OpenAI Completions/Responses and Custom providers in the model workspace, persist thinking/image capability on Custom presets, and send thinking fields through the chat turn so the backend can map them onto reasoningEffort. Co-authored-by: Cursor --- App/backend/local-api-contracts/package.json | 2 +- App/backend/local-api-contracts/src/index.ts | 30 ++- .../src/model-catalog-resolver.ts | 25 +- .../src/model-thinking-levels.ts | 249 ++++++++++++++++++ .../tests/model-catalog-resolver.test.ts | 43 +++ .../tests/model-thinking-levels.test.ts | 36 +++ .../memmy-config/model-config-catalog.ts | 29 +- App/frontend/desktop/src/api/config-client.ts | 37 ++- .../desktop/src/api/memmy-agent-client.ts | 8 +- .../src/components/agent-model-selector.tsx | 13 +- .../src/components/agent-thinking-control.tsx | 117 ++++++++ .../src/components/model-provider-logo.tsx | 4 +- App/frontend/desktop/src/i18n/messages.ts | 54 ++++ App/frontend/desktop/src/pages/home-page.tsx | 33 ++- .../desktop/src/pages/model-config.ts | 48 +++- .../src/pages/model-workspace-section.tsx | 228 ++++++++++++++-- .../desktop/src/pages/settings-page.tsx | 4 +- .../src/pages/tests/home-page.test.tsx | 37 +++ .../src/pages/tests/model-config.test.ts | 29 +- ...del-workspace-section.interaction.test.tsx | 2 +- ...odel-workspace-task-picker-layout.test.tsx | 2 +- .../src/pages/tests/settings-page.test.tsx | 19 +- .../desktop/src/state/agent-chat-slice.ts | 45 ++++ App/frontend/desktop/src/state/app-actions.ts | 8 + .../desktop/src/state/model-workspace.ts | 93 ++++++- .../src/state/tests/agent-chat-slice.test.ts | 37 +++ .../src/state/tests/model-workspace.test.ts | 57 +++- App/frontend/desktop/src/styles.css | 166 ++++++++++++ App/memmy-agent/src/config/schema.ts | 18 +- .../src/core/agent-runtime/loop.ts | 30 ++- .../src/core/agent-runtime/runner.ts | 9 +- App/memmy-agent/src/core/session/manager.ts | 6 + .../src/integrations/channels/websocket.ts | 68 ++--- App/memmy-agent/src/providers/factory.ts | 7 +- .../src/providers/model-catalog.ts | 3 + .../src/providers/model-input-capabilities.ts | 8 + .../runner-byok-image-input.test.ts | 30 +++ .../agent-runtime/thinking-effort.test.ts | 21 ++ .../channels/webui-message-digest.test.ts | 41 +++ .../tests/providers/bedrock-provider.test.ts | 35 +++ .../model-input-capabilities.test.ts | 8 + 41 files changed, 1617 insertions(+), 122 deletions(-) create mode 100644 App/backend/local-api-contracts/src/model-thinking-levels.ts create mode 100644 App/backend/local-api-contracts/tests/model-thinking-levels.test.ts create mode 100644 App/frontend/desktop/src/components/agent-thinking-control.tsx create mode 100644 App/memmy-agent/tests/core/agent-runtime/thinking-effort.test.ts create mode 100644 App/memmy-agent/tests/integrations/channels/webui-message-digest.test.ts diff --git a/App/backend/local-api-contracts/package.json b/App/backend/local-api-contracts/package.json index 17a8c8cf8..d33a62b9f 100644 --- a/App/backend/local-api-contracts/package.json +++ b/App/backend/local-api-contracts/package.json @@ -14,7 +14,7 @@ }, "scripts": { "build": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.json", - "test": "npm run typecheck && vitest run tests/desktop-runtime-manifest.test.ts tests/language.test.ts", + "test": "npm run typecheck && vitest run tests/desktop-runtime-manifest.test.ts tests/language.test.ts tests/model-catalog-resolver.test.ts tests/model-thinking-levels.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "dependencies": { diff --git a/App/backend/local-api-contracts/src/index.ts b/App/backend/local-api-contracts/src/index.ts index 69bf78250..999adcb3b 100644 --- a/App/backend/local-api-contracts/src/index.ts +++ b/App/backend/local-api-contracts/src/index.ts @@ -1,7 +1,9 @@ /** Memmy local API contract. */ import { z } from "zod"; +import { ModelThinkingConfigSchema } from "./model-thinking-levels.js"; export * from "./model-catalog-resolver.js"; +export * from "./model-thinking-levels.js"; export * from "./memory-runtime.js"; export * from "./memory-canonical-json.js"; @@ -742,7 +744,8 @@ export const CatalogProviderIdSchema = z.enum([ "volcengine", "stepfun", "xiaomi_mimo", - "memmy_account" + "memmy_account", + "bedrock" ]); export type CatalogProviderId = z.infer; @@ -784,7 +787,8 @@ export const ModelEndpointProtocolSchema = z.enum([ "dashscope-input-audio-chat", "openai-images", "dashscope-multimodal-generation", - "memmy-account" + "memmy-account", + "bedrock-converse" ]); export type ModelEndpointProtocol = z.infer; @@ -905,19 +909,26 @@ export const CatalogEndpointInputSchema = z.object({ protocol: ModelEndpointProtocolSchema, apiKey: z.string().optional(), extraHeaders: z.record(z.string(), z.string()).optional(), - extraBody: z.record(z.string(), z.unknown()).optional() + extraBody: z.record(z.string(), z.unknown()).optional(), + region: z.string().trim().min(1).optional() }); export type CatalogEndpointInput = z.infer; export const MODEL_NAME_MAX_LENGTH = 128; +export const ModelInputModalitySchema = z.enum(["text", "image", "video"]); +export type ModelInputModality = z.infer; + export const TextModelItemInputSchema = z.object({ presetId: z.string().trim().min(1).optional(), endpointId: z.string().trim().min(1), model: z.string().trim().min(1).max(MODEL_NAME_MAX_LENGTH), source: ModelSourceSchema, ownerAccountId: z.string().trim().min(1).optional(), - capabilities: z.array(ModelCapabilitySchema).min(1) + capabilities: z.array(ModelCapabilitySchema).min(1), + thinking: ModelThinkingConfigSchema.optional(), + custom: z.boolean().optional(), + inputModalities: z.array(ModelInputModalitySchema).optional() }); export type TextModelItemInput = z.infer; @@ -972,7 +983,8 @@ export const ModelConfigTestInputSchema = z.object({ modelId: z.string().min(1), apiKey: z.string().min(1).optional(), capability: ModelConfigTestCapabilitySchema.optional(), - secretTarget: ModelConfigTestSecretTargetSchema.optional() + secretTarget: ModelConfigTestSecretTargetSchema.optional(), + region: z.string().trim().min(1).optional() }); export type ModelConfigTestInput = z.infer; @@ -1078,7 +1090,8 @@ export const CatalogEndpointViewSchema = z.object({ protocol: ModelEndpointProtocolSchema, hasApiKey: z.boolean(), apiKeyMasked: z.string(), - apiKey: z.string().default("") + apiKey: z.string().default(""), + region: z.string().min(1).optional() }); export type CatalogEndpointView = z.infer; @@ -1091,7 +1104,10 @@ export const TextModelItemViewSchema = z.object({ source: ModelSourceSchema, ownerAccountId: z.string().min(1).optional(), capabilities: z.array(ModelCapabilitySchema).min(1), - available: z.boolean() + available: z.boolean(), + thinking: ModelThinkingConfigSchema.optional(), + custom: z.boolean().optional(), + inputModalities: z.array(ModelInputModalitySchema).optional() }); export type TextModelItemView = z.infer; diff --git a/App/backend/local-api-contracts/src/model-catalog-resolver.ts b/App/backend/local-api-contracts/src/model-catalog-resolver.ts index 289eadfad..b813c3ace 100644 --- a/App/backend/local-api-contracts/src/model-catalog-resolver.ts +++ b/App/backend/local-api-contracts/src/model-catalog-resolver.ts @@ -11,6 +11,7 @@ export interface RuntimeCatalogEndpoint { apiKey?: string; extraHeaders?: Record; extraBody?: Record; + region?: string; } export const BUILTIN_LOCAL_EMBEDDING_ASSIGNMENT_ID = "memmy-builtin-local-embedding"; @@ -30,6 +31,7 @@ export interface RuntimeCatalogPreset { source: ModelSource; ownerAccountId?: string; capabilities: ModelCapability[]; + inputModalities?: readonly ("text" | "image" | "video")[]; } export interface RuntimeModelAssignment { @@ -74,6 +76,7 @@ export interface ActualModelContext { ownerAccountId: string | null; capability: ModelCapability; capabilities: readonly ModelCapability[]; + inputModalities?: readonly ("text" | "image" | "video")[]; } export interface ResolvedProviderSnapshot { @@ -118,7 +121,8 @@ const CAPABILITIES = new Set([ const PROTOCOLS = new Set([ "openai-chat-completions", "openai-responses", "anthropic-messages", "gemini-generate-content", "openai-embeddings", "dashscope-input-audio-chat", - "openai-images", "dashscope-multimodal-generation", "memmy-account" + "openai-images", "dashscope-multimodal-generation", "memmy-account", + "bedrock-converse" ]); /** Resolves one immutable current-catalog model assignment without guessing another preset or endpoint. */ @@ -171,7 +175,8 @@ export function resolveAssignedModel(input: ResolveAssignedModelInput): ModelSel source: preset.source, ownerAccountId: preset.ownerAccountId ?? null, capability: input.capability, - capabilities + capabilities, + ...(preset.inputModalities ? { inputModalities: Object.freeze([...preset.inputModalities]) } : {}) }); const providerSnapshot = Object.freeze({ provider: preset.provider, @@ -301,7 +306,12 @@ function isRuntimePreset(value: unknown): value is RuntimeCatalogPreset { && value.capabilities.length > 0 && value.capabilities.every((capability): capability is ModelCapability => ( typeof capability === "string" && CAPABILITIES.has(capability as ModelCapability) - )); + )) + && (value.inputModalities === undefined + || (Array.isArray(value.inputModalities) + && value.inputModalities.every((modality) => ( + modality === "text" || modality === "image" || modality === "video" + )))); } function isRuntimeProvider(value: unknown): value is RuntimeCatalogProvider { @@ -320,7 +330,8 @@ function isRuntimeEndpoint(value: unknown): value is RuntimeCatalogEndpoint { && PROTOCOLS.has(value.protocol as ModelEndpointProtocol) && (value.apiKey === undefined || typeof value.apiKey === "string") && validStringRecord(value.extraHeaders) - && validUnknownRecord(value.extraBody); + && validUnknownRecord(value.extraBody) + && (value.region === undefined || typeof value.region === "string"); } function protocolSupportsCapability( @@ -332,12 +343,14 @@ function protocolSupportsCapability( return protocol === "openai-chat-completions" || protocol === "openai-responses" || protocol === "anthropic-messages" - || protocol === "gemini-generate-content"; + || protocol === "gemini-generate-content" + || protocol === "bedrock-converse"; } if (capability === "memory_summary" || capability === "memory_evolution") { return protocol === "openai-chat-completions" || protocol === "anthropic-messages" - || protocol === "gemini-generate-content"; + || protocol === "gemini-generate-content" + || protocol === "bedrock-converse"; } if (capability === "embedding") return protocol === "openai-embeddings"; if (capability === "asr") return protocol === "dashscope-input-audio-chat"; diff --git a/App/backend/local-api-contracts/src/model-thinking-levels.ts b/App/backend/local-api-contracts/src/model-thinking-levels.ts new file mode 100644 index 000000000..3df8208b2 --- /dev/null +++ b/App/backend/local-api-contracts/src/model-thinking-levels.ts @@ -0,0 +1,249 @@ +/** + * 模型思考强度配置表。Reviewed: 2026-09-10 + * + * 三个字段决定前端渲染与新 session 默认状态: + * - switchable: true → 渲染思考开关(用户可关闭思考) + * false → 不渲染开关(thinking always-on) + * - defaultEnabled: 新 session 默认开关状态(仅 switchable:true 时有意义,当前恒为 true) + * - levels: 强度档位列表(不含表示关闭的 "off")。空数组 = 无档位选择器。 + * + * 档位命名(统一 i18n key):low=低, medium=中, high=高, xhigh=超高, max=最强 + */ +import { z } from "zod"; + +/** 思考强度档位。前端 i18n key 由 `home.thinking.levelValue.${level}` 拼出,须为字面量联合。 */ +export const THINKING_LEVELS = ["low", "medium", "high", "xhigh", "max"] as const; +export type ThinkingLevel = (typeof THINKING_LEVELS)[number]; + +export interface ModelThinkingConfig { + switchable: boolean; + defaultEnabled: boolean; + levels: readonly ThinkingLevel[]; + defaultLevel?: ThinkingLevel; +} + +export const ModelThinkingConfigSchema = z.object({ + switchable: z.boolean(), + defaultEnabled: z.boolean(), + levels: z.array(z.enum(THINKING_LEVELS)), + defaultLevel: z.enum(THINKING_LEVELS).optional() +}); +export type ModelThinkingConfigDto = z.infer; + +export const MODEL_THINKING_REVIEWED_AT = "2026-09-10"; + +const ALWAYS_ON_FIVE: ModelThinkingConfig = { + switchable: false, + defaultEnabled: true, + levels: ["low", "medium", "high", "xhigh", "max"], + defaultLevel: "high" +}; + +const SWITCHABLE_FIVE: ModelThinkingConfig = { + switchable: true, + defaultEnabled: true, + levels: ["low", "medium", "high", "xhigh", "max"], + defaultLevel: "high" +}; + +const SWITCHABLE_FOUR: ModelThinkingConfig = { + switchable: true, + defaultEnabled: true, + levels: ["low", "medium", "high", "xhigh"], + defaultLevel: "medium" +}; + +const SWITCHABLE_THREE: ModelThinkingConfig = { + switchable: true, + defaultEnabled: true, + levels: ["low", "medium", "high"], + defaultLevel: "medium" +}; + +const SWITCHABLE_LOW_HIGH_MAX: ModelThinkingConfig = { + switchable: true, + defaultEnabled: true, + levels: ["low", "high", "max"], + defaultLevel: "high" +}; + +const SWITCHABLE_LOW_HIGH: ModelThinkingConfig = { + switchable: true, + defaultEnabled: true, + levels: ["low", "high"], + defaultLevel: "high" +}; + +const TOGGLE_ONLY: ModelThinkingConfig = { + switchable: true, + defaultEnabled: true, + levels: [] +}; + +export const MODEL_THINKING_CONFIGS: Readonly> = Object.freeze({ + // Anthropic. Reviewed 2026-09-10. + "claude-fable-5-1": ALWAYS_ON_FIVE, + "claude-fable-5": ALWAYS_ON_FIVE, + "claude-mythos-5-1": ALWAYS_ON_FIVE, + "claude-mythos-5": ALWAYS_ON_FIVE, + "claude-mythos-preview": ALWAYS_ON_FIVE, + "claude-opus-5": SWITCHABLE_FIVE, + "claude-sonnet-5": SWITCHABLE_FIVE, + "claude-opus-4-8": SWITCHABLE_FIVE, + "claude-opus-4-7": SWITCHABLE_FIVE, + "claude-opus-4-6": SWITCHABLE_FIVE, + "claude-sonnet-4-6": SWITCHABLE_FIVE, + "claude-opus-4-5-20251101": { + switchable: true, + defaultEnabled: true, + levels: ["low", "medium", "high", "max"], + defaultLevel: "high" + }, + "claude-haiku-4-5": TOGGLE_ONLY, + "claude-haiku-4-5-20251001": TOGGLE_ONLY, + "anthropic.claude-opus-5": SWITCHABLE_FIVE, + "anthropic.claude-sonnet-5": SWITCHABLE_FIVE, + "anthropic.claude-opus-4-8": SWITCHABLE_FIVE, + "anthropic.claude-opus-4-7": SWITCHABLE_FIVE, + "anthropic.claude-haiku-4-5": TOGGLE_ONLY, + + // OpenAI. Reviewed 2026-09-10. + "gpt-5.6": SWITCHABLE_FIVE, + "gpt-5.6-sol": SWITCHABLE_FIVE, + "gpt-5.6-terra": SWITCHABLE_FIVE, + "gpt-5.6-luna": SWITCHABLE_FIVE, + "gpt-5.5": SWITCHABLE_FOUR, + "gpt-5.5-pro": SWITCHABLE_FOUR, + "gpt-5.4": SWITCHABLE_THREE, + "gpt-5.4-pro": SWITCHABLE_THREE, + "gpt-5.4-mini": SWITCHABLE_THREE, + "gpt-5.4-nano": SWITCHABLE_THREE, + "gpt-5.3-codex": SWITCHABLE_THREE, + "gpt-5.2": SWITCHABLE_THREE, + "gpt-5.2-pro": SWITCHABLE_THREE, + "gpt-5.2-codex": SWITCHABLE_THREE, + "gpt-5.1": SWITCHABLE_THREE, + "gpt-5.1-codex": SWITCHABLE_THREE, + + // Gemini. Reviewed 2026-09-10. + "gemini-2.5-flash": TOGGLE_ONLY, + "gemini-2.5-flash-lite": TOGGLE_ONLY, + + // DeepSeek. Reviewed 2026-09-10. + "deepseek-v4-pro": SWITCHABLE_LOW_HIGH_MAX, + "deepseek-v4-flash": SWITCHABLE_LOW_HIGH_MAX, + "deepseek-v4-flash-0731": SWITCHABLE_LOW_HIGH_MAX, + "deepseek-v4-flash-vision-exp": SWITCHABLE_LOW_HIGH_MAX, + "deepseek-v3.2": TOGGLE_ONLY, + + // Zhipu GLM. Reviewed 2026-09-10. + "glm-5.3": { + switchable: false, + defaultEnabled: true, + levels: ["low", "high", "max"], + defaultLevel: "high" + }, + "glm-5.2": TOGGLE_ONLY, + "glm-5.1": TOGGLE_ONLY, + "glm-5": TOGGLE_ONLY, + "glm-5-turbo": TOGGLE_ONLY, + "glm-4.7": TOGGLE_ONLY, + + // Kimi / Moonshot. Reviewed 2026-09-10. + "kimi-k3": SWITCHABLE_LOW_HIGH_MAX, + "k3": SWITCHABLE_LOW_HIGH_MAX, + "k3-256k": SWITCHABLE_LOW_HIGH_MAX, + "kimi-k2.6": TOGGLE_ONLY, + "kimi-k2.5": TOGGLE_ONLY, + + // MiniMax. Reviewed 2026-09-10. + "MiniMax-M3": TOGGLE_ONLY, + + // Qwen. Reviewed 2026-09-10. + "qwen3.8-max": TOGGLE_ONLY, + "qwen3.8-max-preview": TOGGLE_ONLY, + "qwen3.7-max": TOGGLE_ONLY, + "qwen3.7-max-2026-05-20": TOGGLE_ONLY, + "qwen3.7-max-2026-06-08": TOGGLE_ONLY, + "qwen3.7-plus": TOGGLE_ONLY, + "qwen3.7-plus-2026-05-26": TOGGLE_ONLY, + "qwen3.7-flash": TOGGLE_ONLY, + "qwen3.7-flash-2026-07-15": TOGGLE_ONLY, + "qwen3.6-max-preview": TOGGLE_ONLY, + "qwen3.6-plus": TOGGLE_ONLY, + "qwen3.6-plus-2026-04-02": TOGGLE_ONLY, + "qwen3.6-flash": TOGGLE_ONLY, + "qwen3.6-flash-2026-04-16": TOGGLE_ONLY, + "qwen3.5-plus": TOGGLE_ONLY, + "qwen3.5-plus-2026-02-15": TOGGLE_ONLY, + "qwen3.5-flash": TOGGLE_ONLY, + "qwen3.5-flash-2026-02-23": TOGGLE_ONLY, + "qwen3-coder-plus": TOGGLE_ONLY, + "qwen3-coder-flash": TOGGLE_ONLY, + + // Doubao. Reviewed 2026-09-10. + "doubao-seed-evolving": TOGGLE_ONLY, + "doubao-seed-2-1-pro": TOGGLE_ONLY, + "doubao-seed-2-1-turbo": TOGGLE_ONLY, + "doubao-seed-2-0-pro-260215": TOGGLE_ONLY, + "doubao-seed-2-0-lite-260215": TOGGLE_ONLY, + "doubao-seed-2-0-mini-260215": TOGGLE_ONLY, + + // Xiaomi MiMo. Reviewed 2026-09-10. + "mimo-v2.5-pro": TOGGLE_ONLY, + "mimo-v2.5-pro-ultraspeed": TOGGLE_ONLY, + "mimo-v2.5": TOGGLE_ONLY, + + // StepFun. Reviewed 2026-09-10. + "step-3.7-flash": SWITCHABLE_THREE, + "step-3.5-flash": SWITCHABLE_LOW_HIGH, + "step-3.5-flash-2603": SWITCHABLE_LOW_HIGH, + + // Baidu ERNIE. Reviewed 2026-09-10. + "ernie-5.1": TOGGLE_ONLY, + "ernie-5.0": TOGGLE_ONLY, + "ernie-5.0-thinking-preview": TOGGLE_ONLY, + "ernie-5.0-thinking-latest": TOGGLE_ONLY, + "ernie-x1.1": TOGGLE_ONLY, + "ernie-x1.1-preview": TOGGLE_ONLY +}); + +export function getModelThinkingConfig(model: string | null | undefined): ModelThinkingConfig | null { + if (typeof model !== "string") return null; + return MODEL_THINKING_CONFIGS[model] ?? null; +} + +/** 是否只有开关无档位(情况2):可开关且无强度档位。 */ +export function isThinkingToggleOnly(config: ModelThinkingConfig | null): boolean { + if (!config) return false; + return config.switchable && config.levels.length === 0; +} + +/** 解析当前生效的思考档位:会话选择优先,否则回退表中默认档,再退首个档位。 */ +export function resolveThinkingLevel( + config: ModelThinkingConfig, + sessionLevel: string | null | undefined +): ThinkingLevel | null { + if (sessionLevel && config.levels.includes(sessionLevel as ThinkingLevel)) { + return sessionLevel as ThinkingLevel; + } + return config.defaultLevel ?? config.levels[0] ?? null; +} + +/** 解析当前生效的开关状态:always-on 恒 true,其余用会话选择,缺省取 defaultEnabled。 */ +export function resolveThinkingEnabled( + config: ModelThinkingConfig, + sessionEnabled: boolean | undefined +): boolean { + if (!config.switchable) return true; + return sessionEnabled ?? config.defaultEnabled; +} + +/** + * 从档位列表取默认档("中档"):优先 medium,否则取中间项(偶数个时偏下,避免默认落到最高档)。 + */ +export function defaultThinkingLevel(levels: readonly ThinkingLevel[]): ThinkingLevel | undefined { + if (levels.length === 0) return undefined; + if (levels.includes("medium")) return "medium"; + return levels[Math.floor((levels.length - 1) / 2)]; +} diff --git a/App/backend/local-api-contracts/tests/model-catalog-resolver.test.ts b/App/backend/local-api-contracts/tests/model-catalog-resolver.test.ts index 7bc025940..09ea02778 100644 --- a/App/backend/local-api-contracts/tests/model-catalog-resolver.test.ts +++ b/App/backend/local-api-contracts/tests/model-catalog-resolver.test.ts @@ -228,4 +228,47 @@ describe("resolveAssignedModel", () => { capability: "memory_summary" })).toEqual({ ok: false, code: "model_selection_unavailable" }); }); + + it("resolves bedrock-converse and declared input modalities", () => { + const current: RuntimeModelCatalog = { + providers: { + bedrock: { + endpoints: { + chat: { + apiBase: "https://bedrock-runtime.us-west-2.amazonaws.com", + protocol: "bedrock-converse", + region: "us-west-2" + } + } + } + }, + modelPresets: { + custom: { + provider: "bedrock", + endpoint: "chat", + model: "anthropic.claude-sonnet-5", + source: "byok", + capabilities: ["agent"], + inputModalities: ["text", "image"] + } + }, + modelAssignments: { + byok: { + agent: { candidates: ["custom"], default: "custom" } + } + } + }; + const resolved = resolveAssignedModel({ + catalog: current, + mode: "byok", + capability: "agent" + }); + + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(resolved.context.protocol).toBe("bedrock-converse"); + expect(resolved.context.provider).toBe("bedrock"); + expect(resolved.context.inputModalities).toEqual(["text", "image"]); + expect(resolved.provider.apiBase).toBe("https://bedrock-runtime.us-west-2.amazonaws.com"); + }); }); diff --git a/App/backend/local-api-contracts/tests/model-thinking-levels.test.ts b/App/backend/local-api-contracts/tests/model-thinking-levels.test.ts new file mode 100644 index 000000000..d67d416aa --- /dev/null +++ b/App/backend/local-api-contracts/tests/model-thinking-levels.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + defaultThinkingLevel, + getModelThinkingConfig, + isThinkingToggleOnly, + resolveThinkingEnabled, + resolveThinkingLevel, +} from "../src/model-thinking-levels.js"; + +describe("model thinking levels", () => { + it("returns table config, toggle-only, and default middle level", () => { + const gpt = getModelThinkingConfig("gpt-5.4"); + expect(gpt).toMatchObject({ + switchable: true, + defaultEnabled: true, + defaultLevel: "medium", + }); + expect(isThinkingToggleOnly(getModelThinkingConfig("claude-haiku-4-5"))).toBe(true); + expect(getModelThinkingConfig("unlisted-model")).toBeNull(); + expect(defaultThinkingLevel(["low", "medium", "high"])).toBe("medium"); + expect(defaultThinkingLevel(["low", "high"])).toBe("low"); + expect(defaultThinkingLevel([])).toBeUndefined(); + }); + + it("resolves session override against table defaults", () => { + const gpt = getModelThinkingConfig("gpt-5.4")!; + expect(resolveThinkingEnabled(gpt, undefined)).toBe(true); + expect(resolveThinkingEnabled(gpt, false)).toBe(false); + expect(resolveThinkingLevel(gpt, undefined)).toBe("medium"); + expect(resolveThinkingLevel(gpt, "high")).toBe("high"); + expect(resolveThinkingLevel(gpt, "not-a-level")).toBe("medium"); + + const alwaysOn = getModelThinkingConfig("claude-fable-5")!; + expect(resolveThinkingEnabled(alwaysOn, false)).toBe(true); + }); +}); diff --git a/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts b/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts index c1d25dc37..7f6d2dae3 100644 --- a/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts +++ b/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts @@ -27,9 +27,9 @@ const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; type ConfigRecord = Record; const CAPABILITY_PROTOCOLS: Readonly>> = { - agent: new Set(["openai-chat-completions", "openai-responses", "anthropic-messages", "gemini-generate-content", "memmy-account"]), - memory_summary: new Set(["openai-chat-completions", "anthropic-messages", "gemini-generate-content", "memmy-account"]), - memory_evolution: new Set(["openai-chat-completions", "anthropic-messages", "gemini-generate-content", "memmy-account"]), + agent: new Set(["openai-chat-completions", "openai-responses", "anthropic-messages", "gemini-generate-content", "memmy-account", "bedrock-converse"]), + memory_summary: new Set(["openai-chat-completions", "anthropic-messages", "gemini-generate-content", "memmy-account", "bedrock-converse"]), + memory_evolution: new Set(["openai-chat-completions", "anthropic-messages", "gemini-generate-content", "memmy-account", "bedrock-converse"]), embedding: new Set(["openai-embeddings", "memmy-account"]), asr: new Set(["dashscope-input-audio-chat", "memmy-account"]), image_generation: new Set(["openai-images", "dashscope-multimodal-generation", "memmy-account"]) @@ -145,7 +145,7 @@ function mergeModelConfig(config: ConfigRecord, input: ModelConfigInput): Config validatePresetEndpoint(providerInput.provider, modelInput, endpoint); const presetId = resolvePresetId(modelInput.presetId, existingByokPresets, usedPresetIds); const previousPreset = record(existingByokPresets[presetId]); - nextPresets[presetId] = { + const nextPreset: ConfigRecord = { ...previousPreset, provider: providerInput.provider, endpoint: modelInput.endpointId, @@ -153,8 +153,15 @@ function mergeModelConfig(config: ConfigRecord, input: ModelConfigInput): Config source: "byok", capabilities: [...new Set(modelInput.capabilities)] }; - delete record(nextPresets[presetId]).label; - delete record(nextPresets[presetId]).ownerAccountId; + delete record(nextPreset).label; + delete record(nextPreset).ownerAccountId; + if (modelInput.thinking) nextPreset.thinking = modelInput.thinking; + else delete nextPreset.thinking; + if (modelInput.custom) nextPreset.custom = true; + else delete nextPreset.custom; + if (modelInput.inputModalities) nextPreset.inputModalities = [...modelInput.inputModalities]; + else delete nextPreset.inputModalities; + nextPresets[presetId] = nextPreset; } } @@ -443,6 +450,8 @@ function mergeEndpoint(previous: ConfigRecord, input: CatalogEndpointInput): Con setOptionalSecret(next, "apiKey", input.apiKey, previous.apiKey); setOptionalRecord(next, "extraHeaders", input.extraHeaders, previous.extraHeaders); setOptionalRecord(next, "extraBody", input.extraBody, previous.extraBody); + if (input.region) next.region = input.region; + else delete next.region; return next; } @@ -651,7 +660,8 @@ function providerView( protocol, hasApiKey: Boolean(endpointApiKey), apiKeyMasked: maskSecret(endpointApiKey), - apiKey: "" + apiKey: "", + ...(stringValue(endpoint.region) ? { region: stringValue(endpoint.region)! } : {}) }]; }); return { @@ -692,7 +702,10 @@ function presetView( source, ...(stringValue(preset.ownerAccountId) ? { ownerAccountId: stringValue(preset.ownerAccountId)! } : {}), capabilities: arrayValue(preset.capabilities).filter(isModelCapability), - available: Boolean((hasCredential || API_KEY_OPTIONAL_PROVIDERS.has(providerId)) && ownerMatches) + available: Boolean((hasCredential || API_KEY_OPTIONAL_PROVIDERS.has(providerId)) && ownerMatches), + ...(preset.thinking && typeof preset.thinking === "object" ? { thinking: preset.thinking as TextModelItemView["thinking"] } : {}), + ...(preset.custom === true ? { custom: true } : {}), + ...(Array.isArray(preset.inputModalities) ? { inputModalities: preset.inputModalities as TextModelItemView["inputModalities"] } : {}) }; } diff --git a/App/frontend/desktop/src/api/config-client.ts b/App/frontend/desktop/src/api/config-client.ts index 00c147c71..c643a54f6 100644 --- a/App/frontend/desktop/src/api/config-client.ts +++ b/App/frontend/desktop/src/api/config-client.ts @@ -55,6 +55,7 @@ export interface ModelProviderConfig { provider: string; endpointId?: string; protocol?: ModelEndpointProtocol; + region?: string; endpoint: string; model: string; apiKey: string; @@ -270,7 +271,8 @@ export function createHttpConfigClient(config: RuntimeConfig): ConfigClient { modelId: modelConfig.model, apiKey: modelConfig.apiKey || undefined, capability, - secretTarget + secretTarget, + ...(modelConfig.region ? { region: modelConfig.region } : {}) }) }); }, @@ -821,11 +823,32 @@ function fromOptionalPreset(view: ModelConfigView, preset: ModelConfigView["prov } function toModelProvider(provider: string): ModelProvider { - if (provider === "openai") { + if (provider === "openai" || provider === "openai_responses") { return "openai_compatible"; } + if (provider === "bedrock") { + return "anthropic"; + } + if (provider === "gemini") { + return "google"; + } + if (provider === "moonshot") { + return "kimi"; + } + if (provider === "dashscope") { + return "qwen"; + } + if (provider === "qianfan") { + return "baidu"; + } + if (provider === "volcengine") { + return "doubao"; + } + if (provider === "xiaomi_mimo") { + return "xiaomi"; + } - return provider === "gemini" ? "google" : (provider as ModelProvider); + return provider as ModelProvider; } function toCatalogInput(config: ModelConfigInput | ModelConfigView): ModelConfigInput { if (!("configured" in config)) { @@ -841,7 +864,8 @@ function toCatalogInput(config: ModelConfigInput | ModelConfigView): ModelConfig endpointId: endpoint.endpointId, apiBase: endpoint.apiBase, protocol: endpoint.protocol, - ...(endpoint.apiKey ? { apiKey: endpoint.apiKey } : {}) + ...(endpoint.apiKey ? { apiKey: endpoint.apiKey } : {}), + ...(endpoint.region ? { region: endpoint.region } : {}) })), models: provider.models.map((model) => ({ ...(model.presetId ? { presetId: model.presetId } : {}), @@ -849,7 +873,10 @@ function toCatalogInput(config: ModelConfigInput | ModelConfigView): ModelConfig model: model.model, source: model.source, ...(model.ownerAccountId ? { ownerAccountId: model.ownerAccountId } : {}), - capabilities: [...model.capabilities] + capabilities: [...model.capabilities], + ...(model.thinking ? { thinking: model.thinking } : {}), + ...(model.custom ? { custom: true } : {}), + ...(model.inputModalities ? { inputModalities: [...model.inputModalities] } : {}) })) })), modelAssignments: structuredClone(config.modelAssignments) diff --git a/App/frontend/desktop/src/api/memmy-agent-client.ts b/App/frontend/desktop/src/api/memmy-agent-client.ts index 97ccbd236..27c6c80b3 100644 --- a/App/frontend/desktop/src/api/memmy-agent-client.ts +++ b/App/frontend/desktop/src/api/memmy-agent-client.ts @@ -225,7 +225,9 @@ const SessionSummarySchema = z.object({ projectId: z.string().nullable(), cwd: z.string(), model_preset: z.string().nullable().optional(), - model_selection: ModelSelectionSchema.nullable().optional() + model_selection: ModelSelectionSchema.nullable().optional(), + thinking_enabled: z.boolean().nullable().optional(), + thinking_level: z.string().nullable().optional() }).passthrough(); const ProjectSchema = z.object({ @@ -636,6 +638,8 @@ export type MemmyAgentSendMessageInput = { language?: MemmyAgentUiLanguage; media?: MemmyAgentMediaInput[]; modelPreset?: string | null; + thinkingEnabled?: boolean; + thinkingLevel?: string | null; }; export interface MemmyAgentNewChatResult { @@ -2324,6 +2328,8 @@ class MemmyAgentWebSocketSession implements MemmyAgentWebSocketConnection { ...(input.target ? { target: input.target } : {}), ...(input.language ? { language: input.language } : {}), ...(input.modelPreset !== undefined ? { model_preset: input.modelPreset } : {}), + ...(input.thinkingEnabled !== undefined ? { thinking_enabled: input.thinkingEnabled } : {}), + ...(input.thinkingLevel ? { thinking_level: input.thinkingLevel } : {}), ...(input.media?.length ? { media_paths: input.media.map((item) => item.path) } : {}) }, expectedGeneration); this.knownChats.add(input.chatId); diff --git a/App/frontend/desktop/src/components/agent-model-selector.tsx b/App/frontend/desktop/src/components/agent-model-selector.tsx index 85528e583..f81d63ffa 100644 --- a/App/frontend/desktop/src/components/agent-model-selector.tsx +++ b/App/frontend/desktop/src/components/agent-model-selector.tsx @@ -4,14 +4,16 @@ import { useTranslation } from "../i18n/use-translation.js"; import { agentActions, appActions } from "../state/app-actions.js"; import { useAppState } from "../state/app-state.js"; import { - getTaskModelCandidates, createModelWorkspace, + getTaskModelCandidates, resolveModelSelection, + resolveThinkingConfigForModel, type ModelWorkspaceMode } from "../state/model-workspace.js"; import { settingsTabHash } from "../pages/settings-nav.js"; +import { AgentThinkingControl } from "./agent-thinking-control.js"; import { ModelProviderLogo } from "./model-provider-logo.js"; import { Select, type SelectOption } from "./Select.js"; @@ -97,7 +99,15 @@ export function AgentModelSelector(props: AgentModelSelectorProps) { dispatch(appActions.navigate("/settings")); } + const thinkingConfig = resolveThinkingConfigForModel(workspace, resolved.candidate); + return ( +
+
setEditor((prev) => prev ? { ...prev, customProtocol: value as CustomProtocol } : null)} + className="select-control--subtle" + options={CUSTOM_PROTOCOL_OPTIONS.map((option) => ({ + value: option.value, + label: t(option.labelKey) + }))} + /> + {editor.customProtocol === "bedrock-converse" && ( + setEditor((prev) => prev ? { ...prev, region } : prev)} + placeholder={t("settings.modelWorkspace.regionPlaceholder")} + /> + )} +
+ + {editor.customThinkingEnabled && ( + <> + + {editor.customThinkingSwitchable && ( + + )} +
+ + setEditor((prev) => prev ? { ...prev, customThinkingLevels: event.target.value } : null)} + className="custom-thinking-levels__input" + /> +

+ {t("settings.modelWorkspace.customThinkingLevelsHint")} +

+
+ + )} + +
+ + )} ["t"] + t: ReturnType["t"], + identity?: Protocol ): string { - const protocol = protocolFromConnection(provider); + const protocol = identity ?? protocolFromConnection(provider); const option = PROTOCOL_OPTIONS.find((item) => item.value === protocol); return option ? t(option.labelKey) : provider; } +function identityForCandidate( + workspace: ReturnType, + candidate: ModelCandidate +): Protocol | undefined { + if (candidate.source !== "byok" || !candidate.connectionId) return undefined; + const connection = workspace.spaces.byok.connections.find((item) => item.id === candidate.connectionId) + ?? workspace.spaces.account.connections.find((item) => item.id === candidate.connectionId); + if (!connection) return isCustomModelEntry(workspace, candidate) ? "custom" : undefined; + return editorIdentity(connection); +} + export function protocolFromConnection(provider: string): Protocol { if (provider === "moonshot" || provider === "kimi") return "moonshot"; if (provider === "dashscope") return "qwen"; @@ -1466,10 +1621,27 @@ export function protocolFromConnection(provider: string): Protocol { return "openai"; } +export function catalogProviderForEditor(provider: Protocol, customProtocol: CustomProtocol | null): string { + if (provider === "custom") { + if (customProtocol === "anthropic-messages") return "anthropic"; + if (customProtocol === "bedrock-converse") return "bedrock"; + return "openai"; + } + if (provider === "openai_responses") return "openai"; + return fromProtocol(provider) === "kimi" ? "moonshot" : fromProtocol(provider); +} + +function editorIdentity(connection: ModelConnection): Protocol { + if (connection.modelEntries[0]?.custom === true) return "custom"; + if (connection.protocol === "openai-responses") return "openai_responses"; + return protocolFromConnection(connection.provider); +} + function protocolForEditor(provider: Protocol, capability: ModelCapability): ModelEndpointProtocol { if (capability === "embedding") return "openai-embeddings"; if (capability === "asr") return "dashscope-input-audio-chat"; if (capability === "image") return provider === "qwen" ? "dashscope-multimodal-generation" : "openai-images"; + if (provider === "openai_responses") return "openai-responses"; if (provider === "anthropic") return "anthropic-messages"; if (provider === "gemini") return "gemini-generate-content"; return "openai-chat-completions"; diff --git a/App/frontend/desktop/src/pages/settings-page.tsx b/App/frontend/desktop/src/pages/settings-page.tsx index 152fcd5b5..f9bcd51e9 100644 --- a/App/frontend/desktop/src/pages/settings-page.tsx +++ b/App/frontend/desktop/src/pages/settings-page.tsx @@ -2858,7 +2858,9 @@ function ProtocolSelect(props: ProtocolSelectProps) { value={props.value} onValueChange={(value) => props.onChange(value as Protocol)} className="select-control--paper" - options={PROTOCOL_OPTIONS.map((option) => ({ + options={PROTOCOL_OPTIONS + .filter((option) => option.value !== "openai_responses" && option.value !== "custom") + .map((option) => ({ value: option.value, label: t(option.labelKey) }))} diff --git a/App/frontend/desktop/src/pages/tests/home-page.test.tsx b/App/frontend/desktop/src/pages/tests/home-page.test.tsx index d2393cb6a..152e84858 100644 --- a/App/frontend/desktop/src/pages/tests/home-page.test.tsx +++ b/App/frontend/desktop/src/pages/tests/home-page.test.tsx @@ -5,6 +5,7 @@ import { Window } from "happy-dom"; import { renderToString } from "react-dom/server"; import { describe, expect, it, vi } from "vitest"; import { MemmyAgentMessageRejectedError } from "../../api/memmy-agent-client.js"; +import { createModelWorkspace, type ModelCandidate } from "../../state/model-workspace.js"; import { AgentRuntimeBridge } from "../../app/agent-runtime-bridge.js"; import { AppProviders } from "../../app/providers.js"; import { FOCUSED_AGENT_CHAT_STORAGE_KEY } from "../../app/routes.js"; @@ -41,6 +42,7 @@ import { resolveComposerCommandDraft, shouldAcceptAgentStatusResult, submitAgentComposerMessage, + thinkingPayloadForSend, updateAgentComposerOverlayHeight, updateComposerDraftForScope, fileToPendingAttachment, @@ -191,11 +193,16 @@ describe("HomePage", () => { expect(source).toContain("state.agent.committedModelSelectionByScope[modelSelectionScopeKey]"); expect(source).toContain("allowUnassignedSelected: pendingModelPreset == null && Boolean(committedModelSelection)"); expect(source).toContain("modelPreset: resolvedConversationModel.candidateId ?? undefined"); + expect(source).toContain("thinkingPayloadForSend("); expect(source).not.toContain("copyScopedModelSelection"); expect(selectorSource).toContain("agentActions.pendingModelPresetUpdated"); + expect(selectorSource).toContain("AgentThinkingControl"); + expect(selectorSource).toContain("resolveThinkingConfigForModel"); expect(selectorSource).not.toContain("localStorage"); expect(source).not.toContain('sendMessage({ chatId: state.agent.currentChatId, content: "/model'); expect(styles).toContain(".agent-model-selector .agent-model-selector__menu"); + expect(styles).toContain(".agent-model-selector-with-thinking"); + expect(styles).toContain(".agent-thinking-control"); expect(styles).toContain("top: calc(100% + 6px)"); expect(styles).toContain(".agent-model-selector__configure"); expect(source).not.toContain("modelSwitchNotice"); @@ -209,6 +216,36 @@ describe("HomePage", () => { expect(source).not.toContain("agent-conversation-model-error"); }); + it("组帧时按模型思考表解析开关与默认档位", () => { + const workspace = createModelWorkspace(null); + const candidate: ModelCandidate = { + id: "gpt-54", + source: "byok", + provider: "openai", + model: "gpt-5.4", + displayName: "gpt-5.4", + connectionId: null, + endpointId: "chat", + capability: "chat", + capabilities: ["agent"], + available: true + }; + + expect(thinkingPayloadForSend(workspace, candidate, undefined)).toEqual({ + thinkingEnabled: true, + thinkingLevel: "medium" + }); + expect(thinkingPayloadForSend(workspace, candidate, { enabled: false })).toEqual({ + thinkingEnabled: false, + thinkingLevel: null + }); + expect(thinkingPayloadForSend(workspace, candidate, { enabled: true, level: "high" })).toEqual({ + thinkingEnabled: true, + thinkingLevel: "high" + }); + expect(thinkingPayloadForSend(workspace, { ...candidate, model: "unlisted-model" }, undefined)).toEqual({}); + }); + it("hides the agent status line after the websocket is connected", () => { expect(agentStatusText("connected", "agent_chat", (key, values) => `${key}:${values?.model ?? ""}`)).toBeNull(); expect(agentStatusText("connecting", null, (key) => key)).toBe("home.agent.connecting"); diff --git a/App/frontend/desktop/src/pages/tests/model-config.test.ts b/App/frontend/desktop/src/pages/tests/model-config.test.ts index c4f6df998..7df7ea6cd 100644 --- a/App/frontend/desktop/src/pages/tests/model-config.test.ts +++ b/App/frontend/desktop/src/pages/tests/model-config.test.ts @@ -30,7 +30,7 @@ import { import type { TextModelProviderConfig } from "../../api/config-client.js"; import { modelProviderLogoUrl } from "../../components/model-provider-logo.js"; import { canSaveModelConfig, createModelConfigValidationKey, type ModelConfigValidationState } from "../model-config-validation.js"; -import { zhCNMessages } from "../../i18n/messages.js"; +import { zhCNMessages, enUSMessages } from "../../i18n/messages.js"; describe("model config helpers", () => { it("严格映射并过滤桌面不支持或没有模型的 Provider", () => { @@ -530,6 +530,33 @@ describe("model config helpers", () => { expect(zhCNMessages["apiKey.provider.xiaomi"]).toBe("小米 MiMo"); }); + it("OpenAI Completions/Responses 与 Custom 作为文本 Provider 可选且默认值完整", () => { + const options = PROTOCOL_OPTIONS.map((option) => option.value); + expect(options).toContain("openai"); + expect(options).toContain("openai_responses"); + expect(options).toContain("custom"); + expect(PROTOCOL_OPTIONS.find((option) => option.value === "openai")?.labelKey) + .toBe("apiKey.provider.openaiCompletions"); + expect(IMAGE_PROTOCOL_OPTIONS.find((option) => option.value === "openai")?.labelKey) + .toBe("apiKey.provider.openai"); + + expect(DEFAULT_ENDPOINTS.openai_responses).toBe("https://api.openai.com/v1"); + expect(DEFAULT_ENDPOINTS.custom).toBe(""); + expect(DEFAULT_MODEL_IDS.openai_responses).toBe("gpt-4o"); + expect(DEFAULT_MODEL_IDS.custom).toBe(""); + expect(toProtocol("openai_responses")).toBe("openai_responses"); + expect(fromProtocol("openai_responses")).toBe("openai_responses"); + expect(toProtocol("custom")).toBe("custom"); + expect(fromProtocol("custom")).toBe("custom"); + expect(zhCNMessages["apiKey.provider.openaiCompletions"]).toBe("OpenAI Completions"); + expect(zhCNMessages["apiKey.provider.openaiResponses"]).toBe("OpenAI Responses"); + expect(zhCNMessages["apiKey.provider.custom"]).toBe("Custom"); + }); + + it("中英文字典键集合一致", () => { + expect(Object.keys(enUSMessages).sort()).toEqual(Object.keys(zhCNMessages).sort()); + }); + it("为阶跃星辰和小米 MiMo 解析出品牌 logo", () => { expect(modelProviderLogoUrl("stepfun")).toMatch(/^data:image\/svg\+xml/); expect(modelProviderLogoUrl("xiaomi")).toMatch(/^data:image\/svg\+xml/); diff --git a/App/frontend/desktop/src/pages/tests/model-workspace-section.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/model-workspace-section.interaction.test.tsx index d201bb161..12f735264 100644 --- a/App/frontend/desktop/src/pages/tests/model-workspace-section.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/tests/model-workspace-section.interaction.test.tsx @@ -157,7 +157,7 @@ describe("ModelWorkspaceSection BYOK connection deletion", () => { .toEqual([ "Memmy Platform 本地 Embedding", "Memmy Platform 云端 Embedding", - "OpenAI 兼容 · text-embedding-3-small" + "OpenAI Completions · text-embedding-3-small" ]); }); diff --git a/App/frontend/desktop/src/pages/tests/model-workspace-task-picker-layout.test.tsx b/App/frontend/desktop/src/pages/tests/model-workspace-task-picker-layout.test.tsx index 0fc9fda7f..5739e1517 100644 --- a/App/frontend/desktop/src/pages/tests/model-workspace-task-picker-layout.test.tsx +++ b/App/frontend/desktop/src/pages/tests/model-workspace-task-picker-layout.test.tsx @@ -38,7 +38,7 @@ describe("model workspace task picker layout", () => { const row = [...container.querySelectorAll(".task-model-picker__option")] .find((option) => option.textContent?.includes("gpt-5.4") - && option.textContent.includes("OpenAI 兼容") + && option.textContent.includes("OpenAI Completions") && option.textContent.includes("默认")); if (!row) throw new Error("Missing default custom-model row"); const choiceButton = row.querySelector('button[role="checkbox"]'); diff --git a/App/frontend/desktop/src/pages/tests/settings-page.test.tsx b/App/frontend/desktop/src/pages/tests/settings-page.test.tsx index efd7be2da..191982184 100644 --- a/App/frontend/desktop/src/pages/tests/settings-page.test.tsx +++ b/App/frontend/desktop/src/pages/tests/settings-page.test.tsx @@ -28,6 +28,7 @@ import { import { formatMessage, zhCNMessages } from "../../i18n/messages.js"; import { availableConnectionProtocols, + catalogProviderForEditor, editorProtocolForCapabilities, modelCapabilitiesForKind, normalizeEditorCapabilities, @@ -75,6 +76,7 @@ describe("多 BYOK endpoint 入口", () => { endpointId: "openai-chat", endpoint: "https://api.openai.com/v1", protocol: "openai-chat-completions", + region: "", apiKeyMasked: "••••1234", models: ["gpt-4o"], modelEntries: [{ presetId: "preset-openai", model: "gpt-4o", capability: "chat", capabilities: ["agent"] }], @@ -89,6 +91,7 @@ describe("多 BYOK endpoint 入口", () => { endpointId: "anthropic-chat", endpoint: "https://api.anthropic.com", protocol: "anthropic-messages", + region: "", apiKeyMasked: "••••5678", models: ["claude-sonnet-4"], modelEntries: [{ presetId: "preset-anthropic", model: "claude-sonnet-4", capability: "chat", capabilities: ["agent"] }], @@ -100,11 +103,24 @@ describe("多 BYOK endpoint 入口", () => { ]); expect(available).toContain("openai"); + expect(available).toContain("openai_responses"); + expect(available).toContain("custom"); expect(available).toContain("anthropic"); expect(available[0]).toBe("openai"); }); }); +describe("Custom 与 OpenAI Responses 的 catalog provider 推导", () => { + it("Custom 按底层协议落到 openai/anthropic/bedrock,而不是 custom", () => { + expect(catalogProviderForEditor("custom", "openai-chat-completions")).toBe("openai"); + expect(catalogProviderForEditor("custom", "openai-responses")).toBe("openai"); + expect(catalogProviderForEditor("custom", "anthropic-messages")).toBe("anthropic"); + expect(catalogProviderForEditor("custom", "bedrock-converse")).toBe("bedrock"); + expect(catalogProviderForEditor("openai_responses", null)).toBe("openai"); + expect(catalogProviderForEditor("moonshot", null)).toBe("moonshot"); + }); +}); + describe("自定义模型能力选择", () => { it("四种单选类型映射为对应的底层能力", () => { expect(modelCapabilitiesForKind("text")).toEqual(["chat", "memorySummary", "memoryEvolution"]); @@ -196,7 +212,8 @@ describe("自定义模型能力选择", () => { expect(source).toContain('className="select-control--subtle model-capability-select"'); expect(source.match(/t\("settings\.modelWorkspace\.modelCapability"\)/g)).toHaveLength(1); expect(source).toContain("normalizeEditorCapabilities(entry.capabilities.map(fromCatalogCapability))"); - expect(source).not.toContain('type="checkbox"'); + expect(source).toContain("checked={editor.customThinkingEnabled}"); + expect(source).toContain("checked={editor.customImageInput}"); expect(source).not.toContain('t("settings.modelWorkspace.textRoles")'); expect(source).not.toContain('t("settings.modelWorkspace.capability.agent")'); }); diff --git a/App/frontend/desktop/src/state/agent-chat-slice.ts b/App/frontend/desktop/src/state/agent-chat-slice.ts index 2666ef9be..f41ac0a70 100644 --- a/App/frontend/desktop/src/state/agent-chat-slice.ts +++ b/App/frontend/desktop/src/state/agent-chat-slice.ts @@ -42,6 +42,13 @@ import { type AgentToolProgressEvent } from "./agent-tool-traces.js"; +export interface AgentThinkingState { + enabled?: boolean; + level?: string; +} + +export const NEW_TASK_MODEL_SCOPE_KEY = "draft-new-task"; + export type AgentConnectionStatus = "idle" | "bootstrapping" | "connecting" | "connected" | "reconnecting" | "error"; export type AgentOperationSurface = "chat" | "sidebar"; export type AgentOperationErrorSource = "sessions" | "sidebar" | "history" | "new-chat" | "send" | "gateway-command" | "recovery" | "queue"; @@ -191,6 +198,7 @@ export interface AgentState { chatId: string | null; presetId: string | null; }>; + thinkingStateByScope: Record; chatViewVisible: boolean; currentChatId: string | null; currentSessionKey: string | null; @@ -264,6 +272,8 @@ export type AgentAction = | { type: "agent/pendingModelPresetCleared"; scopeKey: string } | { type: "agent/modelSelectionRequestStarted"; scopeKey: string; chatId: string | null; clientRequestId: string; presetId: string | null } | { type: "agent/modelSelectionRequestCancelled"; clientRequestId: string } + | { type: "agent/thinkingToggled"; scopeKey: string; enabled: boolean } + | { type: "agent/thinkingLevelChanged"; scopeKey: string; level: string } | { type: "agent/connectionConnecting" } | { type: "agent/connectionFailed"; message: string } | { type: "agent/connectionDisposed" } @@ -353,6 +363,7 @@ export const initialAgentState: AgentState = { committedModelSelectionByScope: {}, pendingPresetByScope: {}, pendingModelCommitByRequestId: {}, + thinkingStateByScope: {}, chatViewVisible: false, currentChatId: null, currentSessionKey: null, @@ -458,6 +469,28 @@ export function agentReducer(state: AgentState, action: AgentAction): AgentState action.clientRequestId ) }; + case "agent/thinkingToggled": + return { + ...state, + thinkingStateByScope: { + ...state.thinkingStateByScope, + [action.scopeKey]: { + ...state.thinkingStateByScope[action.scopeKey], + enabled: action.enabled + } + } + }; + case "agent/thinkingLevelChanged": + return { + ...state, + thinkingStateByScope: { + ...state.thinkingStateByScope, + [action.scopeKey]: { + ...state.thinkingStateByScope[action.scopeKey], + level: action.level + } + } + }; case "agent/connectionConnecting": return { ...state, connectionStatus: "connecting", connectionError: null }; case "agent/connectionFailed": @@ -1562,9 +1595,17 @@ function completeSessionsLoad(state: AgentState, sessions: MemmyAgentSessionSumm const canonicalChatIds = new Set(sessions.map((session) => sessionKeyToChatId(session.key))); const committedModelSelectionByScope = { ...state.committedModelSelectionByScope }; + const thinkingStateByScope = { ...state.thinkingStateByScope }; for (const session of sessions) { const chatId = sessionKeyToChatId(session.key); committedModelSelectionByScope[chatId] = session.model_selection ?? null; + const restored: AgentThinkingState = { + ...(typeof session.thinking_enabled === "boolean" ? { enabled: session.thinking_enabled } : {}), + ...(typeof session.thinking_level === "string" ? { level: session.thinking_level } : {}) + }; + if (Object.keys(restored).length) { + thinkingStateByScope[chatId] = { ...thinkingStateByScope[chatId], ...restored }; + } } const optimisticTasksByChatId = pruneOptimisticTasks(state.optimisticTasksByChatId, canonicalChatIds); const queuedMessagesByChatId = pruneChatMap( @@ -1599,6 +1640,10 @@ function completeSessionsLoad(state: AgentState, sessions: MemmyAgentSessionSumm ...state, sessions, committedModelSelectionByScope, + thinkingStateByScope: pruneChatMap( + thinkingStateByScope, + new Set([...canonicalChatIds, NEW_TASK_MODEL_SCOPE_KEY]) + ), optimisticTasksByChatId, queuedMessagesByChatId, queueRevisionByChatId, diff --git a/App/frontend/desktop/src/state/app-actions.ts b/App/frontend/desktop/src/state/app-actions.ts index 3de3f5dcd..4d63d178c 100644 --- a/App/frontend/desktop/src/state/app-actions.ts +++ b/App/frontend/desktop/src/state/app-actions.ts @@ -262,6 +262,14 @@ export const agentActions = { return { type: "agent/pendingModelPresetCleared", scopeKey }; }, + thinkingToggled(scopeKey: string, enabled: boolean): AppAction { + return { type: "agent/thinkingToggled", scopeKey, enabled }; + }, + + thinkingLevelChanged(scopeKey: string, level: string): AppAction { + return { type: "agent/thinkingLevelChanged", scopeKey, level }; + }, + modelSelectionRequestStarted( scopeKey: string, chatId: string | null, diff --git a/App/frontend/desktop/src/state/model-workspace.ts b/App/frontend/desktop/src/state/model-workspace.ts index 6b39d8a12..a3558bc77 100644 --- a/App/frontend/desktop/src/state/model-workspace.ts +++ b/App/frontend/desktop/src/state/model-workspace.ts @@ -1,6 +1,7 @@ /** Pure view-model helpers for the canonical model catalog returned by the local API. */ import { BUILTIN_LOCAL_EMBEDDING_ASSIGNMENT_ID, + getModelThinkingConfig, type CatalogEndpointInput, type CatalogProviderId, type ModelAssignment, @@ -8,6 +9,9 @@ import { type ModelConfigInput, type ModelConfigView, type ModelEndpointProtocol, + type ModelInputModality, + type ModelThinkingConfig, + type ModelThinkingConfigDto, type TextModelItemView } from "@memmy/local-api-contracts"; import { CLIENT_PRESET_ID_PREFIX, type ModelProviderConfig } from "../api/config-client.js"; @@ -31,6 +35,7 @@ export interface ModelConnection { endpointId: string; endpoint: string; protocol: ModelEndpointProtocol; + region: string; apiKeyMasked: string; models: string[]; modelEntries: ModelConnectionModel[]; @@ -45,6 +50,9 @@ export interface ModelConnectionModel { model: string; capability: ModelCapability; capabilities: CatalogCapability[]; + thinking?: ModelThinkingConfigDto; + custom?: boolean; + inputModalities?: ModelInputModality[]; } export interface ModelWorkspaceSpace { @@ -78,6 +86,7 @@ export interface ModelConnectionInput { endpointId?: string; endpoint: string; protocol?: ModelEndpointProtocol; + region?: string; apiKey?: string; apiKeyMasked?: string; models: string[]; @@ -86,6 +95,9 @@ export interface ModelConnectionInput { model: string; capability: ModelCapability; capabilities?: ModelCapability[]; + thinking?: ModelThinkingConfigDto | null; + custom?: boolean; + inputModalities?: ModelInputModality[]; }>; modelCapabilities?: Record; } @@ -183,7 +195,10 @@ export function modelConfigInputFromView(view: ModelConfigView): ModelConfigInpu model: model.model, source: model.source, ...(model.ownerAccountId ? { ownerAccountId: model.ownerAccountId } : {}), - capabilities: [...model.capabilities] + capabilities: [...model.capabilities], + ...(model.thinking ? { thinking: model.thinking } : {}), + ...(model.custom ? { custom: true } : {}), + ...(model.inputModalities ? { inputModalities: [...model.inputModalities] } : {}) })) })), modelAssignments: cloneAssignments(view.modelAssignments) @@ -379,12 +394,23 @@ export function upsertModelConnection( ): ModelWorkspaceMutationResult { const providerId = normalizeProvider(input.provider); const endpoint = input.endpoint.trim().replace(/\/+$/, ""); - const entries: Array<{ presetId?: string; model: string; capabilities: ModelCapability[]; capabilitiesExplicit: boolean }> = input.modelEntries?.length + const entries: Array<{ + presetId?: string; + model: string; + capabilities: ModelCapability[]; + capabilitiesExplicit: boolean; + thinking?: ModelThinkingConfigDto | null; + custom?: boolean; + inputModalities?: ModelInputModality[]; + }> = input.modelEntries?.length ? input.modelEntries.map((entry) => ({ ...(entry.presetId ? { presetId: entry.presetId } : {}), model: entry.model.trim(), capabilities: unique(entry.capabilities?.length ? entry.capabilities : [entry.capability]), capabilitiesExplicit: Boolean(entry.capabilities?.length), + ...(entry.thinking !== undefined ? { thinking: entry.thinking } : {}), + ...(entry.custom ? { custom: true } : {}), + ...(entry.inputModalities ? { inputModalities: entry.inputModalities } : {}) })).filter((entry) => Boolean(entry.model)) : input.models.map((model) => ({ model: model.trim(), @@ -453,7 +479,8 @@ export function upsertModelConnection( protocol, hasApiKey: Boolean(input.apiKey || input.apiKeyMasked), apiKeyMasked: input.apiKey ? maskApiKey(input.apiKey) : input.apiKeyMasked ?? "", - apiKey: input.apiKey ?? "" + apiKey: input.apiKey ?? "", + ...(input.region ? { region: input.region } : {}) }); provider.models.push(...entries.map((entry, index) => { const model = entry.model; @@ -476,7 +503,10 @@ export function upsertModelConnection( model, source: "byok" as const, capabilities, - available: true + available: true, + ...(entry.thinking ? { thinking: entry.thinking } : {}), + ...(entry.custom ? { custom: true } : {}), + ...(entry.inputModalities ? { inputModalities: [...entry.inputModalities] } : {}) }; })); @@ -658,13 +688,17 @@ function createSpace(catalog: ModelConfigView, mode: ModelWorkspaceMode): ModelW endpointId: endpoint.endpointId, endpoint: endpoint.apiBase, protocol: endpoint.protocol, + region: endpoint.region ?? "", apiKeyMasked: endpoint.apiKeyMasked || provider.apiKeyMasked, models: models.map((model) => model.model), modelEntries: models.map((model) => ({ presetId: model.presetId, model: model.model, capability: fromCapabilities(model.capabilities), - capabilities: [...model.capabilities] + capabilities: [...model.capabilities], + ...(model.thinking ? { thinking: model.thinking } : {}), + ...(model.custom ? { custom: true } : {}), + ...(model.inputModalities ? { inputModalities: [...model.inputModalities] } : {}) })), modelCapabilities: Object.fromEntries(models.map((model) => [model.model, fromCapabilities(model.capabilities)])), presetIds: Object.fromEntries(models.map((model) => [model.model, model.presetId])), @@ -708,7 +742,8 @@ function endpointInput(endpoint: ModelConfigView["providers"][number]["endpoints endpointId: endpoint.endpointId, apiBase: endpoint.apiBase, protocol: endpoint.protocol, - ...(endpoint.apiKey ? { apiKey: endpoint.apiKey } : {}) + ...(endpoint.apiKey ? { apiKey: endpoint.apiKey } : {}), + ...(endpoint.region ? { region: endpoint.region } : {}) }; } @@ -741,9 +776,16 @@ function cloneAssignment | Mod function normalizeProvider(provider: string): CatalogProviderId | null { const normalized = provider.trim().toLowerCase(); - const aliases: Record = { qwen: "dashscope", kimi: "moonshot", baidu: "qianfan", doubao: "volcengine", xiaomi: "xiaomi_mimo" }; + const aliases: Record = { + qwen: "dashscope", + kimi: "moonshot", + baidu: "qianfan", + doubao: "volcengine", + xiaomi: "xiaomi_mimo", + openai_responses: "openai" + }; const candidate = aliases[normalized] ?? normalized; - return ["openai", "anthropic", "gemini", "deepseek", "zhipu", "dashscope", "moonshot", "minimax", "qianfan", "volcengine", "stepfun", "xiaomi_mimo", "memmy_account"].includes(candidate) + return ["openai", "anthropic", "gemini", "deepseek", "zhipu", "dashscope", "moonshot", "minimax", "qianfan", "volcengine", "stepfun", "xiaomi_mimo", "memmy_account", "bedrock"].includes(candidate) ? candidate as CatalogProviderId : null; } @@ -755,6 +797,7 @@ function protocolFor(provider: CatalogProviderId, capability: ModelCapability): if (provider === "anthropic") return "anthropic-messages"; if (provider === "gemini") return "gemini-generate-content"; if (provider === "memmy_account") return "memmy-account"; + if (provider === "bedrock") return "bedrock-converse"; return "openai-chat-completions"; } @@ -906,3 +949,37 @@ function newClientPresetId(): string { const suffix = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`; return `${CLIENT_PRESET_ID_PREFIX}${suffix}`; } + +function modelEntryFor( + workspace: ModelWorkspace, + candidate: ModelCandidate | null | undefined +) { + if (!candidate || candidate.source !== "byok") return undefined; + const connection = workspace.spaces.byok.connections + .find((item) => item.id === candidate.connectionId); + return connection?.modelEntries.find((item) => item.model === candidate.model); +} + +export function isCustomModelEntry( + workspace: ModelWorkspace, + candidate: ModelCandidate | null | undefined +): boolean { + return modelEntryFor(workspace, candidate)?.custom === true; +} + +export function customThinkingConfigFor( + workspace: ModelWorkspace, + candidate: ModelCandidate | null | undefined +): ModelThinkingConfig | null { + return modelEntryFor(workspace, candidate)?.thinking ?? null; +} + +export function resolveThinkingConfigForModel( + workspace: ModelWorkspace, + candidate: ModelCandidate | null | undefined +): ModelThinkingConfig | null { + if (isCustomModelEntry(workspace, candidate)) { + return customThinkingConfigFor(workspace, candidate); + } + return getModelThinkingConfig(candidate?.model); +} diff --git a/App/frontend/desktop/src/state/tests/agent-chat-slice.test.ts b/App/frontend/desktop/src/state/tests/agent-chat-slice.test.ts index 65bbe4eb3..ac3b43210 100644 --- a/App/frontend/desktop/src/state/tests/agent-chat-slice.test.ts +++ b/App/frontend/desktop/src/state/tests/agent-chat-slice.test.ts @@ -7,6 +7,7 @@ import { buildAgentTasks, defaultAgentSidebarState, initialAgentState, + NEW_TASK_MODEL_SCOPE_KEY, selectedModelPresetForScope, type AgentState, updateSidebarStateForTask @@ -5628,6 +5629,42 @@ describe("agent chat slice", () => { expect(state.activeTurnIdByChatId["chat-1"]).toBe("turn-new"); expect(state.isSending).toBe(true); }); + + it("思考开关与档位按 session scope 写入,并在 sessions 加载时恢复", () => { + let state = agentReducer(initialAgentState, { + type: "agent/thinkingToggled", + scopeKey: "chat-1", + enabled: false + }); + state = agentReducer(state, { + type: "agent/thinkingLevelChanged", + scopeKey: "chat-1", + level: "low" + }); + state = agentReducer(state, { + type: "agent/thinkingToggled", + scopeKey: NEW_TASK_MODEL_SCOPE_KEY, + enabled: true + }); + + expect(state.thinkingStateByScope["chat-1"]).toEqual({ enabled: false, level: "low" }); + expect(state.thinkingStateByScope[NEW_TASK_MODEL_SCOPE_KEY]).toEqual({ enabled: true }); + + state = agentReducer(state, { + type: "agent/sessionsLoaded", + sessions: [ + { + ...sessions[0]!, + thinking_enabled: true, + thinking_level: "high" + } + ] + }); + + expect(state.thinkingStateByScope["chat-1"]).toEqual({ enabled: true, level: "high" }); + expect(state.thinkingStateByScope[NEW_TASK_MODEL_SCOPE_KEY]).toEqual({ enabled: true }); + expect(state.thinkingStateByScope["chat-2"]).toBeUndefined(); + }); }); function recoveryStateWithPendingMessage(content: string): AgentState { diff --git a/App/frontend/desktop/src/state/tests/model-workspace.test.ts b/App/frontend/desktop/src/state/tests/model-workspace.test.ts index 0e455923e..37a93ada8 100644 --- a/App/frontend/desktop/src/state/tests/model-workspace.test.ts +++ b/App/frontend/desktop/src/state/tests/model-workspace.test.ts @@ -29,7 +29,9 @@ import { setModelAssignment, setTaskModelCandidates, upsertByokPreset, - upsertModelConnection + upsertModelConnection, + isCustomModelEntry, + resolveThinkingConfigForModel } from "../model-workspace.js"; const fixtureRoots: string[] = []; @@ -1085,4 +1087,57 @@ describe("canonical model workspace adapter", () => { expect(serialized).not.toContain("maxTokens"); expect(serialized).not.toContain("dailyTokenLimit"); }); + + it("Custom 连接保存思考配置、图片声明与 region,重开后仍在", async () => { + const file = catalogFixture(); + const empty = await readModelConfigCatalog(file); + const created = upsertModelConnection(createModelWorkspace(empty), "byok", { + provider: "bedrock", + endpoint: "https://bedrock-runtime.us-west-2.amazonaws.com", + protocol: "bedrock-converse", + region: "us-west-2", + apiKey: "sk-bedrock", + models: ["anthropic.claude-sonnet-5"], + modelEntries: [{ + model: "anthropic.claude-sonnet-5", + capability: "chat", + capabilities: ["chat", "memorySummary", "memoryEvolution"], + custom: true, + thinking: { + switchable: true, + defaultEnabled: true, + levels: ["low", "medium", "high"], + defaultLevel: "medium" + }, + inputModalities: ["text", "image"] + }] + }); + expect(created.error).toBeNull(); + + const saved = await persistModelCatalogMutation(modelConfigInput(created.workspace), { + read: () => readModelConfigCatalog(file), + write: (input) => writeModelConfigCatalog(file, input) + }); + const reloaded = createModelWorkspace(saved); + const connection = reloaded.spaces.byok.connections.find((item) => item.provider === "bedrock")!; + const entry = connection.modelEntries[0]!; + const candidate = getTaskModelCandidates(reloaded, "byok").find((item) => item.model === "anthropic.claude-sonnet-5")!; + + expect(connection.protocol).toBe("bedrock-converse"); + expect(connection.region).toBe("us-west-2"); + expect(entry.custom).toBe(true); + expect(entry.thinking).toEqual({ + switchable: true, + defaultEnabled: true, + levels: ["low", "medium", "high"], + defaultLevel: "medium" + }); + expect(entry.inputModalities).toEqual(["text", "image"]); + expect(isCustomModelEntry(reloaded, candidate)).toBe(true); + expect(resolveThinkingConfigForModel(reloaded, candidate)).toEqual(entry.thinking); + expect(saved.providers.find((provider) => provider.provider === "bedrock")?.models[0]).toMatchObject({ + custom: true, + inputModalities: ["text", "image"] + }); + }); }); diff --git a/App/frontend/desktop/src/styles.css b/App/frontend/desktop/src/styles.css index 375ca9c0f..6ec3764ec 100644 --- a/App/frontend/desktop/src/styles.css +++ b/App/frontend/desktop/src/styles.css @@ -861,6 +861,172 @@ body.memmy-window-fullscreen { transition: background var(--duration-fast), color var(--duration-fast); } +.agent-model-selector-with-thinking { + display: flex; + align-items: center; + gap: 8px; +} + +.agent-thinking-control { + display: flex; + align-items: center; + gap: 6px; +} + +.thinking-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + height: 32px; + padding: 0 8px; + border: 0; + border-radius: 9px; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); + cursor: pointer; + font-size: 13px; + font-weight: 500; +} + +.thinking-toggle:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-canvas-oat) 64%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 78%, transparent); +} + +.thinking-toggle:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.thinking-toggle--on { + color: var(--color-action-sky); +} + +.thinking-toggle__switch { + position: relative; + width: 28px; + height: 16px; + background: color-mix(in srgb, var(--color-text-ink) 18%, transparent); + border-radius: 999px; +} + +.thinking-toggle__switch--on { + background: var(--color-action-sky); +} + +.thinking-toggle__switch-thumb { + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + background: white; + border-radius: 50%; + transition: transform var(--duration-fast); +} + +.thinking-toggle__switch--on .thinking-toggle__switch-thumb { + transform: translateX(12px); +} + +.thinking-level-trigger-container { + position: relative; +} + +.thinking-level-trigger { + display: inline-flex; + align-items: center; + gap: 4px; + height: 32px; + padding: 0 8px; + border: 0; + border-radius: 9px; + background: transparent; + color: color-mix(in srgb, var(--color-text-ink) 48%, transparent); + cursor: pointer; + font-size: 13px; +} + +.thinking-level-trigger:hover:not(:disabled) { + background: color-mix(in srgb, var(--color-canvas-oat) 64%, transparent); + color: color-mix(in srgb, var(--color-text-ink) 78%, transparent); +} + +.thinking-level-picker { + position: absolute; + right: 0; + bottom: calc(100% + 6px); + z-index: 130; + min-width: 160px; + background: var(--color-background-paper, #fff); + border: 1px solid color-mix(in srgb, var(--color-text-ink) 12%, transparent); + border-radius: 8px; + box-shadow: 0 8px 24px color-mix(in srgb, var(--color-text-ink) 12%, transparent); +} + +.thinking-level-picker__header { + padding: 8px 12px; + font-size: 12px; + font-weight: 600; + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); + border-bottom: 1px solid color-mix(in srgb, var(--color-text-ink) 10%, transparent); +} + +.thinking-level-option { + display: block; + width: 100%; + padding: 8px 12px; + border: 0; + background: none; + text-align: left; + cursor: pointer; + font-size: 13px; +} + +.thinking-level-option:hover { + background: color-mix(in srgb, var(--color-canvas-oat) 64%, transparent); +} + +.thinking-level-option--selected { + color: var(--color-action-sky); + font-weight: 500; +} + +.custom-thinking-config { + display: grid; + gap: 10px; + margin-top: 4px; + padding: 12px; + border: 1px solid color-mix(in srgb, var(--color-text-ink) 12%, transparent); + border-radius: 8px; +} + +.custom-thinking-config__label { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; +} + +.custom-thinking-levels { + display: grid; + gap: 6px; +} + +.custom-thinking-levels__input { + width: 100%; + padding: 8px 12px; + border: 1px solid color-mix(in srgb, var(--color-text-ink) 12%, transparent); + border-radius: 6px; + font-size: 13px; +} + +.custom-thinking-levels__hint { + margin: 0; + font-size: 12px; + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); +} + .agent-model-selector { position: relative; z-index: 50; diff --git a/App/memmy-agent/src/config/schema.ts b/App/memmy-agent/src/config/schema.ts index 9820534b8..8e85c5f18 100644 --- a/App/memmy-agent/src/config/schema.ts +++ b/App/memmy-agent/src/config/schema.ts @@ -97,7 +97,8 @@ export type ModelEndpointProtocol = | "dashscope-input-audio-chat" | "openai-images" | "dashscope-multimodal-generation" - | "memmy-account"; + | "memmy-account" + | "bedrock-converse"; const MODEL_CAPABILITIES = ["agent", "memory_summary", "memory_evolution", "embedding", "asr", "image_generation"] as const; const TEXT_GENERATION_CAPABILITIES: ReadonlySet = new Set([ @@ -106,7 +107,7 @@ const TEXT_GENERATION_CAPABILITIES: ReadonlySet = new Set([ const ENDPOINT_PROTOCOLS = [ "openai-chat-completions", "openai-responses", "anthropic-messages", "gemini-generate-content", "openai-embeddings", "dashscope-input-audio-chat", "openai-images", - "dashscope-multimodal-generation", "memmy-account", + "dashscope-multimodal-generation", "memmy-account", "bedrock-converse", ] as const; export class Base { @@ -234,6 +235,7 @@ export class ModelPresetConfig extends Base { contextWindowTokens = DEFAULT_CONTEXT_WINDOW_TOKENS; temperature = 0.7; reasoningEffort: string | null = null; + inputModalities: readonly string[] | null = null; constructor(init: Dict) { super(init); @@ -262,6 +264,9 @@ export class ModelPresetConfig extends Base { ); this.temperature = pick(init, ["temperature"], 0.7); this.reasoningEffort = pick(init, ["reasoningEffort"], null); + this.inputModalities = Array.isArray(init.inputModalities) + ? Object.freeze([...init.inputModalities.filter((item: unknown) => typeof item === "string")]) + : null; if (this.source === "account" && !optionalString(this.ownerAccountId)) { throw new ValueError("account modelPreset ownerAccountId is required"); } @@ -282,6 +287,7 @@ export class ModelPresetConfig extends Base { return omitUndefined({ ...this, ownerAccountId: this.ownerAccountId ?? undefined, + inputModalities: this.inputModalities ?? undefined, }); } } @@ -591,6 +597,7 @@ export class ProviderConfig extends Base { private chatEndpoint(): ModelEndpointConfig | null { return Object.values(this.endpoints).find((endpoint) => [ "openai-chat-completions", "openai-responses", "anthropic-messages", "gemini-generate-content", "memmy-account", + "bedrock-converse", ].includes(endpoint.protocol)) ?? null; } @@ -612,6 +619,8 @@ export class ModelEndpointConfig extends Base { apiKey: string | null; extraHeaders: Dict | null; extraBody: Dict | null; + region: string | null; + profile: string | null; constructor(init: Dict = {}) { for (const legacy of ["api_key", "api_base", "api_type", "extra_headers", "extra_body"]) { @@ -625,6 +634,8 @@ export class ModelEndpointConfig extends Base { this.apiKey = pick(init, ["apiKey"], null); this.extraHeaders = pick(init, ["extraHeaders"], null); this.extraBody = pick(init, ["extraBody"], null); + this.region = pick(init, ["region"], null); + this.profile = pick(init, ["profile"], null); } override toObject(): Dict { @@ -633,6 +644,8 @@ export class ModelEndpointConfig extends Base { apiKey: this.apiKey ?? undefined, extraHeaders: this.extraHeaders ?? undefined, extraBody: this.extraBody ?? undefined, + region: this.region ?? undefined, + profile: this.profile ?? undefined, }); } } @@ -1298,6 +1311,7 @@ export class Config extends Base { private validateModelCatalog(): void { const textProtocols = new Set([ "openai-chat-completions", "openai-responses", "anthropic-messages", "gemini-generate-content", "memmy-account", + "bedrock-converse", ]); const protocolsByCapability: Readonly>> = { agent: textProtocols, diff --git a/App/memmy-agent/src/core/agent-runtime/loop.ts b/App/memmy-agent/src/core/agent-runtime/loop.ts index 9b379fc5d..7863308c6 100644 --- a/App/memmy-agent/src/core/agent-runtime/loop.ts +++ b/App/memmy-agent/src/core/agent-runtime/loop.ts @@ -25,6 +25,7 @@ import { type ModelSelectionInput, type ResolvedModelSelection, } from "../../providers/model-catalog.js"; +import { getModelThinkingConfig } from "@memmy/local-api-contracts"; import type { ProviderErrorCategory } from "../../providers/provider-error-classifier.js"; type UserFacingModelErrorCategory = ProviderErrorCategory | "model_failed"; @@ -694,6 +695,27 @@ export class SessionWorkspaceError extends Error { } } +function thinkingFieldsFromMetadata(metadata: Record | null | undefined): Record { + return { + ...(typeof metadata?.thinking_enabled === "boolean" ? { thinking_enabled: metadata.thinking_enabled } : {}), + ...(typeof metadata?.thinking_level === "string" ? { thinking_level: metadata.thinking_level } : {}), + }; +} + +export function reasoningEffortFromTurnMetadata( + metadata: Record | null | undefined, + model: string | null | undefined, +): string | undefined { + if (typeof metadata?.thinking_enabled !== "boolean") return undefined; + const config = getModelThinkingConfig(model); + const enabled = config && !config.switchable ? true : metadata.thinking_enabled; + if (!enabled) return "none"; + if (typeof metadata.thinking_level === "string" && metadata.thinking_level.trim()) { + return metadata.thinking_level; + } + return "medium"; +} + export class AgentLoop { bus: MessageBus; config: Config; @@ -3554,7 +3576,9 @@ export class AgentLoop { maxIterationsFinalPrompt: renderTemplate("agent/max-iterations-final-response.md", { strip: true }), maxTokens: activeProvider?.generation?.maxTokens ?? this.config.agents.defaults.maxTokens, temperature: activeProvider?.generation?.temperature ?? this.config.agents.defaults.temperature, - reasoningEffort: activeProvider?.generation?.reasoningEffort ?? this.config.agents.defaults.reasoningEffort, + reasoningEffort: reasoningEffortFromTurnMetadata(metadata, activeModel) + ?? activeProvider?.generation?.reasoningEffort + ?? this.config.agents.defaults.reasoningEffort, maxToolResultChars: this.maxToolResultChars, toolResultMaxCharsByName: SESSION_TOOL_RESULT_MAX_CHARS_BY_NAME, workspace: sessionWorkspace, @@ -3714,6 +3738,7 @@ export class AgentLoop { model_provider: modelSelection.provider, model: modelSelection.model, model_selection: modelSelectionWire(modelSelection), + ...thinkingFieldsFromMetadata(msg.metadata), } : { ...(msg.metadata ?? {}) }, sessionKey: ctx.sessionKey, @@ -3738,6 +3763,7 @@ export class AgentLoop { } ctx.session.metadata.modelPreset = modelSelection.preset; ctx.session.metadata.modelSelection = persistedModelSelection(modelSelection); + Object.assign(ctx.session.metadata, thinkingFieldsFromMetadata(msg.metadata)); const projectedBinding = this.guiTranscriptMirror?.prepareSession( msg, ctx.session, @@ -4306,6 +4332,7 @@ export class AgentLoop { model_provider: modelSelection.provider, model: modelSelection.model, model_selection: modelSelectionWire(modelSelection), + ...thinkingFieldsFromMetadata(msg.metadata), } : { ...(msg.metadata ?? {}) }, sessionKey: key, @@ -4319,6 +4346,7 @@ export class AgentLoop { let session = existingSession ?? await this.getOrCreateSession(key); session.metadata.modelPreset = modelSelection.preset; session.metadata.modelSelection = persistedModelSelection(modelSelection); + Object.assign(session.metadata, thinkingFieldsFromMetadata(msg.metadata)); const sessionBinding = this.resolveSessionWorkspace( msg, session, diff --git a/App/memmy-agent/src/core/agent-runtime/runner.ts b/App/memmy-agent/src/core/agent-runtime/runner.ts index 7a28486c0..2233ded12 100644 --- a/App/memmy-agent/src/core/agent-runtime/runner.ts +++ b/App/memmy-agent/src/core/agent-runtime/runner.ts @@ -3,9 +3,9 @@ import path from "node:path"; import { LLMProvider, LLMResponse, ToolCallRequest } from "../../providers/base.js"; import { coversInputModalities, - getModelInputModalities, hasDeclaredInputModalities, requiredInputModalities, + resolveModelInputModalities, } from "../../providers/model-input-capabilities.js"; import type { ActualModelContext } from "@memmy/local-api-contracts"; import { CONTEXT_SAFETY_BUFFER_TOKENS } from "../../token-budget.js"; @@ -671,15 +671,18 @@ export class AgentRunner { const messagesForModel = this.prepareImageMessages(messages, imageTextState); const model = spec.actualModelContext?.model ?? spec.model; const required = requiredInputModalities(messagesForModel); - const supported = getModelInputModalities(model); + const declared = spec.actualModelContext?.inputModalities; + const supported = resolveModelInputModalities(model, declared); const missing = required.filter((modality) => !supported.includes(modality)); const canUseAccountFallback = missing.length === 1 && missing[0] === "image" && this.canRunAccountImageTextFallback(spec, null); // A BYOK model ID we have never reviewed may well be multimodal, and BYOK has no // image2text fallback to fall back on, so let the user's own endpoint answer rather - // than refusing the request here. + // than refusing the request here. Custom models always declare modalities, so a + // declaration is authoritative and must not defer. const deferToProvider = spec.actualModelContext?.source === "byok" + && !declared && !hasDeclaredInputModalities(model); let initialResponse: LLMResponse | null = null; diff --git a/App/memmy-agent/src/core/session/manager.ts b/App/memmy-agent/src/core/session/manager.ts index cbcc32dd1..715790d7c 100644 --- a/App/memmy-agent/src/core/session/manager.ts +++ b/App/memmy-agent/src/core/session/manager.ts @@ -106,6 +106,12 @@ function sessionSummary(session: Session, filePath: string, options: { repairPre ? session.metadata.modelPreset : null, model_selection: modelSelectionWire(session.metadata?.modelSelection), + thinking_enabled: typeof session.metadata?.thinking_enabled === "boolean" + ? session.metadata.thinking_enabled + : null, + thinking_level: typeof session.metadata?.thinking_level === "string" + ? session.metadata.thinking_level + : null, }; if (session.metadata?.[WEBUI_SESSION_METADATA_KEY] === true) { const binding = readWebuiSessionBinding(session); diff --git a/App/memmy-agent/src/integrations/channels/websocket.ts b/App/memmy-agent/src/integrations/channels/websocket.ts index efc4236f7..142c3a79b 100644 --- a/App/memmy-agent/src/integrations/channels/websocket.ts +++ b/App/memmy-agent/src/integrations/channels/websocket.ts @@ -227,6 +227,36 @@ type SessionDeletionServices = { }; export type WebuiLanguage = "zh-CN" | "en-US"; +export type WebuiMessageDigestInput = { + chatId: string; + content: string; + mediaPaths: string[]; + language: WebuiLanguage | null; + target: WebuiSessionTarget | null; + modelPreset: string | null | undefined; + queueSurface: "chat_composer" | null; + turnAdmission: "queue" | "steer"; + expectedTurnId: string | null; + thinkingEnabled?: boolean; + thinkingLevel?: string | null; +}; + +export function computeWebuiMessageDigest(input: WebuiMessageDigestInput): string { + return crypto.createHash("sha256").update(JSON.stringify({ + chat_id: input.chatId, + content: input.content, + media_paths: input.mediaPaths, + language: input.language, + target: input.target, + model_preset: input.modelPreset, + queue_surface: input.queueSurface, + turn_admission: input.turnAdmission, + expected_turn_id: input.expectedTurnId, + thinking_enabled: input.thinkingEnabled ?? null, + thinking_level: input.thinkingLevel ?? null, + })).digest("hex"); +} + const CHAT_ID_RE = /^[A-Za-z0-9_:-]{1,64}$/; const API_KEY_RE = /^[A-Za-z0-9_:.-]{1,128}$/; const WEBUI_LANGUAGE_VALUES = new Set(["zh-CN", "en-US"]); @@ -912,38 +942,8 @@ export class WebSocketChannel extends BaseChannel { return null; } - private webuiMessageDigest({ - chatId, - content, - mediaPaths, - language, - target, - modelPreset, - queueSurface, - turnAdmission, - expectedTurnId, - }: { - chatId: string; - content: string; - mediaPaths: string[]; - language: WebuiLanguage | null; - target: WebuiSessionTarget | null; - modelPreset: string | null | undefined; - queueSurface: "chat_composer" | null; - turnAdmission: "queue" | "steer"; - expectedTurnId: string | null; - }): string { - return crypto.createHash("sha256").update(JSON.stringify({ - chat_id: chatId, - content, - media_paths: mediaPaths, - language, - target, - model_preset: modelPreset, - queue_surface: queueSurface, - turn_admission: turnAdmission, - expected_turn_id: expectedTurnId, - })).digest("hex"); + private webuiMessageDigest(input: WebuiMessageDigestInput): string { + return computeWebuiMessageDigest(input); } private resolveMessageModel( @@ -3341,6 +3341,8 @@ export class WebSocketChannel extends BaseChannel { queueSurface, turnAdmission, expectedTurnId, + thinkingEnabled: typeof envelope.thinking_enabled === "boolean" ? envelope.thinking_enabled : undefined, + thinkingLevel: typeof envelope.thinking_level === "string" ? envelope.thinking_level : undefined, }) : null; if (existing && clientRequestId) { @@ -3449,6 +3451,8 @@ export class WebSocketChannel extends BaseChannel { model: modelSelection.model, model_selection: modelSelectionWire(modelSelection), }; + if (typeof envelope.thinking_enabled === "boolean") metadata.thinking_enabled = envelope.thinking_enabled; + if (typeof envelope.thinking_level === "string") metadata.thinking_level = envelope.thinking_level; if (clientRequestId && digest) { metadata.client_request_id = clientRequestId; metadata.webui_request_digest = digest; diff --git a/App/memmy-agent/src/providers/factory.ts b/App/memmy-agent/src/providers/factory.ts index 6946c32a2..85518bc39 100644 --- a/App/memmy-agent/src/providers/factory.ts +++ b/App/memmy-agent/src/providers/factory.ts @@ -98,6 +98,7 @@ function makeProviderCore( const model = opts.model ?? resolved.model; const providerName = config.getProviderName(model, { preset: resolved }); const providerConfig = config.getProvider(model, { preset: resolved }); + const endpoint = exactEndpoint(config, providerConfig, resolved); const spec = providerName ? findByName(providerName) : null; const backend = spec?.backend ?? "openai_compat"; const init = providerInit(config, providerConfig, spec, model, resolved); @@ -118,7 +119,11 @@ function makeProviderCore( else if (backend === "azure_openai") provider = new AzureOpenAIProvider(init); else if (backend === "github_copilot") provider = new GitHubCopilotProvider(init); else if (backend === "anthropic") provider = new AnthropicProvider(init); - else if (backend === "bedrock") provider = new BedrockProvider({ ...init, region: (providerConfig as any)?.region ?? null, profile: (providerConfig as any)?.profile ?? null }); + else if (backend === "bedrock") provider = new BedrockProvider({ + ...init, + region: endpoint?.region ?? (providerConfig as { region?: string | null })?.region ?? null, + profile: endpoint?.profile ?? (providerConfig as { profile?: string | null })?.profile ?? null, + }); else provider = new OpenAICompatProvider(init); provider.generation = resolved.toGenerationSettings(); diff --git a/App/memmy-agent/src/providers/model-catalog.ts b/App/memmy-agent/src/providers/model-catalog.ts index b34427479..82116cad2 100644 --- a/App/memmy-agent/src/providers/model-catalog.ts +++ b/App/memmy-agent/src/providers/model-catalog.ts @@ -165,6 +165,7 @@ export function persistedModelSelection( ownerAccountId: selection.ownerAccountId ?? null, capability: selection.capability, capabilities: Object.freeze([...selection.capabilities]), + ...(selection.inputModalities ? { inputModalities: Object.freeze([...selection.inputModalities]) } : {}), }); } @@ -244,6 +245,7 @@ function runtimeCatalog(config: Config): RuntimeModelCatalog { apiKey: endpoint.apiKey, extraHeaders: endpoint.extraHeaders, extraBody: endpoint.extraBody, + region: endpoint.region, }), ])), })]; @@ -257,6 +259,7 @@ function runtimeCatalog(config: Config): RuntimeModelCatalog { source: preset.source, ownerAccountId: preset.ownerAccountId, capabilities: [...preset.capabilities], + inputModalities: preset.inputModalities ?? undefined, }), ])), modelAssignments: { diff --git a/App/memmy-agent/src/providers/model-input-capabilities.ts b/App/memmy-agent/src/providers/model-input-capabilities.ts index 83e49587a..37075add1 100644 --- a/App/memmy-agent/src/providers/model-input-capabilities.ts +++ b/App/memmy-agent/src/providers/model-input-capabilities.ts @@ -367,6 +367,14 @@ export function hasDeclaredInputModalities(model: string | null | undefined): bo && Object.prototype.hasOwnProperty.call(MODEL_INPUT_CAPABILITIES, model); } +/** 声明优先于静态表:有声明用声明,无声明查表。 */ +export function resolveModelInputModalities( + model: string | null | undefined, + declared: readonly ModelInputModality[] | null | undefined, +): readonly ModelInputModality[] { + return declared ?? getModelInputModalities(model); +} + export function requiredInputModalities( messages: readonly Record[], ): readonly ModelInputModality[] { diff --git a/App/memmy-agent/tests/core/agent-runtime/runner-byok-image-input.test.ts b/App/memmy-agent/tests/core/agent-runtime/runner-byok-image-input.test.ts index 7ca833d47..5880c2820 100644 --- a/App/memmy-agent/tests/core/agent-runtime/runner-byok-image-input.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/runner-byok-image-input.test.ts @@ -118,4 +118,34 @@ describe("BYOK custom model image input", () => { expect(provider.calls).toHaveLength(0); expect(result.finalContent).toBe("Current model does not support image input."); }); + + it("blocks images when a Custom model declares text-only input", async () => { + const provider = new ByokProvider(); + + const result = await new AgentRunner(provider).run(new AgentRunSpec({ + initialMessages: [imageMessage()], + provider, + model: "Qwen3-27B", + actualModelContext: { ...byokContext("Qwen3-27B"), inputModalities: ["text"] }, + })); + + expect(provider.calls).toHaveLength(0); + expect(result.finalContent).toBe("Current model does not support image input."); + expect(result.response.errorCategory).toBe("image_input_unsupported"); + }); + + it("allows images when a Custom declaration includes image even if the catalog is text-only", async () => { + const provider = new ByokProvider(); + + const result = await new AgentRunner(provider).run(new AgentRunSpec({ + initialMessages: [imageMessage()], + provider, + model: "deepseek-v4-pro", + actualModelContext: { ...byokContext("deepseek-v4-pro"), inputModalities: ["text", "image"] }, + })); + + expect(result.finalContent).toBe("described the image"); + expect(provider.calls).toHaveLength(1); + expect(JSON.stringify(provider.calls[0].messages)).toContain('"type":"image_url"'); + }); }); diff --git a/App/memmy-agent/tests/core/agent-runtime/thinking-effort.test.ts b/App/memmy-agent/tests/core/agent-runtime/thinking-effort.test.ts new file mode 100644 index 000000000..1052542be --- /dev/null +++ b/App/memmy-agent/tests/core/agent-runtime/thinking-effort.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { reasoningEffortFromTurnMetadata } from "../../../src/core/agent-runtime/loop.js"; + +describe("reasoningEffortFromTurnMetadata", () => { + it("maps disabled thinking to none and missing level to medium", () => { + expect(reasoningEffortFromTurnMetadata({ thinking_enabled: false }, "gpt-5.4")).toBe("none"); + expect(reasoningEffortFromTurnMetadata({ thinking_enabled: true }, "gpt-5.4")).toBe("medium"); + expect(reasoningEffortFromTurnMetadata({ + thinking_enabled: true, + thinking_level: "high", + }, "gpt-5.4")).toBe("high"); + expect(reasoningEffortFromTurnMetadata({}, "gpt-5.4")).toBeUndefined(); + }); + + it("keeps always-on table models enabled even if the frame asked to disable", () => { + expect(reasoningEffortFromTurnMetadata({ + thinking_enabled: false, + thinking_level: "low", + }, "claude-fable-5")).toBe("low"); + }); +}); diff --git a/App/memmy-agent/tests/integrations/channels/webui-message-digest.test.ts b/App/memmy-agent/tests/integrations/channels/webui-message-digest.test.ts new file mode 100644 index 000000000..8dcd3a0d6 --- /dev/null +++ b/App/memmy-agent/tests/integrations/channels/webui-message-digest.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { computeWebuiMessageDigest } from "../../../src/integrations/channels/websocket.js"; + +const base = { + chatId: "chat-1", + content: "hello", + mediaPaths: [] as string[], + language: null, + target: null, + modelPreset: "fast", + queueSurface: "chat_composer" as const, + turnAdmission: "queue" as const, + expectedTurnId: null, +}; + +describe("webui message digest", () => { + it("changes when thinking fields differ for the same text", () => { + const none = computeWebuiMessageDigest({ + ...base, + thinkingEnabled: false, + }); + const medium = computeWebuiMessageDigest({ + ...base, + thinkingEnabled: true, + thinkingLevel: "medium", + }); + const high = computeWebuiMessageDigest({ + ...base, + thinkingEnabled: true, + thinkingLevel: "high", + }); + + expect(none).not.toBe(medium); + expect(medium).not.toBe(high); + expect(computeWebuiMessageDigest({ + ...base, + thinkingEnabled: true, + thinkingLevel: "medium", + })).toBe(medium); + }); +}); diff --git a/App/memmy-agent/tests/providers/bedrock-provider.test.ts b/App/memmy-agent/tests/providers/bedrock-provider.test.ts index 6771a6b0e..c35e2c91b 100644 --- a/App/memmy-agent/tests/providers/bedrock-provider.test.ts +++ b/App/memmy-agent/tests/providers/bedrock-provider.test.ts @@ -36,6 +36,41 @@ describe("Bedrock provider", () => { expect(findByName("bedrock")?.backend).toBe("bedrock"); }); + it("lifts endpoint region and profile into the Bedrock provider", () => { + const provider = makeProvider( + new Config({ + agents: { defaults: { modelPreset: "custom-bedrock" } }, + providers: { + bedrock: { + region: "us-east-1", + profile: "legacy", + endpoints: { + chat: { + apiBase: "https://bedrock-runtime.us-west-2.amazonaws.com", + protocol: "bedrock-converse", + region: "us-west-2", + profile: "work", + }, + }, + }, + }, + modelPresets: { + "custom-bedrock": { + endpoint: "chat", + model: "anthropic.claude-3-5-sonnet", + provider: "bedrock", + source: "byok", + capabilities: ["agent"], + }, + }, + }), + ) as BedrockProvider; + + expect(provider).toBeInstanceOf(BedrockProvider); + expect(provider.region).toBe("us-west-2"); + expect(provider.profile).toBe("work"); + }); + it("maps configured AWS profiles to SDK credential providers", async () => { const provider = new BedrockProvider({ region: "us-east-1", profile: "dev" }); const client = await provider.makeClient(); diff --git a/App/memmy-agent/tests/providers/model-input-capabilities.test.ts b/App/memmy-agent/tests/providers/model-input-capabilities.test.ts index 072e99ed7..063c12926 100644 --- a/App/memmy-agent/tests/providers/model-input-capabilities.test.ts +++ b/App/memmy-agent/tests/providers/model-input-capabilities.test.ts @@ -7,6 +7,7 @@ import { MODEL_INPUT_CAPABILITIES, MODEL_INPUT_CAPABILITIES_REVIEWED_AT, requiredInputModalities, + resolveModelInputModalities, type ModelInputModality, } from "../../src/providers/model-input-capabilities.js"; @@ -82,4 +83,11 @@ describe("model input capabilities", () => { expect(coversInputModalities(["text", "image"], imageRequired)).toBe(true); expect(coversInputModalities(["text"], imageRequired)).toBe(false); }); + + it("prefers an explicit modality declaration over the static catalog", () => { + expect(resolveModelInputModalities("deepseek-v4-pro", ["text", "image"])).toEqual(["text", "image"]); + expect(resolveModelInputModalities("gpt-5.6", ["text"])).toEqual(["text"]); + expect(resolveModelInputModalities("Qwen3-27B", undefined)).toEqual(["text"]); + expect(resolveModelInputModalities("gpt-5.6", null)).toEqual(["text", "image"]); + }); }); From 35c53cb25a3b638b8fc032a848c14d2adadddc8b Mon Sep 17 00:00:00 2001 From: Daoji Wang <627665797@qq.com> Date: Tue, 29 Sep 2026 15:57:56 +0800 Subject: [PATCH 03/85] Let memory summary and evolution call Bedrock Converse and OpenAI Responses. Settings can save those protocols for both memory roles, and the memory runtime now sends the matching request instead of rejecting the assignment or falling through to chat completions. Co-authored-by: Cursor --- .../src/model-catalog-resolver.ts | 1 + .../tests/model-catalog-resolver.test.ts | 11 +- .../memmy-config/model-config-catalog.ts | 10 +- .../tests/model-config-catalog.test.ts | 44 ++++- Memory/src/config/index.ts | 15 +- Memory/src/contracts/index.ts | 3 +- .../src/contracts/model-catalog-resolver.ts | 10 +- Memory/src/model/llm.ts | 58 +++++++ Memory/tests/llm-protocol-adapters.test.ts | 155 ++++++++++++++++++ 9 files changed, 288 insertions(+), 19 deletions(-) create mode 100644 Memory/tests/llm-protocol-adapters.test.ts diff --git a/App/backend/local-api-contracts/src/model-catalog-resolver.ts b/App/backend/local-api-contracts/src/model-catalog-resolver.ts index b813c3ace..af108ff8c 100644 --- a/App/backend/local-api-contracts/src/model-catalog-resolver.ts +++ b/App/backend/local-api-contracts/src/model-catalog-resolver.ts @@ -348,6 +348,7 @@ function protocolSupportsCapability( } if (capability === "memory_summary" || capability === "memory_evolution") { return protocol === "openai-chat-completions" + || protocol === "openai-responses" || protocol === "anthropic-messages" || protocol === "gemini-generate-content" || protocol === "bedrock-converse"; diff --git a/App/backend/local-api-contracts/tests/model-catalog-resolver.test.ts b/App/backend/local-api-contracts/tests/model-catalog-resolver.test.ts index 09ea02778..2a4dc0c13 100644 --- a/App/backend/local-api-contracts/tests/model-catalog-resolver.test.ts +++ b/App/backend/local-api-contracts/tests/model-catalog-resolver.test.ts @@ -216,17 +216,22 @@ describe("resolveAssignedModel", () => { })).toEqual({ ok: false, code: "model_selection_unavailable" }); }); - it("rejects Responses presets for Memory capabilities until a Responses adapter exists", () => { + it("resolves Responses presets for memory capabilities", () => { const current = catalog() as any; current.providers.dashscope.endpoints.asr.protocol = "openai-responses"; current.modelPresets["byok-asr"].capabilities = ["memory_summary"]; current.modelAssignments.byok = { memorySummary: "byok-asr" }; - expect(resolveAssignedModel({ + const resolved = resolveAssignedModel({ catalog: current, mode: "byok", capability: "memory_summary" - })).toEqual({ ok: false, code: "model_selection_unavailable" }); + }); + + expect(resolved.ok).toBe(true); + if (!resolved.ok) return; + expect(resolved.context.protocol).toBe("openai-responses"); + expect(resolved.context.capability).toBe("memory_summary"); }); it("resolves bedrock-converse and declared input modalities", () => { diff --git a/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts b/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts index 7f6d2dae3..0baa81d1f 100644 --- a/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts +++ b/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts @@ -28,8 +28,8 @@ type ConfigRecord = Record; const CAPABILITY_PROTOCOLS: Readonly>> = { agent: new Set(["openai-chat-completions", "openai-responses", "anthropic-messages", "gemini-generate-content", "memmy-account", "bedrock-converse"]), - memory_summary: new Set(["openai-chat-completions", "anthropic-messages", "gemini-generate-content", "memmy-account", "bedrock-converse"]), - memory_evolution: new Set(["openai-chat-completions", "anthropic-messages", "gemini-generate-content", "memmy-account", "bedrock-converse"]), + memory_summary: new Set(["openai-chat-completions", "openai-responses", "anthropic-messages", "gemini-generate-content", "memmy-account", "bedrock-converse"]), + memory_evolution: new Set(["openai-chat-completions", "openai-responses", "anthropic-messages", "gemini-generate-content", "memmy-account", "bedrock-converse"]), embedding: new Set(["openai-embeddings", "memmy-account"]), asr: new Set(["dashscope-input-audio-chat", "memmy-account"]), image_generation: new Set(["openai-images", "dashscope-multimodal-generation", "memmy-account"]) @@ -309,7 +309,7 @@ function memoryConnection(config: ConfigRecord, presetId: string): ConfigRecord const extraHeaders = { ...record(provider.extraHeaders), ...record(endpoint.extraHeaders) }; const extraBody = { ...record(provider.extraBody), ...record(endpoint.extraBody) }; return { - provider: memoryProvider(providerId), + provider: memoryProvider(providerId, stringValue(endpoint.protocol)), sourceProvider: providerId, endpoint: apiBase, model, @@ -319,7 +319,9 @@ function memoryConnection(config: ConfigRecord, presetId: string): ConfigRecord }; } -function memoryProvider(providerId: string): string { +function memoryProvider(providerId: string, protocol: string | undefined): string { + if (protocol === "bedrock-converse") return "bedrock"; + if (protocol === "openai-responses") return "openai_responses"; if (providerId === "anthropic") return "anthropic"; if (providerId === "gemini") return "gemini"; return "openai_compatible"; diff --git a/App/backend/src/infrastructure/memmy-config/tests/model-config-catalog.test.ts b/App/backend/src/infrastructure/memmy-config/tests/model-config-catalog.test.ts index 4515c5ab6..1ed55ca8a 100644 --- a/App/backend/src/infrastructure/memmy-config/tests/model-config-catalog.test.ts +++ b/App/backend/src/infrastructure/memmy-config/tests/model-config-catalog.test.ts @@ -516,13 +516,43 @@ describe("model config catalog", () => { wrongProtocol.providers[0]!.models[0]!.capabilities = ["embedding"]; await expect(writeModelConfigCatalog(file, wrongProtocol)).rejects.toThrow(/does not support capability embedding/); - const unsupportedMemoryResponses = openAiInput(revision); - unsupportedMemoryResponses.providers[0]!.endpoints[0]!.protocol = "openai-responses"; - unsupportedMemoryResponses.providers[0]!.models[0]!.capabilities = ["memory_summary"]; - await expect(writeModelConfigCatalog(file, unsupportedMemoryResponses)) - .rejects.toThrow(/does not support capability memory_summary/); - - const duplicateModel = openAiInput(revision); + const responsesMemory = openAiInput(revision); + responsesMemory.providers[0]!.endpoints[0]!.protocol = "openai-responses"; + responsesMemory.providers[0]!.models[0]!.capabilities = ["agent", "memory_summary", "memory_evolution"]; + const savedResponses = await writeModelConfigCatalog(file, responsesMemory); + const responsesPresetId = savedResponses.providers[0]!.models[0]!.presetId!; + const assignedResponses = openAiInput(savedResponses.configRevision, responsesPresetId); + assignedResponses.providers[0]!.endpoints[0]!.protocol = "openai-responses"; + assignedResponses.providers[0]!.models[0]!.capabilities = ["agent", "memory_summary", "memory_evolution"]; + assignedResponses.modelAssignments.byok.memoryEvolution = responsesPresetId; + await writeModelConfigCatalog(file, assignedResponses); + expect((YAML.parse(readFileSync(file, "utf8")) as any).memmyMemory.evolution.provider).toBe("openai_responses"); + + const bedrockMemory = openAiInput((await readModelConfigCatalog(file)).configRevision); + bedrockMemory.providers = [{ + provider: "bedrock", + endpoints: [{ + endpointId: "chat", + apiBase: "https://bedrock-runtime.us-west-2.amazonaws.com", + protocol: "bedrock-converse", + region: "us-west-2" + }], + models: [{ + endpointId: "chat", + model: "anthropic.claude-sonnet-5", + source: "byok", + capabilities: ["agent", "memory_summary", "memory_evolution"] + }] + }]; + const savedBedrock = await writeModelConfigCatalog(file, bedrockMemory); + const bedrockPresetId = savedBedrock.providers[0]!.models[0]!.presetId!; + bedrockMemory.configRevision = savedBedrock.configRevision; + bedrockMemory.providers[0]!.models[0]!.presetId = bedrockPresetId; + bedrockMemory.modelAssignments.byok.memorySummary = bedrockPresetId; + await writeModelConfigCatalog(file, bedrockMemory); + expect((YAML.parse(readFileSync(file, "utf8")) as any).memmyMemory.summary.provider).toBe("bedrock"); + + const duplicateModel = openAiInput((await readModelConfigCatalog(file)).configRevision); duplicateModel.providers[0]!.models.push({ endpointId: "chat", model: "gpt-5", diff --git a/Memory/src/config/index.ts b/Memory/src/config/index.ts index 85a96a4f2..6e9a98627 100644 --- a/Memory/src/config/index.ts +++ b/Memory/src/config/index.ts @@ -17,6 +17,7 @@ export type LlmProviderName = | "" | "local_only" | "openai_compatible" + | "openai_responses" | "gemini" | "anthropic" | "bedrock" @@ -841,7 +842,7 @@ function resolveAssignedLlm( if (!resolved.ok || !llmProtocolSupported(resolved.context.protocol)) { return unavailableLlm(defaults); } - const runtimeProvider = memoryLlmProvider(resolved.context.provider); + const runtimeProvider = memoryRuntimeProvider(resolved.context.provider, resolved.context.protocol); return { ...defaults, provider: runtimeProvider, @@ -887,6 +888,15 @@ function unavailableLlm(defaults: LlmConfig): Record { }; } +function memoryRuntimeProvider( + provider: string, + protocol: ActualModelContext["protocol"] +): LlmProviderName { + if (protocol === "bedrock-converse") return "bedrock"; + if (protocol === "openai-responses") return "openai_responses"; + return memoryLlmProvider(provider); +} + function memoryLlmProvider(provider: string): LlmProviderName { switch (provider) { case "anthropic": @@ -1094,8 +1104,10 @@ function runtimeAssignmentMode(rootConfig: Record): "account" | function llmProtocolSupported(protocol: ActualModelContext["protocol"]): boolean { return protocol === "openai-chat-completions" + || protocol === "openai-responses" || protocol === "anthropic-messages" || protocol === "gemini-generate-content" + || protocol === "bedrock-converse" || protocol === "memmy-account"; } @@ -1354,6 +1366,7 @@ function llmProvider(value: unknown, fallback: LlmProviderName): LlmProviderName provider === "" || provider === "local_only" || provider === "openai_compatible" || + provider === "openai_responses" || provider === "gemini" || provider === "anthropic" || provider === "bedrock" || diff --git a/Memory/src/contracts/index.ts b/Memory/src/contracts/index.ts index 766bea1b0..bf9475c4f 100644 --- a/Memory/src/contracts/index.ts +++ b/Memory/src/contracts/index.ts @@ -16,7 +16,8 @@ export type ModelEndpointProtocol = | "dashscope-input-audio-chat" | "openai-images" | "dashscope-multimodal-generation" - | "memmy-account"; + | "memmy-account" + | "bedrock-converse"; export * from "./memory-canonical-json.js"; export * from "./memory-workspace-identity.js"; diff --git a/Memory/src/contracts/model-catalog-resolver.ts b/Memory/src/contracts/model-catalog-resolver.ts index 289eadfad..d32efb5fb 100644 --- a/Memory/src/contracts/model-catalog-resolver.ts +++ b/Memory/src/contracts/model-catalog-resolver.ts @@ -118,7 +118,8 @@ const CAPABILITIES = new Set([ const PROTOCOLS = new Set([ "openai-chat-completions", "openai-responses", "anthropic-messages", "gemini-generate-content", "openai-embeddings", "dashscope-input-audio-chat", - "openai-images", "dashscope-multimodal-generation", "memmy-account" + "openai-images", "dashscope-multimodal-generation", "memmy-account", + "bedrock-converse" ]); /** Resolves one immutable current-catalog model assignment without guessing another preset or endpoint. */ @@ -332,12 +333,15 @@ function protocolSupportsCapability( return protocol === "openai-chat-completions" || protocol === "openai-responses" || protocol === "anthropic-messages" - || protocol === "gemini-generate-content"; + || protocol === "gemini-generate-content" + || protocol === "bedrock-converse"; } if (capability === "memory_summary" || capability === "memory_evolution") { return protocol === "openai-chat-completions" + || protocol === "openai-responses" || protocol === "anthropic-messages" - || protocol === "gemini-generate-content"; + || protocol === "gemini-generate-content" + || protocol === "bedrock-converse"; } if (capability === "embedding") return protocol === "openai-embeddings"; if (capability === "asr") return protocol === "dashscope-input-audio-chat"; diff --git a/Memory/src/model/llm.ts b/Memory/src/model/llm.ts index 19f046bd7..a2cc03f21 100644 --- a/Memory/src/model/llm.ts +++ b/Memory/src/model/llm.ts @@ -14,6 +14,15 @@ import type { LlmClient, LlmCompletionOptions, LlmMessage, ModelStatus } from ". const logger = createMemoryLogger("llm"); +interface OpenAiResponsesResponse { + status?: string; + output?: Array<{ + type?: string; + content?: Array<{ type?: string; text?: string }>; + }>; + usage?: Record; +} + interface OpenAiChatResponse { choices?: Array<{ message?: { @@ -299,6 +308,8 @@ class HttpLlmClient implements LlmClient { switch (this.config.provider) { case "openai_compatible": return this.completeOpenAiCompatible(messages, options); + case "openai_responses": + return this.completeOpenAiResponses(messages, options); case "gemini": return this.completeGemini(messages, options); case "anthropic": @@ -374,6 +385,46 @@ class HttpLlmClient implements LlmClient { return { text, finishReason: normalizeFinishReason(choice?.finish_reason) }; } + private async completeOpenAiResponses(messages: LlmMessage[], options: LlmCompletionOptions): Promise { + const base = trimTrailingSlash(this.config.endpoint || "https://api.openai.com/v1"); + const url = base.endsWith("/responses") ? base : `${base}/responses`; + const maxOutputTokens = Math.max(1, options.maxTokens ?? this.config.maxTokens ?? 1); + const response = await postJsonWithRetry({ + actualModelContext: this.config.actualModelContext, + provider: "openai_responses", + operation: options.operation, + model: this.config.model, + url, + headers: { + ...bearer(this.config.apiKey), + ...(this.config.extraHeaders ?? {}) + }, + timeoutMs: options.timeoutMs ?? this.config.timeoutMs, + maxRetries: options.maxRetries ?? this.config.maxRetries, + body: { + model: this.config.model, + input: messages.map((message) => ({ role: message.role, content: message.content })), + max_output_tokens: maxOutputTokens, + store: false, + stream: false, + temperature: options.temperature ?? this.config.temperature, + ...(options.jsonMode ? { text: { format: { type: "json_object" } } } : {}), + ...(this.config.extraBody ?? {}) + } + }); + const text = (response.output ?? []) + .filter((item) => item.type === "message") + .flatMap((item) => item.content ?? []) + .filter((part) => part.type === "output_text" || typeof part.text === "string") + .map((part) => part.text ?? "") + .join(""); + if (!text.trim()) { + throw new Error("openai_responses response missing output text"); + } + this.recordTokenUsage(response, options); + return { text, finishReason: responsesFinishReason(response.status) }; + } + private async completeGemini(messages: LlmMessage[], options: LlmCompletionOptions): Promise { if (!this.config.apiKey) { throw new Error("gemini provider requires apiKey"); @@ -1048,6 +1099,13 @@ function doubleMaxTokens(value: number | undefined, limit = Number.MAX_SAFE_INTE return expanded > value ? expanded : undefined; } +function responsesFinishReason(status: string | undefined): LlmCallResult["finishReason"] { + if (status === "completed") return "stop"; + if (status === "incomplete") return "length"; + if (status === "failed" || status === "cancelled") return "other"; + return normalizeFinishReason(status); +} + function normalizeFinishReason(value: string | undefined): LlmCallResult["finishReason"] { if (!value) return undefined; switch (value.trim().toLowerCase()) { diff --git a/Memory/tests/llm-protocol-adapters.test.ts b/Memory/tests/llm-protocol-adapters.test.ts new file mode 100644 index 000000000..6661bde5b --- /dev/null +++ b/Memory/tests/llm-protocol-adapters.test.ts @@ -0,0 +1,155 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveAssignedModel, type RuntimeModelCatalog } from "../src/contracts/index.js"; +import type { LlmConfig } from "../src/config/index.js"; +import { createLlmClient } from "../src/model/llm.js"; +import type { LlmMessage } from "../src/model/types.js"; + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("memory protocol adapters", () => { + it("resolves openai-responses and bedrock-converse for memory roles", () => { + const responses = resolveAssignedModel({ + catalog: catalog("openai-responses"), + mode: "byok", + capability: "memory_summary" + }); + const bedrock = resolveAssignedModel({ + catalog: catalog("bedrock-converse"), + mode: "byok", + capability: "memory_evolution" + }); + + expect(responses.ok && responses.context.protocol).toBe("openai-responses"); + expect(bedrock.ok && bedrock.context.protocol).toBe("bedrock-converse"); + }); + + it("posts memory summary turns to the Responses API", async () => { + const fetchMock = stubFetch(responsesBody("{\"summary\":\"ok\"}")); + vi.stubGlobal("fetch", fetchMock); + const messages: LlmMessage[] = [ + { role: "system", content: "Return JSON." }, + { role: "user", content: "Summarize this." } + ]; + + const text = await createLlmClient(llmConfig({ provider: "openai_responses" }), { + modelRole: "memory_summary" + }).complete(messages, { operation: "capture.summarize", jsonMode: true, maxTokens: 512 }); + + expect(text).toBe("{\"summary\":\"ok\"}"); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.test/v1/responses", + expect.objectContaining({ method: "POST" }) + ); + expect(requestBody(fetchMock)).toMatchObject({ + model: "memory-model", + input: messages, + max_output_tokens: 512, + store: false, + stream: false, + text: { format: { type: "json_object" } } + }); + }); + + it("posts memory evolution turns to Bedrock Converse", async () => { + const fetchMock = stubFetch(bedrockBody("evolved")); + vi.stubGlobal("fetch", fetchMock); + const messages: LlmMessage[] = [ + { role: "system", content: "Evolve the memory." }, + { role: "user", content: "New evidence." } + ]; + + const text = await createLlmClient(llmConfig({ + provider: "bedrock", + endpoint: "https://bedrock-runtime.us-west-2.amazonaws.com", + model: "anthropic.claude-sonnet-5" + }), { + modelRole: "memory_evolution" + }).complete(messages, { operation: "skill.crystallize", maxTokens: 700 }); + + expect(text).toBe("evolved"); + expect(fetchMock).toHaveBeenCalledWith( + "https://bedrock-runtime.us-west-2.amazonaws.com/model/anthropic.claude-sonnet-5/converse", + expect.objectContaining({ method: "POST" }) + ); + expect(requestBody(fetchMock)).toMatchObject({ + system: [{ text: "Evolve the memory." }], + messages: [{ role: "user", content: [{ text: "New evidence." }] }], + inferenceConfig: { maxTokens: 700 } + }); + }); +}); + +function catalog(protocol: "openai-responses" | "bedrock-converse"): RuntimeModelCatalog { + return { + providers: { + custom: { + apiKey: "secret", + endpoints: { + chat: { + apiBase: protocol === "bedrock-converse" + ? "https://bedrock-runtime.us-west-2.amazonaws.com" + : "https://api.example.test/v1", + protocol + } + } + } + }, + modelPresets: { + "memory-model": { + provider: "custom", + endpoint: "chat", + model: "memory-model", + source: "byok", + capabilities: ["agent", "memory_summary", "memory_evolution"] + } + }, + modelAssignments: { + byok: protocol === "bedrock-converse" + ? { memoryEvolution: "memory-model" } + : { memorySummary: "memory-model" } + } + }; +} + +function llmConfig(overrides: Partial): LlmConfig { + return { + provider: "openai_responses", + endpoint: "https://api.example.test/v1", + model: "memory-model", + apiKey: "sk-test", + enableThinking: false, + temperature: 0, + maxTokens: 4096, + timeoutMs: 60_000, + maxRetries: 0, + malformedRetries: 0, + ...overrides + }; +} + +function responsesBody(text: string): Response { + return new Response(JSON.stringify({ + status: "completed", + output: [{ type: "message", content: [{ type: "output_text", text }] }], + usage: { input_tokens: 3, output_tokens: 2, total_tokens: 5 } + }), { status: 200, headers: { "content-type": "application/json" } }); +} + +function bedrockBody(text: string): Response { + return new Response(JSON.stringify({ + output: { message: { content: [{ text }] } }, + stopReason: "end_turn", + usage: { inputTokens: 3, outputTokens: 2 } + }), { status: 200, headers: { "content-type": "application/json" } }); +} + +function stubFetch(response: Response): ReturnType> { + return vi.fn(async () => response); +} + +function requestBody(fetchMock: ReturnType>): Record { + return JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as Record; +} From 284639b34dcd25dcffd765a66fa5c18f91e114ab Mon Sep 17 00:00:00 2001 From: Daoji Wang <627665797@qq.com> Date: Tue, 29 Sep 2026 16:21:32 +0800 Subject: [PATCH 04/85] Add official thinking levels for the September model releases. GPT-6, Claude Opus 5.5, Gemini 3.8 Flash, and DeepSeek V4.1 Flash were missing from the picker table, so the composer had no effort control for those model ids. Co-authored-by: Cursor --- .../src/model-thinking-levels.ts | 44 ++++++++++++++++--- .../tests/model-thinking-levels.test.ts | 36 +++++++++++++++ 2 files changed, 75 insertions(+), 5 deletions(-) diff --git a/App/backend/local-api-contracts/src/model-thinking-levels.ts b/App/backend/local-api-contracts/src/model-thinking-levels.ts index 3df8208b2..667f2f045 100644 --- a/App/backend/local-api-contracts/src/model-thinking-levels.ts +++ b/App/backend/local-api-contracts/src/model-thinking-levels.ts @@ -1,5 +1,5 @@ /** - * 模型思考强度配置表。Reviewed: 2026-09-10 + * 模型思考强度配置表。Reviewed: 2026-09-29 * * 三个字段决定前端渲染与新 session 默认状态: * - switchable: true → 渲染思考开关(用户可关闭思考) @@ -30,7 +30,7 @@ export const ModelThinkingConfigSchema = z.object({ }); export type ModelThinkingConfigDto = z.infer; -export const MODEL_THINKING_REVIEWED_AT = "2026-09-10"; +export const MODEL_THINKING_REVIEWED_AT = "2026-09-29"; const ALWAYS_ON_FIVE: ModelThinkingConfig = { switchable: false, @@ -46,6 +46,27 @@ const SWITCHABLE_FIVE: ModelThinkingConfig = { defaultLevel: "high" }; +/** GPT-6 Sol / Luna: `none` turns reasoning off; omitted effort is `medium`. */ +const SWITCHABLE_FIVE_MEDIUM: ModelThinkingConfig = { + switchable: true, + defaultEnabled: true, + levels: ["low", "medium", "high", "xhigh", "max"], + defaultLevel: "medium" +}; + +/** + * Thinking cannot be disabled. Five effort levels, default `medium`. + * GPT-6 Astra rejects `none`. Claude Opus 5.5 documents `medium` as its API default. + * OpenAI's reasoning guide does not publish a separate omit-effort default for Astra; + * `medium` is the default it documents for the other GPT-6 models and for most workloads. + */ +const ALWAYS_ON_FIVE_MEDIUM: ModelThinkingConfig = { + switchable: false, + defaultEnabled: true, + levels: ["low", "medium", "high", "xhigh", "max"], + defaultLevel: "medium" +}; + const SWITCHABLE_FOUR: ModelThinkingConfig = { switchable: true, defaultEnabled: true, @@ -87,6 +108,7 @@ export const MODEL_THINKING_CONFIGS: Readonly { const alwaysOn = getModelThinkingConfig("claude-fable-5")!; expect(resolveThinkingEnabled(alwaysOn, false)).toBe(true); }); + + it("matches vendor docs for models added on 2026-09-29", () => { + expect(getModelThinkingConfig("gpt-6-sol")).toEqual({ + switchable: true, + defaultEnabled: true, + levels: ["low", "medium", "high", "xhigh", "max"], + defaultLevel: "medium" + }); + expect(getModelThinkingConfig("gpt-6-luna")).toEqual(getModelThinkingConfig("gpt-6-sol")); + expect(getModelThinkingConfig("gpt-6-astra")).toEqual({ + switchable: false, + defaultEnabled: true, + levels: ["low", "medium", "high", "xhigh", "max"], + defaultLevel: "medium" + }); + expect(resolveThinkingEnabled(getModelThinkingConfig("gpt-6-astra")!, false)).toBe(true); + + expect(getModelThinkingConfig("claude-opus-5-5")).toEqual(getModelThinkingConfig("gpt-6-astra")); + expect(getModelThinkingConfig("anthropic.claude-opus-5-5")).toEqual(getModelThinkingConfig("claude-opus-5-5")); + + expect(getModelThinkingConfig("gemini-3.8-flash")).toEqual({ + switchable: false, + defaultEnabled: true, + levels: ["low", "medium", "high"], + defaultLevel: "medium" + }); + + const deepseek = { + switchable: true, + defaultEnabled: true, + levels: ["low", "high", "max"], + defaultLevel: "high" + }; + expect(getModelThinkingConfig("deepseek-flash")).toEqual(deepseek); + expect(getModelThinkingConfig("deepseek-v4.1-flash")).toEqual(deepseek); + }); }); From cddc65b5aeb1e6d005ccc271f0ac909162a9803b Mon Sep 17 00:00:00 2001 From: Daoji Wang <627665797@qq.com> Date: Tue, 29 Sep 2026 16:28:13 +0800 Subject: [PATCH 05/85] Remove the brain icon from the thinking mode toggle. The label and switch already identify the control, and the icon crowded the composer action bar. Co-authored-by: Cursor --- App/frontend/desktop/src/components/agent-thinking-control.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/App/frontend/desktop/src/components/agent-thinking-control.tsx b/App/frontend/desktop/src/components/agent-thinking-control.tsx index 0ec32faf1..88c885ad4 100644 --- a/App/frontend/desktop/src/components/agent-thinking-control.tsx +++ b/App/frontend/desktop/src/components/agent-thinking-control.tsx @@ -1,4 +1,4 @@ -import { Brain, ChevronDown } from "lucide-react"; +import { ChevronDown } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { resolveThinkingEnabled, @@ -70,7 +70,6 @@ export function AgentThinkingControl(props: AgentThinkingControlProps) { className={`thinking-toggle ${enabled ? "thinking-toggle--on" : ""}`} aria-label={t("home.thinking.toggle")} > - {t("home.thinking.label")}
From cb4832612aeaef99fe53245f69aa6e981e392777 Mon Sep 17 00:00:00 2001 From: Daoji Wang <627665797@qq.com> Date: Tue, 29 Sep 2026 17:01:17 +0800 Subject: [PATCH 06/85] Explain custom model capability options on hover. The thinking and image checkboxes did not say what they change, and the checkbox itself did not show a pointer. Co-authored-by: Cursor --- App/frontend/desktop/src/i18n/messages.ts | 8 ++ .../src/pages/model-workspace-section.tsx | 77 +++++++++++-------- .../src/pages/tests/settings-page.test.tsx | 8 ++ App/frontend/desktop/src/styles.css | 4 + 4 files changed, 65 insertions(+), 32 deletions(-) diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index dc21ce1be..aa4178b4c 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -1552,12 +1552,16 @@ export const zhCNMessages = { "settings.modelWorkspace.region": "区域", "settings.modelWorkspace.regionPlaceholder": "例如:us-east-1", "settings.modelWorkspace.customThinkingEnabled": "支持思考", + "settings.modelWorkspace.customThinkingEnabledHint": "勾选后,输入框可以设置思考。不勾选则不传思考参数,由模型接口按默认行为处理。", "settings.modelWorkspace.customThinkingSwitchable": "可关闭思考", + "settings.modelWorkspace.customThinkingSwitchableHint": "勾选后,对话里可以临时关闭思考。不勾选则思考始终开启,输入框不显示思考开关。", "settings.modelWorkspace.customThinkingDefaultEnabled": "默认开启思考", + "settings.modelWorkspace.customThinkingDefaultEnabledHint": "决定新对话一开始是否开启思考。之后仍可在输入框里更改。", "settings.modelWorkspace.customThinkingLevels": "思考档位", "settings.modelWorkspace.customThinkingLevelsPlaceholder": "例如:low,medium,high", "settings.modelWorkspace.customThinkingLevelsHint": "用逗号分隔多个档位名称,留空表示无档位", "settings.modelWorkspace.customImageInput": "支持图片输入", + "settings.modelWorkspace.customImageInputHint": "勾选后可以在消息里发送图片。不勾选时,带图片的消息会被拒绝。", "settings.token.detail": "Token 用量详情", "settings.token.agentTask": "Agent 任务", "settings.token.memorySummary": "记忆摘要", @@ -3319,12 +3323,16 @@ export const enUSMessages: Record = { "settings.modelWorkspace.region": "Region", "settings.modelWorkspace.regionPlaceholder": "For example: us-east-1", "settings.modelWorkspace.customThinkingEnabled": "Supports thinking", + "settings.modelWorkspace.customThinkingEnabledHint": "When checked, the composer can set thinking. When unchecked, no thinking parameters are sent and the model endpoint uses its default behavior.", "settings.modelWorkspace.customThinkingSwitchable": "Thinking can be turned off", + "settings.modelWorkspace.customThinkingSwitchableHint": "When checked, a conversation can turn thinking off. When unchecked, thinking stays on and the composer hides the thinking switch.", "settings.modelWorkspace.customThinkingDefaultEnabled": "Thinking on by default", + "settings.modelWorkspace.customThinkingDefaultEnabledHint": "Sets whether a new conversation starts with thinking on. You can still change it in the composer.", "settings.modelWorkspace.customThinkingLevels": "Thinking levels", "settings.modelWorkspace.customThinkingLevelsPlaceholder": "For example: low,medium,high", "settings.modelWorkspace.customThinkingLevelsHint": "Comma-separated level names. Leave empty for no levels.", "settings.modelWorkspace.customImageInput": "Supports image input", + "settings.modelWorkspace.customImageInputHint": "When checked, messages can include images. When unchecked, messages with images are rejected.", "settings.token.detail": "Token usage details", "settings.token.agentTask": "Agent tasks", "settings.token.memorySummary": "Memory summary", diff --git a/App/frontend/desktop/src/pages/model-workspace-section.tsx b/App/frontend/desktop/src/pages/model-workspace-section.tsx index d6140102a..916328ae4 100644 --- a/App/frontend/desktop/src/pages/model-workspace-section.tsx +++ b/App/frontend/desktop/src/pages/model-workspace-section.tsx @@ -1173,33 +1173,27 @@ export function ModelWorkspaceSection(props: ModelWorkspaceSectionProps) { /> )}
- + setEditor((prev) => prev ? { ...prev, customThinkingEnabled } : null)} + /> {editor.customThinkingEnabled && ( <> - + setEditor((prev) => prev ? { ...prev, customThinkingSwitchable } : null)} + /> {editor.customThinkingSwitchable && ( - + setEditor((prev) => prev ? { ...prev, customThinkingDefaultEnabled } : null)} + /> )}
)} - + setEditor((prev) => prev ? { ...prev, customImageInput } : null)} + />
)} @@ -1777,3 +1769,24 @@ async function simulateConnectionTest(): Promise<{ ok: boolean }> { await new Promise((resolve) => window.setTimeout(resolve, 450)); return { ok: true }; } + +function CustomCapabilityOption(props: { + checked: boolean; + label: string; + hint: string; + onChange: (checked: boolean) => void; +}) { + return ( + + ); +} diff --git a/App/frontend/desktop/src/pages/tests/settings-page.test.tsx b/App/frontend/desktop/src/pages/tests/settings-page.test.tsx index 191982184..1c9a6f7da 100644 --- a/App/frontend/desktop/src/pages/tests/settings-page.test.tsx +++ b/App/frontend/desktop/src/pages/tests/settings-page.test.tsx @@ -204,6 +204,7 @@ describe("自定义模型能力选择", () => { it("模型能力沿用标准 Select 四选一,不再拆分文本用途", () => { const source = readFileSync(modelWorkspaceSourcePath, "utf8"); + const styles = readFileSync(fileURLToPath(new URL("../../styles.css", import.meta.url)), "utf8"); expect(source).toContain('const MODEL_KIND_OPTIONS = ["text", "embedding", "asr", "image"] as const;'); expect(source).toContain("value={kind}"); @@ -214,6 +215,13 @@ describe("自定义模型能力选择", () => { expect(source).toContain("normalizeEditorCapabilities(entry.capabilities.map(fromCatalogCapability))"); expect(source).toContain("checked={editor.customThinkingEnabled}"); expect(source).toContain("checked={editor.customImageInput}"); + expect(source).toContain('t("settings.modelWorkspace.customThinkingEnabledHint")'); + expect(source).toContain('t("settings.modelWorkspace.customThinkingSwitchableHint")'); + expect(source).toContain('t("settings.modelWorkspace.customThinkingDefaultEnabledHint")'); + expect(source).toContain('t("settings.modelWorkspace.customImageInputHint")'); + expect(source).toContain('className="custom-thinking-config__checkbox"'); + expect(styles).toContain(".custom-thinking-config__checkbox"); + expect(styles).toContain("cursor: pointer"); expect(source).not.toContain('t("settings.modelWorkspace.textRoles")'); expect(source).not.toContain('t("settings.modelWorkspace.capability.agent")'); }); diff --git a/App/frontend/desktop/src/styles.css b/App/frontend/desktop/src/styles.css index 6ec3764ec..eff8f6dd0 100644 --- a/App/frontend/desktop/src/styles.css +++ b/App/frontend/desktop/src/styles.css @@ -1008,6 +1008,10 @@ body.memmy-window-fullscreen { font-size: 13px; } +.custom-thinking-config__checkbox { + cursor: pointer; +} + .custom-thinking-levels { display: grid; gap: 6px; From 2560b36a677322ca430dc057c3f02971ca8f4b31 Mon Sep 17 00:00:00 2001 From: Daoji Wang <627665797@qq.com> Date: Tue, 29 Sep 2026 19:31:04 +0800 Subject: [PATCH 07/85] Save Custom as its own provider and route each protocol to the matching client. Co-authored-by: Cursor --- App/backend/local-api-contracts/src/index.ts | 3 +- .../memmy-config/model-config-catalog.ts | 127 +++++++++++++++- .../tests/model-config-catalog.test.ts | 137 ++++++++++++++++++ .../src/pages/model-workspace-section.tsx | 8 +- .../src/pages/tests/settings-page.test.tsx | 10 +- .../desktop/src/state/model-workspace.ts | 2 +- .../src/state/tests/model-workspace.test.ts | 6 +- App/memmy-agent/src/providers/factory.ts | 10 +- .../src/providers/openai-compat-provider.ts | 3 +- .../tests/providers/custom-provider.test.ts | 42 ++++++ Memory/src/config/index.ts | 2 + Memory/src/config/model-catalog.ts | 3 +- Memory/tests/llm-protocol-adapters.test.ts | 51 ++++++- 13 files changed, 381 insertions(+), 23 deletions(-) diff --git a/App/backend/local-api-contracts/src/index.ts b/App/backend/local-api-contracts/src/index.ts index 999adcb3b..66d746787 100644 --- a/App/backend/local-api-contracts/src/index.ts +++ b/App/backend/local-api-contracts/src/index.ts @@ -745,7 +745,8 @@ export const CatalogProviderIdSchema = z.enum([ "stepfun", "xiaomi_mimo", "memmy_account", - "bedrock" + "bedrock", + "custom" ]); export type CatalogProviderId = z.infer; diff --git a/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts b/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts index 0baa81d1f..94fbfb1e7 100644 --- a/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts +++ b/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts @@ -64,7 +64,23 @@ export class InvalidModelConfigError extends Error { export async function readModelConfigCatalog(configPath: string): Promise { const target = resolve(configPath); const { content, config } = await readConfig(target); - return buildModelConfigView(config, revisionFor(config), await updatedAt(target, content)); + if (!relocateCustomPresets(config)) { + return buildModelConfigView(config, revisionFor(config), await updatedAt(target, content)); + } + try { + const result = await mutateRuntimeConfig(target, (current) => { + relocateCustomPresets(current); + return current; + }); + return buildModelConfigView(result.value, revisionFor(result.value), new Date().toISOString()); + } catch (error) { + if (isErrorCode(error, "migration_lock_timeout")) { + throw Object.assign(new Error("Model configuration is busy; try again"), { + code: "config_write_busy" as const + }); + } + throw error; + } } export async function writeModelConfigCatalog( @@ -165,6 +181,7 @@ function mergeModelConfig(config: ConfigRecord, input: ModelConfigInput): Config } } + relocateCustomPresets({ providers: nextProviders, modelPresets: nextPresets }); validateUniqueModels(nextPresets); const modelAssignments = cloneAssignments(input.modelAssignments); validateAssignments(modelAssignments, nextPresets, existingAssignments); @@ -322,11 +339,115 @@ function memoryConnection(config: ConfigRecord, presetId: string): ConfigRecord function memoryProvider(providerId: string, protocol: string | undefined): string { if (protocol === "bedrock-converse") return "bedrock"; if (protocol === "openai-responses") return "openai_responses"; - if (providerId === "anthropic") return "anthropic"; - if (providerId === "gemini") return "gemini"; + if (protocol === "anthropic-messages" || providerId === "anthropic") return "anthropic"; + if (protocol === "gemini-generate-content" || providerId === "gemini") return "gemini"; return "openai_compatible"; } +const CUSTOM_PROVIDER_ID = "custom"; +const PROVIDER_CONNECTION_KEYS = new Set([ + "apiKey", "extraHeaders", "extraBody", "region", "profile", "endpoints", "apiBase", "apiType" +]); + +function relocateCustomPresets(config: ConfigRecord): boolean { + const providers = record(config.providers); + const presets = record(config.modelPresets); + const customProvider = record(providers[CUSTOM_PROVIDER_ID]); + const customEndpoints = record(customProvider.endpoints); + let changed = false; + + for (const presetValue of Object.values(presets)) { + const preset = record(presetValue); + if (preset.custom !== true) continue; + const providerId = stringValue(preset.provider); + const endpointId = stringValue(preset.endpoint); + if (!providerId || !endpointId || providerId === CUSTOM_PROVIDER_ID) continue; + const sourceProvider = record(providers[providerId]); + const sourceEndpoint = record(record(sourceProvider.endpoints)[endpointId]); + if (!stringValue(sourceEndpoint.apiBase)) continue; + const targetId = placeCustomEndpoint(customEndpoints, endpointId, movedEndpoint(sourceProvider, sourceEndpoint)); + preset.provider = CUSTOM_PROVIDER_ID; + preset.endpoint = targetId; + changed = true; + } + if (!changed) return false; + + customProvider.endpoints = customEndpoints; + providers[CUSTOM_PROVIDER_ID] = customProvider; + for (const [providerId, providerValue] of Object.entries(providers)) { + if (providerId === CUSTOM_PROVIDER_ID) continue; + const provider = record(providerValue); + const endpoints = record(provider.endpoints); + for (const endpointId of Object.keys(endpoints)) { + const referenced = Object.values(presets).some((presetValue) => { + const preset = record(presetValue); + return preset.provider === providerId && preset.endpoint === endpointId; + }); + if (!referenced) delete endpoints[endpointId]; + } + const stillReferenced = Object.values(presets).some((presetValue) => record(presetValue).provider === providerId); + const onlyConnectionFields = Object.keys(provider).every((key) => PROVIDER_CONNECTION_KEYS.has(key)); + if (!stillReferenced && !Object.keys(endpoints).length && onlyConnectionFields) { + delete providers[providerId]; + } + } + config.providers = providers; + config.modelPresets = presets; + return true; +} + +function movedEndpoint(provider: ConfigRecord, endpoint: ConfigRecord): ConfigRecord { + const next: ConfigRecord = { ...endpoint }; + const apiKey = stringValue(endpoint.apiKey) ?? stringValue(provider.apiKey); + if (apiKey) next.apiKey = apiKey; + const extraHeaders = { ...record(provider.extraHeaders), ...record(endpoint.extraHeaders) }; + if (Object.keys(extraHeaders).length) next.extraHeaders = extraHeaders; + const extraBody = { ...record(provider.extraBody), ...record(endpoint.extraBody) }; + if (Object.keys(extraBody).length) next.extraBody = extraBody; + const region = stringValue(endpoint.region) ?? stringValue(provider.region); + if (region) next.region = region; + const profile = stringValue(endpoint.profile) ?? stringValue(provider.profile); + if (profile) next.profile = profile; + return next; +} + +function placeCustomEndpoint(endpoints: ConfigRecord, preferredId: string, endpoint: ConfigRecord): string { + const existing = record(endpoints[preferredId]); + if (!Object.keys(existing).length) { + endpoints[preferredId] = endpoint; + return preferredId; + } + if (sameStoredEndpoint(existing, endpoint)) return preferredId; + let suffix = 2; + let id = `${preferredId}-custom`; + while (Object.keys(record(endpoints[id])).length && !sameStoredEndpoint(record(endpoints[id]), endpoint)) { + id = `${preferredId}-custom-${suffix}`; + suffix += 1; + } + endpoints[id] = endpoint; + return id; +} + +function sameStoredEndpoint(left: ConfigRecord, right: ConfigRecord): boolean { + return stableJson({ + protocol: left.protocol ?? null, + apiBase: normalizeApiBase(stringValue(left.apiBase) ?? ""), + apiKey: stringValue(left.apiKey) ?? null, + region: stringValue(left.region) ?? null, + profile: stringValue(left.profile) ?? null, + extraHeaders: left.extraHeaders ?? null, + extraBody: left.extraBody ?? null + }) === stableJson({ + protocol: right.protocol ?? null, + apiBase: normalizeApiBase(stringValue(right.apiBase) ?? ""), + apiKey: stringValue(right.apiKey) ?? null, + region: stringValue(right.region) ?? null, + profile: stringValue(right.profile) ?? null, + extraHeaders: right.extraHeaders ?? null, + extraBody: right.extraBody ?? null + }); +} + function mergeMemoryConnection(previous: ConfigRecord, connection: ConfigRecord): ConfigRecord { return { ...withoutMemoryConnection(previous), diff --git a/App/backend/src/infrastructure/memmy-config/tests/model-config-catalog.test.ts b/App/backend/src/infrastructure/memmy-config/tests/model-config-catalog.test.ts index 1ed55ca8a..7c9f4d93b 100644 --- a/App/backend/src/infrastructure/memmy-config/tests/model-config-catalog.test.ts +++ b/App/backend/src/infrastructure/memmy-config/tests/model-config-catalog.test.ts @@ -69,6 +69,143 @@ function openAiInput(revision: string, presetId?: string): ModelConfigInput { } describe("model config catalog", () => { + it("stores Custom beside OpenAI and moves an existing custom preset onto provider custom", async () => { + const file = fixture({ + providers: { + openai: { + apiKey: "sk-shared", + endpoints: { + chat: { + apiBase: "https://api.openai.com/v1", + protocol: "openai-chat-completions" + } + } + } + }, + modelPresets: { + "legacy-custom": { + provider: "openai", + endpoint: "chat", + model: "legacy-model", + source: "byok", + capabilities: ["agent", "memory_summary", "memory_evolution"], + custom: true + } + }, + modelAssignments: emptyAssignments() + }); + + const read = await readModelConfigCatalog(file); + const migrated = YAML.parse(readFileSync(file, "utf8")) as any; + expect(migrated.modelPresets["legacy-custom"]).toMatchObject({ + provider: "custom", + endpoint: "chat", + custom: true + }); + expect(migrated.providers.custom.endpoints.chat).toMatchObject({ + apiBase: "https://api.openai.com/v1", + protocol: "openai-chat-completions", + apiKey: "sk-shared" + }); + expect(migrated.providers.openai).toBeUndefined(); + expect(read.providers.map((provider) => provider.provider)).toEqual(["custom"]); + + const legacy = read.providers[0]!.models[0]!; + const saved = await writeModelConfigCatalog(file, { + configRevision: read.configRevision, + providers: [ + { + provider: "custom", + endpoints: [{ + endpointId: legacy.endpointId, + apiBase: "https://api.openai.com/v1", + protocol: "openai-chat-completions", + apiKey: "sk-shared" + }, { + endpointId: "anthropic", + apiBase: "https://api.anthropic.com/v1", + protocol: "anthropic-messages", + apiKey: "sk-anthropic" + }], + models: [ + { + presetId: legacy.presetId, + endpointId: legacy.endpointId, + model: "legacy-model", + source: "byok", + capabilities: ["agent", "memory_summary", "memory_evolution"], + custom: true + }, + { + endpointId: "anthropic", + model: "claude-sonnet", + source: "byok", + capabilities: ["memory_summary", "memory_evolution"], + custom: true + } + ] + }, + { + provider: "openai", + endpoints: [{ + endpointId: "chat", + apiBase: "https://api.openai.com/v1", + protocol: "openai-chat-completions", + apiKey: "sk-shared" + }], + models: [{ + endpointId: "chat", + model: "gpt-official", + source: "byok", + capabilities: ["agent"] + }] + } + ], + modelAssignments: emptyAssignments() + }); + expect(saved.providers.map((provider) => provider.provider).sort()).toEqual(["custom", "openai"]); + + const anthropicId = saved.providers + .find((provider) => provider.provider === "custom")! + .models.find((model) => model.model === "claude-sonnet")! + .presetId; + const officialId = saved.providers + .find((provider) => provider.provider === "openai")! + .models[0]!.presetId; + const assigned = await writeModelConfigCatalog(file, { + configRevision: saved.configRevision, + providers: saved.providers.map((provider) => ({ + provider: provider.provider, + endpoints: provider.endpoints.map((endpoint) => ({ + endpointId: endpoint.endpointId, + apiBase: endpoint.apiBase, + protocol: endpoint.protocol, + ...(provider.provider === "custom" && endpoint.protocol === "openai-chat-completions" ? { apiKey: "sk-shared" } : {}), + ...(endpoint.protocol === "anthropic-messages" ? { apiKey: "sk-anthropic" } : {}), + ...(provider.provider === "openai" ? { apiKey: "sk-shared" } : {}) + })), + models: provider.models.map((model) => ({ + presetId: model.presetId, + endpointId: model.endpointId, + model: model.model, + source: model.source, + capabilities: [...model.capabilities], + ...(model.custom ? { custom: true } : {}) + })) + })), + modelAssignments: { + ...emptyAssignments(), + byok: { + ...emptyAssignment(), + agent: { candidates: [officialId], default: officialId }, + memoryEvolution: anthropicId + } + } + }); + expect(assigned.providers.find((provider) => provider.provider === "custom")?.models).toHaveLength(2); + expect((YAML.parse(readFileSync(file, "utf8")) as any).memmyMemory.evolution.provider).toBe("anthropic"); + }); + it("round-trips StepFun and Xiaomi MiMo connections back into the view", async () => { const file = fixture({ modelAssignments: emptyAssignments() }); const current = await readModelConfigCatalog(file); diff --git a/App/frontend/desktop/src/pages/model-workspace-section.tsx b/App/frontend/desktop/src/pages/model-workspace-section.tsx index 916328ae4..a80115715 100644 --- a/App/frontend/desktop/src/pages/model-workspace-section.tsx +++ b/App/frontend/desktop/src/pages/model-workspace-section.tsx @@ -1613,12 +1613,8 @@ export function protocolFromConnection(provider: string): Protocol { return "openai"; } -export function catalogProviderForEditor(provider: Protocol, customProtocol: CustomProtocol | null): string { - if (provider === "custom") { - if (customProtocol === "anthropic-messages") return "anthropic"; - if (customProtocol === "bedrock-converse") return "bedrock"; - return "openai"; - } +export function catalogProviderForEditor(provider: Protocol, _customProtocol: CustomProtocol | null): string { + if (provider === "custom") return "custom"; if (provider === "openai_responses") return "openai"; return fromProtocol(provider) === "kimi" ? "moonshot" : fromProtocol(provider); } diff --git a/App/frontend/desktop/src/pages/tests/settings-page.test.tsx b/App/frontend/desktop/src/pages/tests/settings-page.test.tsx index 1c9a6f7da..543fb1de4 100644 --- a/App/frontend/desktop/src/pages/tests/settings-page.test.tsx +++ b/App/frontend/desktop/src/pages/tests/settings-page.test.tsx @@ -111,11 +111,11 @@ describe("多 BYOK endpoint 入口", () => { }); describe("Custom 与 OpenAI Responses 的 catalog provider 推导", () => { - it("Custom 按底层协议落到 openai/anthropic/bedrock,而不是 custom", () => { - expect(catalogProviderForEditor("custom", "openai-chat-completions")).toBe("openai"); - expect(catalogProviderForEditor("custom", "openai-responses")).toBe("openai"); - expect(catalogProviderForEditor("custom", "anthropic-messages")).toBe("anthropic"); - expect(catalogProviderForEditor("custom", "bedrock-converse")).toBe("bedrock"); + it("Custom 按供应商 custom 保存,协议留在端点上", () => { + expect(catalogProviderForEditor("custom", "openai-chat-completions")).toBe("custom"); + expect(catalogProviderForEditor("custom", "openai-responses")).toBe("custom"); + expect(catalogProviderForEditor("custom", "anthropic-messages")).toBe("custom"); + expect(catalogProviderForEditor("custom", "bedrock-converse")).toBe("custom"); expect(catalogProviderForEditor("openai_responses", null)).toBe("openai"); expect(catalogProviderForEditor("moonshot", null)).toBe("moonshot"); }); diff --git a/App/frontend/desktop/src/state/model-workspace.ts b/App/frontend/desktop/src/state/model-workspace.ts index a3558bc77..162b93697 100644 --- a/App/frontend/desktop/src/state/model-workspace.ts +++ b/App/frontend/desktop/src/state/model-workspace.ts @@ -785,7 +785,7 @@ function normalizeProvider(provider: string): CatalogProviderId | null { openai_responses: "openai" }; const candidate = aliases[normalized] ?? normalized; - return ["openai", "anthropic", "gemini", "deepseek", "zhipu", "dashscope", "moonshot", "minimax", "qianfan", "volcengine", "stepfun", "xiaomi_mimo", "memmy_account", "bedrock"].includes(candidate) + return ["openai", "anthropic", "gemini", "deepseek", "zhipu", "dashscope", "moonshot", "minimax", "qianfan", "volcengine", "stepfun", "xiaomi_mimo", "memmy_account", "bedrock", "custom"].includes(candidate) ? candidate as CatalogProviderId : null; } diff --git a/App/frontend/desktop/src/state/tests/model-workspace.test.ts b/App/frontend/desktop/src/state/tests/model-workspace.test.ts index 37a93ada8..ebf1f7fd2 100644 --- a/App/frontend/desktop/src/state/tests/model-workspace.test.ts +++ b/App/frontend/desktop/src/state/tests/model-workspace.test.ts @@ -1092,7 +1092,7 @@ describe("canonical model workspace adapter", () => { const file = catalogFixture(); const empty = await readModelConfigCatalog(file); const created = upsertModelConnection(createModelWorkspace(empty), "byok", { - provider: "bedrock", + provider: "custom", endpoint: "https://bedrock-runtime.us-west-2.amazonaws.com", protocol: "bedrock-converse", region: "us-west-2", @@ -1119,7 +1119,7 @@ describe("canonical model workspace adapter", () => { write: (input) => writeModelConfigCatalog(file, input) }); const reloaded = createModelWorkspace(saved); - const connection = reloaded.spaces.byok.connections.find((item) => item.provider === "bedrock")!; + const connection = reloaded.spaces.byok.connections.find((item) => item.provider === "custom")!; const entry = connection.modelEntries[0]!; const candidate = getTaskModelCandidates(reloaded, "byok").find((item) => item.model === "anthropic.claude-sonnet-5")!; @@ -1135,7 +1135,7 @@ describe("canonical model workspace adapter", () => { expect(entry.inputModalities).toEqual(["text", "image"]); expect(isCustomModelEntry(reloaded, candidate)).toBe(true); expect(resolveThinkingConfigForModel(reloaded, candidate)).toEqual(entry.thinking); - expect(saved.providers.find((provider) => provider.provider === "bedrock")?.models[0]).toMatchObject({ + expect(saved.providers.find((provider) => provider.provider === "custom")?.models[0]).toMatchObject({ custom: true, inputModalities: ["text", "image"] }); diff --git a/App/memmy-agent/src/providers/factory.ts b/App/memmy-agent/src/providers/factory.ts index 85518bc39..cc6ac81b3 100644 --- a/App/memmy-agent/src/providers/factory.ts +++ b/App/memmy-agent/src/providers/factory.ts @@ -72,6 +72,12 @@ function mergedRecord( return { ...(defaults ?? {}), ...(overrides ?? {}) }; } +function backendForCustomProtocol(protocol: string | undefined): ProviderSpec["backend"] { + if (protocol === "anthropic-messages") return "anthropic"; + if (protocol === "bedrock-converse") return "bedrock"; + return "openai_compat"; +} + function exactEndpoint( config: Config, providerConfig: ProviderConfig | null, @@ -100,7 +106,9 @@ function makeProviderCore( const providerConfig = config.getProvider(model, { preset: resolved }); const endpoint = exactEndpoint(config, providerConfig, resolved); const spec = providerName ? findByName(providerName) : null; - const backend = spec?.backend ?? "openai_compat"; + const backend = providerName === "custom" + ? backendForCustomProtocol(endpoint?.protocol) + : spec?.backend ?? "openai_compat"; const init = providerInit(config, providerConfig, spec, model, resolved); if (opts.validateCredentials !== false && backend === "azure_openai") { diff --git a/App/memmy-agent/src/providers/openai-compat-provider.ts b/App/memmy-agent/src/providers/openai-compat-provider.ts index 7b9b72d9b..5865f98a0 100644 --- a/App/memmy-agent/src/providers/openai-compat-provider.ts +++ b/App/memmy-agent/src/providers/openai-compat-provider.ts @@ -135,7 +135,8 @@ export class OpenAICompatProvider extends LLMProvider { } this.defaultModel = this.model ?? "gpt-4o"; - if (this.apiType !== "auto" && specName(this.spec) !== "openai") { + const name = specName(this.spec); + if (this.apiType !== "auto" && name !== "openai" && name !== "custom") { this.apiType = "auto"; } if (this.apiKey && this.spec?.envKey) this.setupEnv(this.apiKey, this.apiBase); diff --git a/App/memmy-agent/tests/providers/custom-provider.test.ts b/App/memmy-agent/tests/providers/custom-provider.test.ts index 3e58c2a76..bfe025447 100644 --- a/App/memmy-agent/tests/providers/custom-provider.test.ts +++ b/App/memmy-agent/tests/providers/custom-provider.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from "vitest"; import { Config } from "../../src/config/schema.js"; +import { AnthropicProvider } from "../../src/providers/anthropic-provider.js"; +import { BedrockProvider } from "../../src/providers/bedrock-provider.js"; import { makeProvider } from "../../src/providers/factory.js"; import { OpenAICompatProvider } from "../../src/providers/openai-compat-provider.js"; import { findByName } from "../../src/providers/registry.js"; @@ -47,6 +49,17 @@ describe("custom provider", () => { } } } })).toThrow(); }); + it("selects the client from the custom endpoint protocol", () => { + const responses = makeProvider(customPresetConfig("openai-responses", "gpt-4o-mini")) as OpenAICompatProvider; + const anthropic = makeProvider(customPresetConfig("anthropic-messages", "claude-sonnet")); + const bedrock = makeProvider(customPresetConfig("bedrock-converse", "anthropic.claude-sonnet")); + + expect(responses).toBeInstanceOf(OpenAICompatProvider); + expect(responses.apiType).toBe("responses"); + expect(anthropic).toBeInstanceOf(AnthropicProvider); + expect(bedrock).toBeInstanceOf(BedrockProvider); + }); + it("parses empty choices as an error response", () => { const provider = new OpenAICompatProvider(); @@ -140,3 +153,32 @@ describe("custom provider", () => { expect(result.content).toContain("proxy/tunnel"); }); }); + +function customPresetConfig(protocol: string, model: string): Config { + return new Config({ + agents: { defaults: { provider: "custom", model, modelPreset: "chosen" } }, + providers: { + custom: { + apiKey: "test-key", + endpoints: { + chat: { + apiBase: protocol === "bedrock-converse" + ? "https://bedrock-runtime.us-west-2.amazonaws.com" + : "https://example.com/v1", + protocol, + ...(protocol === "bedrock-converse" ? { region: "us-west-2" } : {}) + } + } + } + }, + modelPresets: { + chosen: { + provider: "custom", + endpoint: "chat", + model, + source: "byok", + capabilities: ["agent"] + } + } + }); +} diff --git a/Memory/src/config/index.ts b/Memory/src/config/index.ts index 6e9a98627..602f6ef21 100644 --- a/Memory/src/config/index.ts +++ b/Memory/src/config/index.ts @@ -894,6 +894,8 @@ function memoryRuntimeProvider( ): LlmProviderName { if (protocol === "bedrock-converse") return "bedrock"; if (protocol === "openai-responses") return "openai_responses"; + if (protocol === "anthropic-messages") return "anthropic"; + if (protocol === "gemini-generate-content") return "gemini"; return memoryLlmProvider(provider); } diff --git a/Memory/src/config/model-catalog.ts b/Memory/src/config/model-catalog.ts index 5c92097e2..ee4a959a8 100644 --- a/Memory/src/config/model-catalog.ts +++ b/Memory/src/config/model-catalog.ts @@ -150,7 +150,8 @@ function catalogProviderId(connection: Record): string { const provider = aliases[source] ?? source; return [ "openai", "anthropic", "gemini", "deepseek", "zhipu", "dashscope", - "moonshot", "minimax", "qianfan", "volcengine", "stepfun", "xiaomi_mimo" + "moonshot", "minimax", "qianfan", "volcengine", "stepfun", "xiaomi_mimo", + "custom" ].includes(provider) ? provider : "openai"; } diff --git a/Memory/tests/llm-protocol-adapters.test.ts b/Memory/tests/llm-protocol-adapters.test.ts index 6661bde5b..0de0a1a19 100644 --- a/Memory/tests/llm-protocol-adapters.test.ts +++ b/Memory/tests/llm-protocol-adapters.test.ts @@ -1,6 +1,10 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; +import YAML from "yaml"; import { resolveAssignedModel, type RuntimeModelCatalog } from "../src/contracts/index.js"; -import type { LlmConfig } from "../src/config/index.js"; +import { loadMemmyConfig, type LlmConfig } from "../src/config/index.js"; import { createLlmClient } from "../src/model/llm.js"; import type { LlmMessage } from "../src/model/types.js"; @@ -80,6 +84,51 @@ describe("memory protocol adapters", () => { inferenceConfig: { maxTokens: 700 } }); }); + + it("runs a custom Anthropic Messages preset through the Anthropic memory runtime", () => { + const root = mkdtempSync(join(tmpdir(), "memmy-custom-anthropic-")); + const file = join(root, "config.yaml"); + writeFileSync(file, YAML.stringify({ + app: { userMode: "byok" }, + providers: { + custom: { + endpoints: { + chat: { + apiBase: "https://api.anthropic.com/v1", + protocol: "anthropic-messages", + apiKey: "sk-anthropic" + } + } + } + }, + modelPresets: { + "memory-model": { + provider: "custom", + endpoint: "chat", + model: "claude-sonnet", + source: "byok", + capabilities: ["agent", "memory_summary", "memory_evolution"] + } + }, + modelAssignments: { + byok: { + agent: { candidates: ["memory-model"], default: "memory-model" } + } + }, + memmyMemory: { + roleRouting: { summary: "follow", evolution: "follow" } + } + }), "utf8"); + + try { + const { config } = loadMemmyConfig(file); + expect(config.evolution.provider).toBe("anthropic"); + expect(config.evolution.model).toBe("claude-sonnet"); + expect(config.evolution.endpoint).toBe("https://api.anthropic.com/v1"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); function catalog(protocol: "openai-responses" | "bedrock-converse"): RuntimeModelCatalog { From 776753b500be656e67b51ae7036300130c1ac4c5 Mon Sep 17 00:00:00 2001 From: Daoji Wang <627665797@qq.com> Date: Tue, 29 Sep 2026 20:27:45 +0800 Subject: [PATCH 08/85] Let Custom connection tests follow the selected protocol. Co-authored-by: Cursor --- App/backend/local-api-contracts/src/index.ts | 9 +- .../src/services/model-config-tester.ts | 27 +++-- .../tests/model-config-tester.test.ts | 105 ++++++++++++++++++ .../src/tests/local-app-contracts.test.ts | 8 ++ App/frontend/desktop/src/api/config-client.ts | 2 +- .../src/api/tests/config-client.test.ts | 43 +++++++ 6 files changed, 182 insertions(+), 12 deletions(-) diff --git a/App/backend/local-api-contracts/src/index.ts b/App/backend/local-api-contracts/src/index.ts index 66d746787..09ade9431 100644 --- a/App/backend/local-api-contracts/src/index.ts +++ b/App/backend/local-api-contracts/src/index.ts @@ -731,6 +731,13 @@ export const ModelProviderSchema = z.enum([ ]); export type ModelProvider = z.infer; +/** Connection tests accept the catalog provider custom. Role model config stays on ModelProviderSchema. */ +export const ModelConfigTestProviderSchema = z.union([ + ModelProviderSchema, + z.literal("custom") +]); +export type ModelConfigTestProvider = z.infer; + export const CatalogProviderIdSchema = z.enum([ "openai", "anthropic", @@ -977,7 +984,7 @@ export type ModelConfigInput = z.infer; /** Definition for model config test input. */ export const ModelConfigTestInputSchema = z.object({ - provider: ModelProviderSchema, + provider: ModelConfigTestProviderSchema, endpointId: z.string().trim().min(1), protocol: ModelEndpointProtocolSchema, apiBase: z.string().url(), diff --git a/App/backend/src/services/model-config-tester.ts b/App/backend/src/services/model-config-tester.ts index 84aaf87f2..064522b23 100644 --- a/App/backend/src/services/model-config-tester.ts +++ b/App/backend/src/services/model-config-tester.ts @@ -2,8 +2,7 @@ import type { ModelConfigTestInput, ModelConfigTestResult, - ModelEndpointProtocol, - ModelProvider + ModelEndpointProtocol } from "@memmy/local-api-contracts"; type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise; @@ -56,7 +55,7 @@ export function createHttpModelConfigTester(options: CreateHttpModelConfigTester return result( false, response.status === 404 - ? appendBaseUrlGuidance(errorMessage, input.provider) + ? appendBaseUrlGuidance(errorMessage, input.protocol) : errorMessage, now ); @@ -68,7 +67,7 @@ export function createHttpModelConfigTester(options: CreateHttpModelConfigTester return result(false, redactSecret(providerError, input.apiKey), now); } if (!probe.isValidBody(body)) { - return result(false, appendBaseUrlGuidance(INVALID_SUCCESS_BODY_MESSAGE, input.provider), now); + return result(false, appendBaseUrlGuidance(INVALID_SUCCESS_BODY_MESSAGE, input.protocol), now); } const modelListed = probe.listedModels(body).some((model) => model === input.modelId); @@ -156,16 +155,24 @@ function resourceUrl(apiBase: string, resource: string): string { return `${apiBase.replace(/\/+$/u, "")}/${resource}`; } -function baseUrlGuidance(provider: ModelProvider): string { - if (provider === "anthropic") { +function baseUrlGuidance(protocol: ModelEndpointProtocol): string { + if (protocol === "anthropic-messages") { return "Anthropic API 地址通常不包含 /v1,例如 https://api.anthropic.com"; } - if (provider === "google") return ""; - return "OpenAI 兼容 API 地址通常以 /v1 结尾,例如 https://api.openai.com/v1"; + if (protocol === "gemini-generate-content") return ""; + if ( + protocol === "openai-chat-completions" + || protocol === "openai-responses" + || protocol === "openai-embeddings" + || protocol === "openai-images" + ) { + return "OpenAI 兼容 API 地址通常以 /v1 结尾,例如 https://api.openai.com/v1"; + } + return ""; } -function appendBaseUrlGuidance(message: string, provider: ModelProvider): string { - const hint = baseUrlGuidance(provider); +function appendBaseUrlGuidance(message: string, protocol: ModelEndpointProtocol): string { + const hint = baseUrlGuidance(protocol); if (!hint) return message; return `${message.replace(/[。\.\s]+$/u, "")}。${hint}`; } diff --git a/App/backend/src/services/tests/model-config-tester.test.ts b/App/backend/src/services/tests/model-config-tester.test.ts index dcd087bf8..d4c0029bd 100644 --- a/App/backend/src/services/tests/model-config-tester.test.ts +++ b/App/backend/src/services/tests/model-config-tester.test.ts @@ -177,6 +177,111 @@ describe("model config tester", () => { expect(JSON.stringify(response)).not.toContain("sk-secret"); }); + it.each([ + ["openai-chat-completions", "https://api.example.test/v1/models", { Authorization: "Bearer sk-secret" }], + ["openai-responses", "https://api.example.test/v1/models", { Authorization: "Bearer sk-secret" }] + ] as Array<[ModelEndpointProtocol, string, Record]>)( + "probes custom %s with the OpenAI model list", + async (protocol, url, headers) => { + const calls: Array<{ url: string; init: RequestInit }> = []; + const tester = createHttpModelConfigTester({ + fetch: async (request, init) => { + calls.push({ url: request.toString(), init: init ?? {} }); + return json({ data: [{ id: "model-a" }] }); + } + }); + + await tester.test(input({ + provider: "custom", + protocol, + apiBase: "https://api.example.test/v1" + })); + + expect(calls[0]).toMatchObject({ url, init: { method: "GET", headers } }); + } + ); + + it("probes custom Anthropic Messages with the Anthropic model list", async () => { + const calls: Array<{ url: string; init: RequestInit }> = []; + const tester = createHttpModelConfigTester({ + fetch: async (request, init) => { + calls.push({ url: request.toString(), init: init ?? {} }); + return json({ data: [{ id: "model-a" }] }); + } + }); + + await tester.test(input({ + provider: "custom", + protocol: "anthropic-messages", + apiBase: "https://api.anthropic.com" + })); + + expect(calls[0]).toMatchObject({ + url: "https://api.anthropic.com/v1/models", + init: { + method: "GET", + headers: { + "x-api-key": "sk-secret", + "anthropic-version": "2023-06-01" + } + } + }); + }); + + it("probes custom Gemini with the Gemini model list", async () => { + const calls: Array<{ url: string; init: RequestInit }> = []; + const tester = createHttpModelConfigTester({ + fetch: async (request, init) => { + calls.push({ url: request.toString(), init: init ?? {} }); + return json({ models: [{ name: "models/model-a" }] }); + } + }); + + await tester.test(input({ + provider: "custom", + protocol: "gemini-generate-content", + apiBase: "https://generativelanguage.googleapis.com/v1beta" + })); + + expect(calls[0]).toMatchObject({ + url: "https://generativelanguage.googleapis.com/v1beta/models", + init: { method: "GET", headers: { "x-goog-api-key": "sk-secret" } } + }); + }); + + it("does not probe custom Bedrock Converse", async () => { + const fetch = vi.fn(); + const tester = createHttpModelConfigTester({ now: () => checkedAt, fetch }); + + await expect(tester.test(input({ + provider: "custom", + protocol: "bedrock-converse", + apiBase: "https://bedrock-runtime.us-west-2.amazonaws.com" + }))).resolves.toEqual({ + ok: false, + message: "当前 endpoint 协议不支持模型列表连接测试", + checkedAt + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("uses the selected protocol for custom failure hints", async () => { + const tester = createHttpModelConfigTester({ + now: () => checkedAt, + fetch: async () => json({ error: { message: "not found" } }, 404) + }); + + const openai = await tester.test(input({ provider: "custom", protocol: "openai-chat-completions" })); + const anthropic = await tester.test(input({ + provider: "custom", + protocol: "anthropic-messages", + apiBase: "https://api.anthropic.com" + })); + + expect(openai.message).toContain("OpenAI 兼容 API 地址通常以 /v1 结尾"); + expect(anthropic.message).toContain("Anthropic API 地址通常不包含 /v1"); + }); + it("keeps actionable Base URL guidance on 404", async () => { const tester = createHttpModelConfigTester({ now: () => checkedAt, diff --git a/App/backend/src/tests/local-app-contracts.test.ts b/App/backend/src/tests/local-app-contracts.test.ts index 6a0623f62..69d779506 100644 --- a/App/backend/src/tests/local-app-contracts.test.ts +++ b/App/backend/src/tests/local-app-contracts.test.ts @@ -52,6 +52,7 @@ describe("local app contracts", () => { it("accepts StepFun and Xiaomi as text providers and canonicalises their catalog ids", () => { expect(ModelProviderSchema.safeParse("stepfun").success).toBe(true); expect(ModelProviderSchema.safeParse("xiaomi").success).toBe(true); + expect(ModelProviderSchema.safeParse("custom").success).toBe(false); expect(canonicalCatalogProviderId("stepfun")).toBe("stepfun"); expect(canonicalCatalogProviderId("xiaomi")).toBe("xiaomi_mimo"); @@ -409,6 +410,13 @@ describe("local app contracts", () => { expect(input.modelId).toBe("gpt-5.5"); expect(asrInput.capability).toBe("asr"); + expect(ModelConfigTestInputSchema.safeParse({ + provider: "custom", + endpointId: "chat", + protocol: "openai-chat-completions", + apiBase: "https://api.example.test/v1", + modelId: "gpt-6-sol" + }).success).toBe(true); const result = ModelConfigTestResultSchema.parse({ ok: false, diff --git a/App/frontend/desktop/src/api/config-client.ts b/App/frontend/desktop/src/api/config-client.ts index c643a54f6..95d05a157 100644 --- a/App/frontend/desktop/src/api/config-client.ts +++ b/App/frontend/desktop/src/api/config-client.ts @@ -264,7 +264,7 @@ export function createHttpConfigClient(config: RuntimeConfig): ConfigClient { path: "/api/app/model-config/test", schema: ModelConfigTestResultSchema, body: ModelConfigTestInputSchema.parse({ - provider: toModelProvider(modelConfig.provider), + provider: modelConfig.provider === "custom" ? "custom" : toModelProvider(modelConfig.provider), endpointId: modelConfig.endpointId ?? `connection-test-${secretTarget ?? capability}`, protocol: modelConfig.protocol ?? testProtocolFor(modelConfig.provider, capability), apiBase: modelConfig.endpoint, diff --git a/App/frontend/desktop/src/api/tests/config-client.test.ts b/App/frontend/desktop/src/api/tests/config-client.test.ts index eae3aa81a..dd157227b 100644 --- a/App/frontend/desktop/src/api/tests/config-client.test.ts +++ b/App/frontend/desktop/src/api/tests/config-client.test.ts @@ -249,6 +249,49 @@ describe("config-client canonical model catalog", () => { }); expect(result.ok).toBe(true); }); + + it("keeps custom as the test provider and probes by the selected protocol", async () => { + const bodies: any[] = []; + vi.stubGlobal("fetch", vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body))); + return jsonResponse({ ok: true, message: "ok", checkedAt: "2026-08-11T00:00:00.000Z" }); + })); + const client = createHttpConfigClient(config); + + await client.testModelConfig({ + provider: "custom", + endpointId: "chat", + protocol: "openai-chat-completions", + endpoint: "https://api.example.test/v1", + model: "gpt-6-sol", + apiKey: "sk-live", + apiKeyMasked: "", + configured: true + }); + await client.testModelConfig({ + provider: "custom", + endpointId: "messages", + protocol: "anthropic-messages", + endpoint: "https://api.anthropic.com", + model: "claude-sonnet", + apiKey: "sk-live", + apiKeyMasked: "", + configured: true + }); + + expect(bodies[0]).toMatchObject({ + provider: "custom", + protocol: "openai-chat-completions", + apiBase: "https://api.example.test/v1", + modelId: "gpt-6-sol" + }); + expect(bodies[1]).toMatchObject({ + provider: "custom", + protocol: "anthropic-messages", + apiBase: "https://api.anthropic.com", + modelId: "claude-sonnet" + }); + }); }); function catalog(configRevision: string): ModelConfigView { From a1fae2a421c16567c3db1e004efb22772b460593 Mon Sep 17 00:00:00 2001 From: Daoji Wang <627665797@qq.com> Date: Tue, 29 Sep 2026 20:59:15 +0800 Subject: [PATCH 09/85] Send max_completion_tokens for newer OpenAI model names on Custom Chat Completions. Co-authored-by: Cursor --- .../src/providers/custom-provider.ts | 6 ++++ .../src/providers/openai-compat-provider.ts | 5 ++- .../tests/providers/custom-provider.test.ts | 31 +++++++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 App/memmy-agent/src/providers/custom-provider.ts diff --git a/App/memmy-agent/src/providers/custom-provider.ts b/App/memmy-agent/src/providers/custom-provider.ts new file mode 100644 index 000000000..ad6ceb00b --- /dev/null +++ b/App/memmy-agent/src/providers/custom-provider.ts @@ -0,0 +1,6 @@ +const MAX_COMPLETION_TOKEN_MARKERS = ["gpt-5", "gpt-6", "o1", "o3", "o4"] as const; + +export function customModelUsesMaxCompletionTokens(modelName: string): boolean { + const name = modelName.toLowerCase(); + return MAX_COMPLETION_TOKEN_MARKERS.some((marker) => name.includes(marker)); +} diff --git a/App/memmy-agent/src/providers/openai-compat-provider.ts b/App/memmy-agent/src/providers/openai-compat-provider.ts index 5865f98a0..b62c6b5a4 100644 --- a/App/memmy-agent/src/providers/openai-compat-provider.ts +++ b/App/memmy-agent/src/providers/openai-compat-provider.ts @@ -15,6 +15,7 @@ import { convertTools, parseResponseOutput, } from "./openai-responses/index.js"; +import { customModelUsesMaxCompletionTokens } from "./custom-provider.js"; import { memmyAccountNoneThinkingStyle } from "./memmy-account.js"; import { getModelInputModalities } from "./model-input-capabilities.js"; import { OPENROUTER_ATTRIBUTION_HEADERS } from "./openrouter-attribution.js"; @@ -608,7 +609,9 @@ export class OpenAICompatProvider extends LLMProvider { if (OpenAICompatProvider.supportsTemperature(modelName, reasoningEffort)) kwargs.temperature = temperature; - if (this.spec?.supportsMaxCompletionTokens) kwargs.max_completion_tokens = maxTokens; + const useMaxCompletionTokens = Boolean(this.spec?.supportsMaxCompletionTokens) + || (specName(this.spec) === "custom" && customModelUsesMaxCompletionTokens(modelName)); + if (useMaxCompletionTokens) kwargs.max_completion_tokens = maxTokens; else kwargs.max_tokens = maxTokens; for (const [pattern, overrides] of this.spec?.modelOverrides ?? []) { diff --git a/App/memmy-agent/tests/providers/custom-provider.test.ts b/App/memmy-agent/tests/providers/custom-provider.test.ts index bfe025447..02e783daa 100644 --- a/App/memmy-agent/tests/providers/custom-provider.test.ts +++ b/App/memmy-agent/tests/providers/custom-provider.test.ts @@ -3,6 +3,7 @@ import { Config } from "../../src/config/schema.js"; import { AnthropicProvider } from "../../src/providers/anthropic-provider.js"; import { BedrockProvider } from "../../src/providers/bedrock-provider.js"; import { makeProvider } from "../../src/providers/factory.js"; +import { customModelUsesMaxCompletionTokens } from "../../src/providers/custom-provider.js"; import { OpenAICompatProvider } from "../../src/providers/openai-compat-provider.js"; import { findByName } from "../../src/providers/registry.js"; @@ -49,6 +50,23 @@ describe("custom provider", () => { } } } })).toThrow(); }); + it("uses max_completion_tokens for newer OpenAI model names", () => { + expect(customModelUsesMaxCompletionTokens("gpt-6-sol")).toBe(true); + expect(customModelUsesMaxCompletionTokens("gpt-5.4")).toBe(true); + expect(customModelUsesMaxCompletionTokens("deepseek-v4")).toBe(false); + expect(customModelUsesMaxCompletionTokens("qwen-max")).toBe(false); + }); + + it("sends max_completion_tokens only for matching custom chat models", () => { + const gpt = chatKwargs("gpt-6-sol"); + const qwen = chatKwargs("qwen-max"); + + expect(gpt.max_completion_tokens).toBe(1024); + expect(gpt).not.toHaveProperty("max_tokens"); + expect(qwen.max_tokens).toBe(1024); + expect(qwen).not.toHaveProperty("max_completion_tokens"); + }); + it("selects the client from the custom endpoint protocol", () => { const responses = makeProvider(customPresetConfig("openai-responses", "gpt-4o-mini")) as OpenAICompatProvider; const anthropic = makeProvider(customPresetConfig("anthropic-messages", "claude-sonnet")); @@ -154,6 +172,19 @@ describe("custom provider", () => { }); }); +function chatKwargs(model: string): Record { + const provider = new OpenAICompatProvider("test-key", "https://example.com/v1", model, findByName("custom")); + return provider.buildKwargs({ + messages: [{ role: "user", content: "hi" }], + tools: null, + model, + maxTokens: 1024, + temperature: 0.7, + reasoningEffort: null, + toolChoice: null, + }); +} + function customPresetConfig(protocol: string, model: string): Config { return new Config({ agents: { defaults: { provider: "custom", model, modelPreset: "chosen" } }, From f37f21fc2e4e56b8b64f57b0cb08e487c17f72c4 Mon Sep 17 00:00:00 2001 From: Daoji Wang <627665797@qq.com> Date: Wed, 30 Sep 2026 11:03:34 +0800 Subject: [PATCH 10/85] Hide thinking for OpenAI Chat Completions on newer reasoning models, and warn Custom users to turn it off. Co-authored-by: Cursor --- App/frontend/desktop/src/i18n/messages.ts | 4 +- .../src/pages/tests/settings-page.test.tsx | 11 ++++- .../desktop/src/state/model-workspace.ts | 25 +++++++++++ .../src/state/tests/model-workspace.test.ts | 29 ++++++++++++- .../src/providers/openai-compat-provider.ts | 7 ++- .../tests/providers/custom-provider.test.ts | 18 ++++++-- .../tests/providers/openai-responses.test.ts | 43 +++++++++++++++++++ 7 files changed, 129 insertions(+), 8 deletions(-) diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index aa4178b4c..a0b30b16c 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -1552,7 +1552,7 @@ export const zhCNMessages = { "settings.modelWorkspace.region": "区域", "settings.modelWorkspace.regionPlaceholder": "例如:us-east-1", "settings.modelWorkspace.customThinkingEnabled": "支持思考", - "settings.modelWorkspace.customThinkingEnabledHint": "勾选后,输入框可以设置思考。不勾选则不传思考参数,由模型接口按默认行为处理。", + "settings.modelWorkspace.customThinkingEnabledHint": "勾选后,输入框可以设置思考。不勾选则不传思考参数,由模型接口按默认行为处理。使用 Chat Completions 并且使用 gpt 模型时,请关闭支持思考,否则可能导致错误。", "settings.modelWorkspace.customThinkingSwitchable": "可关闭思考", "settings.modelWorkspace.customThinkingSwitchableHint": "勾选后,对话里可以临时关闭思考。不勾选则思考始终开启,输入框不显示思考开关。", "settings.modelWorkspace.customThinkingDefaultEnabled": "默认开启思考", @@ -3323,7 +3323,7 @@ export const enUSMessages: Record = { "settings.modelWorkspace.region": "Region", "settings.modelWorkspace.regionPlaceholder": "For example: us-east-1", "settings.modelWorkspace.customThinkingEnabled": "Supports thinking", - "settings.modelWorkspace.customThinkingEnabledHint": "When checked, the composer can set thinking. When unchecked, no thinking parameters are sent and the model endpoint uses its default behavior.", + "settings.modelWorkspace.customThinkingEnabledHint": "When checked, the composer can set thinking. When unchecked, no thinking parameters are sent and the model endpoint uses its default behavior. When using Chat Completions with a GPT model, turn off Supports thinking, or the request may fail.", "settings.modelWorkspace.customThinkingSwitchable": "Thinking can be turned off", "settings.modelWorkspace.customThinkingSwitchableHint": "When checked, a conversation can turn thinking off. When unchecked, thinking stays on and the composer hides the thinking switch.", "settings.modelWorkspace.customThinkingDefaultEnabled": "Thinking on by default", diff --git a/App/frontend/desktop/src/pages/tests/settings-page.test.tsx b/App/frontend/desktop/src/pages/tests/settings-page.test.tsx index 543fb1de4..f22e844f5 100644 --- a/App/frontend/desktop/src/pages/tests/settings-page.test.tsx +++ b/App/frontend/desktop/src/pages/tests/settings-page.test.tsx @@ -25,7 +25,7 @@ import { shouldSaveAccountNicknameOnKeyDown, writeLogLevel } from "../settings-page.js"; -import { formatMessage, zhCNMessages } from "../../i18n/messages.js"; +import { enUSMessages, formatMessage, zhCNMessages } from "../../i18n/messages.js"; import { availableConnectionProtocols, catalogProviderForEditor, @@ -225,6 +225,15 @@ describe("自定义模型能力选择", () => { expect(source).not.toContain('t("settings.modelWorkspace.textRoles")'); expect(source).not.toContain('t("settings.modelWorkspace.capability.agent")'); }); + + it("支持思考的说明提醒 Chat Completions 的 gpt 模型关掉思考", () => { + expect(zhCNMessages["settings.modelWorkspace.customThinkingEnabledHint"]).toContain( + "使用 Chat Completions 并且使用 gpt 模型时,请关闭支持思考,否则可能导致错误。" + ); + expect(enUSMessages["settings.modelWorkspace.customThinkingEnabledHint"]).toContain( + "When using Chat Completions with a GPT model, turn off Supports thinking, or the request may fail." + ); + }); }); describe("日志级别本地持久化", () => { diff --git a/App/frontend/desktop/src/state/model-workspace.ts b/App/frontend/desktop/src/state/model-workspace.ts index 162b93697..442d97aad 100644 --- a/App/frontend/desktop/src/state/model-workspace.ts +++ b/App/frontend/desktop/src/state/model-workspace.ts @@ -974,6 +974,24 @@ export function customThinkingConfigFor( return modelEntryFor(workspace, candidate)?.thinking ?? null; } +const CHAT_COMPLETIONS_THINKING_MARKERS = ["gpt-5", "gpt-6", "o1", "o3", "o4"] as const; + +function endpointProtocolFor( + workspace: ModelWorkspace, + candidate: ModelCandidate +): ModelEndpointProtocol | undefined { + for (const space of Object.values(workspace.spaces)) { + const connection = space.connections.find((item) => item.id === candidate.connectionId); + if (connection) return connection.protocol; + } + return undefined; +} + +function chatCompletionsHidesCatalogThinking(modelName: string): boolean { + const name = modelName.toLowerCase(); + return CHAT_COMPLETIONS_THINKING_MARKERS.some((marker) => name.includes(marker)); +} + export function resolveThinkingConfigForModel( workspace: ModelWorkspace, candidate: ModelCandidate | null | undefined @@ -981,5 +999,12 @@ export function resolveThinkingConfigForModel( if (isCustomModelEntry(workspace, candidate)) { return customThinkingConfigFor(workspace, candidate); } + if ( + candidate + && endpointProtocolFor(workspace, candidate) === "openai-chat-completions" + && chatCompletionsHidesCatalogThinking(candidate.model) + ) { + return null; + } return getModelThinkingConfig(candidate?.model); } diff --git a/App/frontend/desktop/src/state/tests/model-workspace.test.ts b/App/frontend/desktop/src/state/tests/model-workspace.test.ts index ebf1f7fd2..bf59a1ca1 100644 --- a/App/frontend/desktop/src/state/tests/model-workspace.test.ts +++ b/App/frontend/desktop/src/state/tests/model-workspace.test.ts @@ -3,8 +3,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { BUILTIN_LOCAL_EMBEDDING_ASSIGNMENT_ID, + getModelThinkingConfig, type ModelConfigInput, - type ModelConfigView + type ModelConfigView, + type ModelEndpointProtocol } from "@memmy/local-api-contracts"; import YAML from "yaml"; import { afterEach, describe, expect, it } from "vitest"; @@ -1140,4 +1142,29 @@ describe("canonical model workspace adapter", () => { inputModalities: ["text", "image"] }); }); + + it("OpenAI Chat Completions 的 gpt-6 不提供目录思考,Responses 仍提供", () => { + const chat = openaiThinkingWorkspace("openai-chat-completions"); + const responses = openaiThinkingWorkspace("openai-responses"); + const chatCandidate = getTaskModelCandidates(chat, "byok").find((item) => item.model === "gpt-6-sol")!; + const responsesCandidate = getTaskModelCandidates(responses, "byok").find((item) => item.model === "gpt-6-sol")!; + + expect(resolveThinkingConfigForModel(chat, chatCandidate)).toBeNull(); + expect(resolveThinkingConfigForModel(responses, responsesCandidate)).toEqual(getModelThinkingConfig("gpt-6-sol")); + }); }); + +function openaiThinkingWorkspace(protocol: ModelEndpointProtocol) { + return upsertModelConnection(createModelWorkspace(null), "byok", { + provider: "openai", + endpoint: "https://api.openai.com/v1", + protocol, + apiKey: "sk-test", + models: ["gpt-6-sol"], + modelEntries: [{ + model: "gpt-6-sol", + capability: "chat", + capabilities: ["chat", "memorySummary", "memoryEvolution"] + }] + }).workspace; +} diff --git a/App/memmy-agent/src/providers/openai-compat-provider.ts b/App/memmy-agent/src/providers/openai-compat-provider.ts index b62c6b5a4..d7eb7fc0f 100644 --- a/App/memmy-agent/src/providers/openai-compat-provider.ts +++ b/App/memmy-agent/src/providers/openai-compat-provider.ts @@ -621,7 +621,12 @@ export class OpenAICompatProvider extends LLMProvider { } } - if (wireEffort && semanticEffort !== "none") kwargs.reasoning_effort = wireEffort; + const omitReasoningEffortForTools = specName(this.spec) === "openai" + && Boolean(tools?.length) + && customModelUsesMaxCompletionTokens(modelName); + if (wireEffort && semanticEffort !== "none" && !omitReasoningEffortForTools) { + kwargs.reasoning_effort = wireEffort; + } if (reasoningEffort !== null) { const thinkingEnabled = semanticEffort !== "none" && semanticEffort !== "minimal"; const styles = thinkingStylesFor(this.spec, modelName); diff --git a/App/memmy-agent/tests/providers/custom-provider.test.ts b/App/memmy-agent/tests/providers/custom-provider.test.ts index 02e783daa..aae3e0cb3 100644 --- a/App/memmy-agent/tests/providers/custom-provider.test.ts +++ b/App/memmy-agent/tests/providers/custom-provider.test.ts @@ -67,6 +67,15 @@ describe("custom provider", () => { expect(qwen).not.toHaveProperty("max_completion_tokens"); }); + it("keeps reasoning_effort when custom thinking is on", () => { + const kwargs = chatKwargs("gpt-6-sol", { + reasoningEffort: "medium", + tools: [{ type: "function", function: { name: "read_file" } }], + }); + + expect(kwargs.reasoning_effort).toBe("medium"); + }); + it("selects the client from the custom endpoint protocol", () => { const responses = makeProvider(customPresetConfig("openai-responses", "gpt-4o-mini")) as OpenAICompatProvider; const anthropic = makeProvider(customPresetConfig("anthropic-messages", "claude-sonnet")); @@ -172,15 +181,18 @@ describe("custom provider", () => { }); }); -function chatKwargs(model: string): Record { +function chatKwargs( + model: string, + overrides: { reasoningEffort?: string | null; tools?: Record[] | null } = {}, +): Record { const provider = new OpenAICompatProvider("test-key", "https://example.com/v1", model, findByName("custom")); return provider.buildKwargs({ messages: [{ role: "user", content: "hi" }], - tools: null, + tools: overrides.tools ?? null, model, maxTokens: 1024, temperature: 0.7, - reasoningEffort: null, + reasoningEffort: overrides.reasoningEffort ?? null, toolChoice: null, }); } diff --git a/App/memmy-agent/tests/providers/openai-responses.test.ts b/App/memmy-agent/tests/providers/openai-responses.test.ts index 37fc97cab..d2443c1f1 100644 --- a/App/memmy-agent/tests/providers/openai-responses.test.ts +++ b/App/memmy-agent/tests/providers/openai-responses.test.ts @@ -569,3 +569,46 @@ describe("OpenAI Responses consumeSdkStream", () => { expect(toolCalls[0].arguments).toEqual({ bad: null }); }); }); + +describe("OpenAI chat completions reasoning effort", () => { + const tool = [{ type: "function", function: { name: "read_file" } }]; + + it("omits reasoning_effort when gpt-6-sol is called with tools", () => { + const kwargs = chatKwargs("gpt-6-sol", tool); + + expect(kwargs).not.toHaveProperty("reasoning_effort"); + expect(kwargs.tools).toEqual(tool); + }); + + it("keeps reasoning_effort when gpt-6-sol is called without tools", () => { + expect(chatKwargs("gpt-6-sol", null).reasoning_effort).toBe("medium"); + }); + + it("keeps reasoning effort on the responses body", () => { + const provider = new OpenAICompatProvider("test-key", "https://api.openai.com/v1", "gpt-6-sol", findByName("openai")); + const body = provider.buildResponsesBody({ + messages: [{ role: "user", content: "hi" }], + tools: tool, + model: "gpt-6-sol", + maxTokens: 1024, + temperature: 0.7, + reasoningEffort: "medium", + toolChoice: null, + }); + + expect(body.reasoning).toEqual({ effort: "medium" }); + }); +}); + +function chatKwargs(model: string, tools: Record[] | null): Record { + const provider = new OpenAICompatProvider("test-key", "https://api.openai.com/v1", model, findByName("openai")); + return provider.buildKwargs({ + messages: [{ role: "user", content: "hi" }], + tools, + model, + maxTokens: 1024, + temperature: 0.7, + reasoningEffort: "medium", + toolChoice: null, + }); +} From 407cf0298240a6292ffbe680f53aba4351a6c4f1 Mon Sep 17 00:00:00 2001 From: Daoji Wang <627665797@qq.com> Date: Wed, 30 Sep 2026 11:27:29 +0800 Subject: [PATCH 11/85] Send reasoning_effort none for OpenAI Chat Completions when newer reasoning models are called with tools. Co-authored-by: Cursor --- .../src/providers/openai-compat-provider.ts | 7 +++---- .../tests/providers/openai-responses.test.ts | 20 +++++++++++++------ 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/App/memmy-agent/src/providers/openai-compat-provider.ts b/App/memmy-agent/src/providers/openai-compat-provider.ts index d7eb7fc0f..103d85f40 100644 --- a/App/memmy-agent/src/providers/openai-compat-provider.ts +++ b/App/memmy-agent/src/providers/openai-compat-provider.ts @@ -621,12 +621,11 @@ export class OpenAICompatProvider extends LLMProvider { } } - const omitReasoningEffortForTools = specName(this.spec) === "openai" + const forceNoneReasoningEffort = specName(this.spec) === "openai" && Boolean(tools?.length) && customModelUsesMaxCompletionTokens(modelName); - if (wireEffort && semanticEffort !== "none" && !omitReasoningEffortForTools) { - kwargs.reasoning_effort = wireEffort; - } + if (forceNoneReasoningEffort) kwargs.reasoning_effort = "none"; + else if (wireEffort && semanticEffort !== "none") kwargs.reasoning_effort = wireEffort; if (reasoningEffort !== null) { const thinkingEnabled = semanticEffort !== "none" && semanticEffort !== "minimal"; const styles = thinkingStylesFor(this.spec, modelName); diff --git a/App/memmy-agent/tests/providers/openai-responses.test.ts b/App/memmy-agent/tests/providers/openai-responses.test.ts index d2443c1f1..c4db8394d 100644 --- a/App/memmy-agent/tests/providers/openai-responses.test.ts +++ b/App/memmy-agent/tests/providers/openai-responses.test.ts @@ -573,15 +573,19 @@ describe("OpenAI Responses consumeSdkStream", () => { describe("OpenAI chat completions reasoning effort", () => { const tool = [{ type: "function", function: { name: "read_file" } }]; - it("omits reasoning_effort when gpt-6-sol is called with tools", () => { - const kwargs = chatKwargs("gpt-6-sol", tool); + it("sends reasoning_effort none when gpt-6-sol is called with tools", () => { + const kwargs = chatKwargs("gpt-6-sol", tool, "medium"); - expect(kwargs).not.toHaveProperty("reasoning_effort"); + expect(kwargs.reasoning_effort).toBe("none"); expect(kwargs.tools).toEqual(tool); }); + it("sends reasoning_effort none when tools are present and no effort was requested", () => { + expect(chatKwargs("gpt-6-astra", tool, null).reasoning_effort).toBe("none"); + }); + it("keeps reasoning_effort when gpt-6-sol is called without tools", () => { - expect(chatKwargs("gpt-6-sol", null).reasoning_effort).toBe("medium"); + expect(chatKwargs("gpt-6-sol", null, "medium").reasoning_effort).toBe("medium"); }); it("keeps reasoning effort on the responses body", () => { @@ -600,7 +604,11 @@ describe("OpenAI chat completions reasoning effort", () => { }); }); -function chatKwargs(model: string, tools: Record[] | null): Record { +function chatKwargs( + model: string, + tools: Record[] | null, + reasoningEffort: string | null, +): Record { const provider = new OpenAICompatProvider("test-key", "https://api.openai.com/v1", model, findByName("openai")); return provider.buildKwargs({ messages: [{ role: "user", content: "hi" }], @@ -608,7 +616,7 @@ function chatKwargs(model: string, tools: Record[] | null): Rec model, maxTokens: 1024, temperature: 0.7, - reasoningEffort: "medium", + reasoningEffort, toolChoice: null, }); } From 85893b7956eab10b69ada399750d7958ea9e448b Mon Sep 17 00:00:00 2001 From: Daoji Wang <627665797@qq.com> Date: Wed, 30 Sep 2026 11:52:40 +0800 Subject: [PATCH 12/85] Send reasoning_effort none on Chat Completions for any GPT or o-series model with tools, including Custom when thinking is off. Co-authored-by: Cursor --- .../desktop/src/state/model-workspace.ts | 5 ++- .../src/state/tests/model-workspace.test.ts | 20 ++++++--- .../src/core/agent-runtime/loop.ts | 2 +- .../src/providers/custom-provider.ts | 6 +++ .../src/providers/openai-compat-provider.ts | 14 +++++-- .../agent-runtime/thinking-effort.test.ts | 8 ++++ .../tests/providers/custom-provider.test.ts | 41 ++++++++++++++++++- .../tests/providers/openai-responses.test.ts | 1 + 8 files changed, 85 insertions(+), 12 deletions(-) diff --git a/App/frontend/desktop/src/state/model-workspace.ts b/App/frontend/desktop/src/state/model-workspace.ts index 442d97aad..41a3e7dd5 100644 --- a/App/frontend/desktop/src/state/model-workspace.ts +++ b/App/frontend/desktop/src/state/model-workspace.ts @@ -974,7 +974,7 @@ export function customThinkingConfigFor( return modelEntryFor(workspace, candidate)?.thinking ?? null; } -const CHAT_COMPLETIONS_THINKING_MARKERS = ["gpt-5", "gpt-6", "o1", "o3", "o4"] as const; +const CHAT_COMPLETIONS_O_SERIES_MARKERS = ["o1", "o3", "o4"] as const; function endpointProtocolFor( workspace: ModelWorkspace, @@ -989,7 +989,8 @@ function endpointProtocolFor( function chatCompletionsHidesCatalogThinking(modelName: string): boolean { const name = modelName.toLowerCase(); - return CHAT_COMPLETIONS_THINKING_MARKERS.some((marker) => name.includes(marker)); + return name.includes("gpt-") + || CHAT_COMPLETIONS_O_SERIES_MARKERS.some((marker) => name.includes(marker)); } export function resolveThinkingConfigForModel( diff --git a/App/frontend/desktop/src/state/tests/model-workspace.test.ts b/App/frontend/desktop/src/state/tests/model-workspace.test.ts index bf59a1ca1..2ca7ab61b 100644 --- a/App/frontend/desktop/src/state/tests/model-workspace.test.ts +++ b/App/frontend/desktop/src/state/tests/model-workspace.test.ts @@ -1144,25 +1144,35 @@ describe("canonical model workspace adapter", () => { }); it("OpenAI Chat Completions 的 gpt-6 不提供目录思考,Responses 仍提供", () => { - const chat = openaiThinkingWorkspace("openai-chat-completions"); - const responses = openaiThinkingWorkspace("openai-responses"); + const chat = openaiThinkingWorkspace("openai-chat-completions", "gpt-6-sol"); + const responses = openaiThinkingWorkspace("openai-responses", "gpt-6-sol"); const chatCandidate = getTaskModelCandidates(chat, "byok").find((item) => item.model === "gpt-6-sol")!; const responsesCandidate = getTaskModelCandidates(responses, "byok").find((item) => item.model === "gpt-6-sol")!; expect(resolveThinkingConfigForModel(chat, chatCandidate)).toBeNull(); expect(resolveThinkingConfigForModel(responses, responsesCandidate)).toEqual(getModelThinkingConfig("gpt-6-sol")); }); + + it("OpenAI Chat Completions 的 gpt-4o 不提供目录思考,Responses 仍按目录", () => { + const chat = openaiThinkingWorkspace("openai-chat-completions", "gpt-4o"); + const responses = openaiThinkingWorkspace("openai-responses", "gpt-4o"); + const chatCandidate = getTaskModelCandidates(chat, "byok").find((item) => item.model === "gpt-4o")!; + const responsesCandidate = getTaskModelCandidates(responses, "byok").find((item) => item.model === "gpt-4o")!; + + expect(resolveThinkingConfigForModel(chat, chatCandidate)).toBeNull(); + expect(resolveThinkingConfigForModel(responses, responsesCandidate)).toEqual(getModelThinkingConfig("gpt-4o")); + }); }); -function openaiThinkingWorkspace(protocol: ModelEndpointProtocol) { +function openaiThinkingWorkspace(protocol: ModelEndpointProtocol, model: string) { return upsertModelConnection(createModelWorkspace(null), "byok", { provider: "openai", endpoint: "https://api.openai.com/v1", protocol, apiKey: "sk-test", - models: ["gpt-6-sol"], + models: [model], modelEntries: [{ - model: "gpt-6-sol", + model, capability: "chat", capabilities: ["chat", "memorySummary", "memoryEvolution"] }] diff --git a/App/memmy-agent/src/core/agent-runtime/loop.ts b/App/memmy-agent/src/core/agent-runtime/loop.ts index 7863308c6..eab9fcbc6 100644 --- a/App/memmy-agent/src/core/agent-runtime/loop.ts +++ b/App/memmy-agent/src/core/agent-runtime/loop.ts @@ -707,7 +707,7 @@ export function reasoningEffortFromTurnMetadata( model: string | null | undefined, ): string | undefined { if (typeof metadata?.thinking_enabled !== "boolean") return undefined; - const config = getModelThinkingConfig(model); + const config = metadata.model_provider === "custom" ? null : getModelThinkingConfig(model); const enabled = config && !config.switchable ? true : metadata.thinking_enabled; if (!enabled) return "none"; if (typeof metadata.thinking_level === "string" && metadata.thinking_level.trim()) { diff --git a/App/memmy-agent/src/providers/custom-provider.ts b/App/memmy-agent/src/providers/custom-provider.ts index ad6ceb00b..0bccc0023 100644 --- a/App/memmy-agent/src/providers/custom-provider.ts +++ b/App/memmy-agent/src/providers/custom-provider.ts @@ -1,6 +1,12 @@ const MAX_COMPLETION_TOKEN_MARKERS = ["gpt-5", "gpt-6", "o1", "o3", "o4"] as const; +const O_SERIES_MARKERS = ["o1", "o3", "o4"] as const; export function customModelUsesMaxCompletionTokens(modelName: string): boolean { const name = modelName.toLowerCase(); return MAX_COMPLETION_TOKEN_MARKERS.some((marker) => name.includes(marker)); } + +export function chatCompletionsModelForcesNoneReasoning(modelName: string): boolean { + const name = modelName.toLowerCase(); + return name.includes("gpt-") || O_SERIES_MARKERS.some((marker) => name.includes(marker)); +} diff --git a/App/memmy-agent/src/providers/openai-compat-provider.ts b/App/memmy-agent/src/providers/openai-compat-provider.ts index 103d85f40..72a252ac8 100644 --- a/App/memmy-agent/src/providers/openai-compat-provider.ts +++ b/App/memmy-agent/src/providers/openai-compat-provider.ts @@ -15,7 +15,10 @@ import { convertTools, parseResponseOutput, } from "./openai-responses/index.js"; -import { customModelUsesMaxCompletionTokens } from "./custom-provider.js"; +import { + chatCompletionsModelForcesNoneReasoning, + customModelUsesMaxCompletionTokens, +} from "./custom-provider.js"; import { memmyAccountNoneThinkingStyle } from "./memmy-account.js"; import { getModelInputModalities } from "./model-input-capabilities.js"; import { OPENROUTER_ATTRIBUTION_HEADERS } from "./openrouter-attribution.js"; @@ -621,9 +624,14 @@ export class OpenAICompatProvider extends LLMProvider { } } - const forceNoneReasoningEffort = specName(this.spec) === "openai" + const chatCompletionsGptWithTools = this.apiType === "chatCompletions" && Boolean(tools?.length) - && customModelUsesMaxCompletionTokens(modelName); + && chatCompletionsModelForcesNoneReasoning(modelName); + const providerName = specName(this.spec); + const thinkingOff = !wireEffort || semanticEffort === "none"; + const forceNoneReasoningEffort = chatCompletionsGptWithTools && ( + providerName === "openai" || (providerName === "custom" && thinkingOff) + ); if (forceNoneReasoningEffort) kwargs.reasoning_effort = "none"; else if (wireEffort && semanticEffort !== "none") kwargs.reasoning_effort = wireEffort; if (reasoningEffort !== null) { diff --git a/App/memmy-agent/tests/core/agent-runtime/thinking-effort.test.ts b/App/memmy-agent/tests/core/agent-runtime/thinking-effort.test.ts index 1052542be..3750aa897 100644 --- a/App/memmy-agent/tests/core/agent-runtime/thinking-effort.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/thinking-effort.test.ts @@ -18,4 +18,12 @@ describe("reasoningEffortFromTurnMetadata", () => { thinking_level: "low", }, "claude-fable-5")).toBe("low"); }); + + it("lets a custom model turn thinking off even when the catalog model is always on", () => { + expect(reasoningEffortFromTurnMetadata({ + model_provider: "custom", + thinking_enabled: false, + thinking_level: "low", + }, "gpt-6-astra")).toBe("none"); + }); }); diff --git a/App/memmy-agent/tests/providers/custom-provider.test.ts b/App/memmy-agent/tests/providers/custom-provider.test.ts index aae3e0cb3..f80b528a9 100644 --- a/App/memmy-agent/tests/providers/custom-provider.test.ts +++ b/App/memmy-agent/tests/providers/custom-provider.test.ts @@ -3,7 +3,10 @@ import { Config } from "../../src/config/schema.js"; import { AnthropicProvider } from "../../src/providers/anthropic-provider.js"; import { BedrockProvider } from "../../src/providers/bedrock-provider.js"; import { makeProvider } from "../../src/providers/factory.js"; -import { customModelUsesMaxCompletionTokens } from "../../src/providers/custom-provider.js"; +import { + chatCompletionsModelForcesNoneReasoning, + customModelUsesMaxCompletionTokens, +} from "../../src/providers/custom-provider.js"; import { OpenAICompatProvider } from "../../src/providers/openai-compat-provider.js"; import { findByName } from "../../src/providers/registry.js"; @@ -55,6 +58,14 @@ describe("custom provider", () => { expect(customModelUsesMaxCompletionTokens("gpt-5.4")).toBe(true); expect(customModelUsesMaxCompletionTokens("deepseek-v4")).toBe(false); expect(customModelUsesMaxCompletionTokens("qwen-max")).toBe(false); + expect(customModelUsesMaxCompletionTokens("gpt-4o")).toBe(false); + }); + + it("treats gpt- names and o-series names as chat completions reasoning models", () => { + expect(chatCompletionsModelForcesNoneReasoning("gpt-4o")).toBe(true); + expect(chatCompletionsModelForcesNoneReasoning("gpt-6-sol")).toBe(true); + expect(chatCompletionsModelForcesNoneReasoning("o3-mini")).toBe(true); + expect(chatCompletionsModelForcesNoneReasoning("qwen-max")).toBe(false); }); it("sends max_completion_tokens only for matching custom chat models", () => { @@ -76,6 +87,33 @@ describe("custom provider", () => { expect(kwargs.reasoning_effort).toBe("medium"); }); + it("sends reasoning_effort none for custom chat completions gpt models when thinking is off", () => { + const tools = [{ type: "function", function: { name: "read_file" } }]; + + expect(chatKwargs("gpt-6-sol", { tools }).reasoning_effort).toBe("none"); + expect(chatKwargs("gpt-6-sol", { reasoningEffort: "none", tools }).reasoning_effort).toBe("none"); + expect(chatKwargs("gpt-4o", { tools }).reasoning_effort).toBe("none"); + expect(chatKwargs("gpt-4o", { tools }).max_tokens).toBe(1024); + expect(chatKwargs("gpt-4o", { tools })).not.toHaveProperty("max_completion_tokens"); + expect(chatKwargs("qwen-max", { tools })).not.toHaveProperty("reasoning_effort"); + }); + + it("does not force reasoning_effort none for custom responses", () => { + const provider = new OpenAICompatProvider("test-key", "https://example.com/v1", "gpt-6-sol", findByName("custom")); + provider.apiType = "responses"; + const kwargs = provider.buildKwargs({ + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "read_file" } }], + model: "gpt-6-sol", + maxTokens: 1024, + temperature: 0.7, + reasoningEffort: null, + toolChoice: null, + }); + + expect(kwargs).not.toHaveProperty("reasoning_effort"); + }); + it("selects the client from the custom endpoint protocol", () => { const responses = makeProvider(customPresetConfig("openai-responses", "gpt-4o-mini")) as OpenAICompatProvider; const anthropic = makeProvider(customPresetConfig("anthropic-messages", "claude-sonnet")); @@ -186,6 +224,7 @@ function chatKwargs( overrides: { reasoningEffort?: string | null; tools?: Record[] | null } = {}, ): Record { const provider = new OpenAICompatProvider("test-key", "https://example.com/v1", model, findByName("custom")); + provider.apiType = "chatCompletions"; return provider.buildKwargs({ messages: [{ role: "user", content: "hi" }], tools: overrides.tools ?? null, diff --git a/App/memmy-agent/tests/providers/openai-responses.test.ts b/App/memmy-agent/tests/providers/openai-responses.test.ts index c4db8394d..ef49beff1 100644 --- a/App/memmy-agent/tests/providers/openai-responses.test.ts +++ b/App/memmy-agent/tests/providers/openai-responses.test.ts @@ -610,6 +610,7 @@ function chatKwargs( reasoningEffort: string | null, ): Record { const provider = new OpenAICompatProvider("test-key", "https://api.openai.com/v1", model, findByName("openai")); + provider.apiType = "chatCompletions"; return provider.buildKwargs({ messages: [{ role: "user", content: "hi" }], tools, From 1785fafa8bdb13f76ae9f514a46491065d768735 Mon Sep 17 00:00:00 2001 From: Daoji Wang <627665797@qq.com> Date: Wed, 30 Sep 2026 11:57:58 +0800 Subject: [PATCH 13/85] =?UTF-8?q?Recommend=20the=20Responses=20API=20for?= =?UTF-8?q?=20GPT=20models=20in=20the=20Custom=20thinking=20hint,=20and=20?= =?UTF-8?q?show=20=E8=87=AA=E5=AE=9A=E4=B9=89=20for=20that=20provider=20in?= =?UTF-8?q?=20Chinese.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- App/frontend/desktop/src/i18n/messages.ts | 6 +++--- App/frontend/desktop/src/pages/tests/model-config.test.ts | 3 ++- App/frontend/desktop/src/pages/tests/settings-page.test.tsx | 6 ++++++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index a0b30b16c..f62fcdedf 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -319,7 +319,7 @@ export const zhCNMessages = { "apiKey.provider.stepfun": "阶跃星辰", "apiKey.provider.xiaomi": "小米 MiMo", "apiKey.provider.memmy": "Memmy", - "apiKey.provider.custom": "Custom", + "apiKey.provider.custom": "自定义", "apiKey.customProtocol.openaiCompletions": "OpenAI Chat Completions", "apiKey.customProtocol.openaiResponses": "OpenAI Responses", "apiKey.customProtocol.anthropicMessages": "Anthropic Messages", @@ -1552,7 +1552,7 @@ export const zhCNMessages = { "settings.modelWorkspace.region": "区域", "settings.modelWorkspace.regionPlaceholder": "例如:us-east-1", "settings.modelWorkspace.customThinkingEnabled": "支持思考", - "settings.modelWorkspace.customThinkingEnabledHint": "勾选后,输入框可以设置思考。不勾选则不传思考参数,由模型接口按默认行为处理。使用 Chat Completions 并且使用 gpt 模型时,请关闭支持思考,否则可能导致错误。", + "settings.modelWorkspace.customThinkingEnabledHint": "勾选后,输入框可以设置思考。不勾选则不传思考参数,由模型接口按默认行为处理。使用 Chat Completions 并且使用 gpt 模型时,请关闭支持思考,否则可能导致错误。gpt 模型建议使用 Responses 接口获得最佳体验。", "settings.modelWorkspace.customThinkingSwitchable": "可关闭思考", "settings.modelWorkspace.customThinkingSwitchableHint": "勾选后,对话里可以临时关闭思考。不勾选则思考始终开启,输入框不显示思考开关。", "settings.modelWorkspace.customThinkingDefaultEnabled": "默认开启思考", @@ -3323,7 +3323,7 @@ export const enUSMessages: Record = { "settings.modelWorkspace.region": "Region", "settings.modelWorkspace.regionPlaceholder": "For example: us-east-1", "settings.modelWorkspace.customThinkingEnabled": "Supports thinking", - "settings.modelWorkspace.customThinkingEnabledHint": "When checked, the composer can set thinking. When unchecked, no thinking parameters are sent and the model endpoint uses its default behavior. When using Chat Completions with a GPT model, turn off Supports thinking, or the request may fail.", + "settings.modelWorkspace.customThinkingEnabledHint": "When checked, the composer can set thinking. When unchecked, no thinking parameters are sent and the model endpoint uses its default behavior. When using Chat Completions with a GPT model, turn off Supports thinking, or the request may fail. GPT models should use the Responses API for the best experience.", "settings.modelWorkspace.customThinkingSwitchable": "Thinking can be turned off", "settings.modelWorkspace.customThinkingSwitchableHint": "When checked, a conversation can turn thinking off. When unchecked, thinking stays on and the composer hides the thinking switch.", "settings.modelWorkspace.customThinkingDefaultEnabled": "Thinking on by default", diff --git a/App/frontend/desktop/src/pages/tests/model-config.test.ts b/App/frontend/desktop/src/pages/tests/model-config.test.ts index 7df7ea6cd..052e84bfd 100644 --- a/App/frontend/desktop/src/pages/tests/model-config.test.ts +++ b/App/frontend/desktop/src/pages/tests/model-config.test.ts @@ -550,7 +550,8 @@ describe("model config helpers", () => { expect(fromProtocol("custom")).toBe("custom"); expect(zhCNMessages["apiKey.provider.openaiCompletions"]).toBe("OpenAI Completions"); expect(zhCNMessages["apiKey.provider.openaiResponses"]).toBe("OpenAI Responses"); - expect(zhCNMessages["apiKey.provider.custom"]).toBe("Custom"); + expect(zhCNMessages["apiKey.provider.custom"]).toBe("自定义"); + expect(enUSMessages["apiKey.provider.custom"]).toBe("Custom"); }); it("中英文字典键集合一致", () => { diff --git a/App/frontend/desktop/src/pages/tests/settings-page.test.tsx b/App/frontend/desktop/src/pages/tests/settings-page.test.tsx index f22e844f5..0fb79f27f 100644 --- a/App/frontend/desktop/src/pages/tests/settings-page.test.tsx +++ b/App/frontend/desktop/src/pages/tests/settings-page.test.tsx @@ -230,9 +230,15 @@ describe("自定义模型能力选择", () => { expect(zhCNMessages["settings.modelWorkspace.customThinkingEnabledHint"]).toContain( "使用 Chat Completions 并且使用 gpt 模型时,请关闭支持思考,否则可能导致错误。" ); + expect(zhCNMessages["settings.modelWorkspace.customThinkingEnabledHint"]).toContain( + "gpt 模型建议使用 Responses 接口获得最佳体验。" + ); expect(enUSMessages["settings.modelWorkspace.customThinkingEnabledHint"]).toContain( "When using Chat Completions with a GPT model, turn off Supports thinking, or the request may fail." ); + expect(enUSMessages["settings.modelWorkspace.customThinkingEnabledHint"]).toContain( + "GPT models should use the Responses API for the best experience." + ); }); }); From 69cfa4c83f91249c6ead34a6d8fcea4acd3a3de9 Mon Sep 17 00:00:00 2001 From: Daoji Wang <627665797@qq.com> Date: Wed, 30 Sep 2026 15:22:37 +0800 Subject: [PATCH 14/85] Give the Custom provider its own puzzle mark so it is no longer shown with the OpenAI logo. Co-authored-by: Cursor --- App/frontend/desktop/src/assets/model-logos/custom.svg | 1 + App/frontend/desktop/src/components/model-provider-logo.tsx | 3 ++- App/frontend/desktop/src/pages/tests/model-config.test.ts | 6 ++++++ 3 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 App/frontend/desktop/src/assets/model-logos/custom.svg diff --git a/App/frontend/desktop/src/assets/model-logos/custom.svg b/App/frontend/desktop/src/assets/model-logos/custom.svg new file mode 100644 index 000000000..5773776b0 --- /dev/null +++ b/App/frontend/desktop/src/assets/model-logos/custom.svg @@ -0,0 +1 @@ +Custom diff --git a/App/frontend/desktop/src/components/model-provider-logo.tsx b/App/frontend/desktop/src/components/model-provider-logo.tsx index a00e0e748..143c9069f 100644 --- a/App/frontend/desktop/src/components/model-provider-logo.tsx +++ b/App/frontend/desktop/src/components/model-provider-logo.tsx @@ -1,6 +1,7 @@ import memmyCoverLogoUrl from "../assets/brand/memmy-cover.png"; import anthropicLogoUrl from "../assets/model-logos/anthropic.svg"; import baiduLogoUrl from "../assets/model-logos/baidu.svg"; +import customLogoUrl from "../assets/model-logos/custom.svg"; import deepseekLogoUrl from "../assets/model-logos/deepseek.svg"; import doubaoLogoUrl from "../assets/model-logos/doubao.svg"; import geminiLogoUrl from "../assets/model-logos/gemini.svg"; @@ -29,7 +30,7 @@ const PROTOCOL_LOGO_URLS: Record = { doubao: doubaoLogoUrl, stepfun: stepfunLogoUrl, xiaomi: xiaomiLogoUrl, - custom: openaiLogoUrl + custom: customLogoUrl }; /** Resolve a logo URL for a protocol or free-form provider string. */ diff --git a/App/frontend/desktop/src/pages/tests/model-config.test.ts b/App/frontend/desktop/src/pages/tests/model-config.test.ts index 052e84bfd..290b9a3fa 100644 --- a/App/frontend/desktop/src/pages/tests/model-config.test.ts +++ b/App/frontend/desktop/src/pages/tests/model-config.test.ts @@ -558,6 +558,12 @@ describe("model config helpers", () => { expect(Object.keys(enUSMessages).sort()).toEqual(Object.keys(zhCNMessages).sort()); }); + it("自定义供应商使用独立的拼图图标", () => { + expect(modelProviderLogoUrl("custom")).toMatch(/^data:image\/svg\+xml/); + expect(modelProviderLogoUrl("custom")).not.toBe(modelProviderLogoUrl("openai")); + expect(modelProviderLogoUrl("openai_responses")).toBe(modelProviderLogoUrl("openai")); + }); + it("为阶跃星辰和小米 MiMo 解析出品牌 logo", () => { expect(modelProviderLogoUrl("stepfun")).toMatch(/^data:image\/svg\+xml/); expect(modelProviderLogoUrl("xiaomi")).toMatch(/^data:image\/svg\+xml/); From 4e4c5fc0d199f714676c08d0e274758b8759ae3e Mon Sep 17 00:00:00 2001 From: ZongYue Date: Fri, 9 Oct 2026 12:21:19 +0800 Subject: [PATCH 15/85] feat: package agent runtime for npm --- .gitignore | 1 + App/memmy-agent/package.json | 3 + App/memmy-agent/scripts/build-npm-package.mjs | 96 +++++++++++++++++++ package.json | 2 + 4 files changed, 102 insertions(+) create mode 100644 App/memmy-agent/scripts/build-npm-package.mjs diff --git a/.gitignore b/.gitignore index fec3c3dd1..34416d4da 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +App/memmy-agent/.npm-package/ release/ App/shell/desktop/release/ Mac软件打包/ diff --git a/App/memmy-agent/package.json b/App/memmy-agent/package.json index 78ec3cfaf..9be992733 100644 --- a/App/memmy-agent/package.json +++ b/App/memmy-agent/package.json @@ -15,6 +15,9 @@ "pretest": "npm --prefix ../../Knowledge run build && npm run version:sync && npm --prefix ../../Migrations run build && npm --prefix ../backend/local-api-contracts run build", "pretest:tui-cursor": "npm run version:sync", "build": "tsc -p tsconfig.build.json && node scripts/copy-build-assets.mjs", + "prepackage:npm": "npm run build", + "package:npm": "node scripts/build-npm-package.mjs", + "pack:npm": "npm run package:npm && npm pack ./.npm-package", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", "test:tui-cursor": "vitest run tests/entrypoints/cli/tui-cursor.test.ts", diff --git a/App/memmy-agent/scripts/build-npm-package.mjs b/App/memmy-agent/scripts/build-npm-package.mjs new file mode 100644 index 000000000..0eff8b995 --- /dev/null +++ b/App/memmy-agent/scripts/build-npm-package.mjs @@ -0,0 +1,96 @@ +import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; + +const scriptRoot = dirname(fileURLToPath(import.meta.url)); +const agentRoot = resolve(scriptRoot, ".."); +const repoRoot = resolve(agentRoot, "../.."); +const outputRoot = join(agentRoot, ".npm-package"); +const vendorRoot = join(outputRoot, "vendor"); +const sourceManifest = JSON.parse(await readFile(join(agentRoot, "package.json"), "utf8")); +const packageName = "@memtensor/memmy-agent"; + +const localDependencies = new Map([ + ["@memmy/local-api-contracts", "App/backend/local-api-contracts"], + ["@memmy/migrations", "Migrations"], + ["@memmy/knowledge", "Knowledge"], +]); + +await rm(outputRoot, { recursive: true, force: true }); +await rm(join(agentRoot, "dist", "memmy-agent-npm"), { recursive: true, force: true }); +await mkdir(vendorRoot, { recursive: true }); + +const vendorPackages = {}; +for (const [dependencyName, packagePath] of localDependencies) { + const packageRoot = join(repoRoot, packagePath); + const tarball = await pack(packageRoot, vendorRoot); + vendorPackages[dependencyName] = `file:vendor/${tarball}`; +} + +const packageManifest = { + name: packageName, + version: sourceManifest.version, + description: sourceManifest.description, + type: "module", + main: "./dist/index.js", + types: "./dist/index.d.ts", + exports: { + ".": { + types: "./dist/index.d.ts", + import: "./dist/index.js", + default: "./dist/index.js", + }, + }, + bin: { memmy: "./dist/main.js" }, + publishConfig: { access: "public" }, + files: ["dist/**/*", ".env.example", "README.md", "vendor/*.tgz"], + engines: sourceManifest.engines, + dependencies: { + ...sourceManifest.dependencies, + ...vendorPackages, + }, +}; + +await writeFile(join(outputRoot, "package.json"), `${JSON.stringify(packageManifest, null, 2)}\n`); +await cp(join(agentRoot, "dist"), join(outputRoot, "dist"), { recursive: true }); +await cp(join(agentRoot, ".env.example"), join(outputRoot, ".env.example")); +await cp(join(agentRoot, "README.md"), join(outputRoot, "README.md")); + +for (const file of ["dist/main.js", "dist/index.js", ".env.example", "README.md"]) { + if (!(await exists(join(outputRoot, file)))) throw new Error(`npm package is missing ${file}`); +} +if (Object.values(packageManifest.dependencies).some((value) => value.startsWith("file:../") || value.startsWith("file:../../"))) { + throw new Error("npm package contains a dependency outside its own archive"); +} + +console.log(`Prepared ${packageName}@${sourceManifest.version} at ${outputRoot}`); + +async function pack(packageRoot, destination) { + const result = await run("npm", ["pack", packageRoot, "--pack-destination", destination, "--ignore-scripts", "--json"], repoRoot); + const metadata = JSON.parse(result.stdout).at(-1); + if (!metadata?.filename) throw new Error(`npm pack did not return a filename for ${packageRoot}`); + return metadata.filename; +} + +async function exists(path) { + try { + await readFile(path); + return true; + } catch { + return false; + } +} + +function run(command, args, cwd) { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "inherit"] }); + let stdout = ""; + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.once("error", reject); + child.once("exit", (code) => { + if (code === 0) resolvePromise({ stdout }); + else reject(new Error(`${command} ${args.join(" ")} exited with ${code}`)); + }); + }); +} diff --git a/package.json b/package.json index 6a7814a8e..f5158cf4b 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,8 @@ "memory:pack": "npm run pack:npm -w @memmy/memory", "memory:binary": "npm run binary -w @memmy/memory", "memory:runtime:package": "npm run runtime:package -w @memmy/memory", + "agent:package": "npm --prefix App/memmy-agent run package:npm", + "agent:pack": "npm --prefix App/memmy-agent run pack:npm", "memory:dev": "npm run dev -w @memmy/memory", "memory:serve": "npm run serve -w @memmy/memory", "memory:serve:local": "npm run serve:local -w @memmy/memory", From 2f934f522283cfa72bf5a1af7c11bb43623fa26d Mon Sep 17 00:00:00 2001 From: ZongYue Date: Fri, 9 Oct 2026 14:16:50 +0800 Subject: [PATCH 16/85] chore: relocate agent npm package output --- .gitignore | 2 +- App/memmy-agent/package.json | 2 +- App/memmy-agent/scripts/build-npm-package.mjs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 34416d4da..b390d647b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ node_modules/ dist/ -App/memmy-agent/.npm-package/ +App/memmy-agent/npm-packages/ release/ App/shell/desktop/release/ Mac软件打包/ diff --git a/App/memmy-agent/package.json b/App/memmy-agent/package.json index 9be992733..b6979b934 100644 --- a/App/memmy-agent/package.json +++ b/App/memmy-agent/package.json @@ -17,7 +17,7 @@ "build": "tsc -p tsconfig.build.json && node scripts/copy-build-assets.mjs", "prepackage:npm": "npm run build", "package:npm": "node scripts/build-npm-package.mjs", - "pack:npm": "npm run package:npm && npm pack ./.npm-package", + "pack:npm": "npm run package:npm && npm pack ./npm-packages/memmy-agent --pack-destination ./npm-packages", "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vitest run", "test:tui-cursor": "vitest run tests/entrypoints/cli/tui-cursor.test.ts", diff --git a/App/memmy-agent/scripts/build-npm-package.mjs b/App/memmy-agent/scripts/build-npm-package.mjs index 0eff8b995..ef6432e0d 100644 --- a/App/memmy-agent/scripts/build-npm-package.mjs +++ b/App/memmy-agent/scripts/build-npm-package.mjs @@ -6,7 +6,7 @@ import { spawn } from "node:child_process"; const scriptRoot = dirname(fileURLToPath(import.meta.url)); const agentRoot = resolve(scriptRoot, ".."); const repoRoot = resolve(agentRoot, "../.."); -const outputRoot = join(agentRoot, ".npm-package"); +const outputRoot = join(agentRoot, "npm-packages", "memmy-agent"); const vendorRoot = join(outputRoot, "vendor"); const sourceManifest = JSON.parse(await readFile(join(agentRoot, "package.json"), "utf8")); const packageName = "@memtensor/memmy-agent"; From 019cce2867ee012c3f82993a4844f11dcb1c8982 Mon Sep 17 00:00:00 2001 From: ZongYue Date: Fri, 9 Oct 2026 14:22:39 +0800 Subject: [PATCH 17/85] fix: sync generated agent npm package version --- scripts/sync-project-version.mjs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/scripts/sync-project-version.mjs b/scripts/sync-project-version.mjs index 336d8e7e4..37f932be8 100644 --- a/scripts/sync-project-version.mjs +++ b/scripts/sync-project-version.mjs @@ -1,4 +1,4 @@ -import { readFile, writeFile } from "node:fs/promises"; +import { access, readFile, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -16,6 +16,9 @@ const derivedManifests = [ "App/memmy-agent/package.json", "App/shell/desktop/package.json", ]; +const optionalDerivedManifests = [ + "App/memmy-agent/npm-packages/memmy-agent/package.json", +]; const staleFiles = []; for (const relativePath of derivedManifests) { @@ -23,6 +26,13 @@ for (const relativePath of derivedManifests) { json.version = version; }); } +for (const relativePath of optionalDerivedManifests) { + if (await exists(relativePath)) { + await updateJson(relativePath, (json) => { + json.version = version; + }); + } +} await updateText( "App/backend/src/project-version.ts", @@ -74,3 +84,12 @@ async function updateText(relativePath, nextText) { async function readJson(path) { return JSON.parse(await readFile(path, "utf8")); } + +async function exists(relativePath) { + try { + await access(join(root, relativePath)); + return true; + } catch { + return false; + } +} From 8f9c428b2e14a10ed51587dbc52ca753c7b900d9 Mon Sep 17 00:00:00 2001 From: xrzhu Date: Fri, 9 Oct 2026 17:18:01 +0800 Subject: [PATCH 18/85] fix(chat): keep copy actions on completed historical replies --- .../src/pages/agent-thread-messages.tsx | 22 ++-- .../agent-thread-copy.interaction.test.tsx | 100 ++++++++++++++++++ 2 files changed, 115 insertions(+), 7 deletions(-) create mode 100644 App/frontend/desktop/src/pages/tests/agent-thread-copy.interaction.test.tsx diff --git a/App/frontend/desktop/src/pages/agent-thread-messages.tsx b/App/frontend/desktop/src/pages/agent-thread-messages.tsx index 29588a66c..d5da86ea4 100644 --- a/App/frontend/desktop/src/pages/agent-thread-messages.tsx +++ b/App/frontend/desktop/src/pages/agent-thread-messages.tsx @@ -136,7 +136,7 @@ export const AgentThreadMessages = memo(function AgentThreadMessages(props: Agen () => buildAgentDisplayUnits(props.messages, { chatScopeKey: props.chatScopeKey, retryWaitStatus: props.retryWaitStatus ?? null }), [props.chatScopeKey, props.messages, props.retryWaitStatus] ); - const finalAssistantAnswerIndex = useMemo(() => findFinalAssistantAnswerUnitIndex(units, { isSending: props.isSending }), [props.isSending, units]); + const finalAssistantAnswerIndices = useMemo(() => findFinalAssistantAnswerUnitIndices(units, { isSending: props.isSending }), [props.isSending, units]); const recallEvidenceAnchors = useMemo( () => findRecallEvidenceUserAnchors(units, { isSending: props.isSending }), [props.isSending, units] @@ -217,7 +217,7 @@ export const AgentThreadMessages = memo(function AgentThreadMessages(props: Agen artifactClient={props.artifactClient} chatScopeKey={props.chatScopeKey} unitIndex={index} - isFinalAssistantAnswer={index === finalAssistantAnswerIndex} + isFinalAssistantAnswer={finalAssistantAnswerIndices.has(index)} forceMessageActions={unit.message.id === (props.forceMessageActionsForMessageId ?? props.afterMessageId)} deferContentRender={shouldDeferAgentMessageContent(unit, index, units.length)} deferredRevealDelayMs={deferredAgentMessageRevealDelay(index, units.length)} @@ -361,16 +361,23 @@ function isActivityAutoOpenRunning(unit: Extract { + const indices = new Set(); const lastUserUnitIndex = findLastUserUnitIndex(units); + let foundAnswerInTurn = false; for (let index = units.length - 1; index >= 0; index -= 1) { const unit = units[index]; if (unit?.type !== "single") continue; + if (unit.message.role === "user") { + foundAnswerInTurn = false; + continue; + } + if (foundAnswerInTurn) continue; if (options.isSending && index > lastUserUnitIndex) continue; const message = unit.message; if (message.role === "assistant" @@ -378,10 +385,11 @@ function findFinalAssistantAnswerUnitIndex(units: AgentDisplayUnit[], options: { && message.kind !== "narration" && message.kind !== "context_compaction" && message.content.trim().length > 0) { - return index; + indices.add(index); + foundAnswerInTurn = true; } } - return -1; + return indices; } function findLastUserUnitIndex(units: AgentDisplayUnit[]): number { diff --git a/App/frontend/desktop/src/pages/tests/agent-thread-copy.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/agent-thread-copy.interaction.test.tsx new file mode 100644 index 000000000..87dad7cf4 --- /dev/null +++ b/App/frontend/desktop/src/pages/tests/agent-thread-copy.interaction.test.tsx @@ -0,0 +1,100 @@ +// @vitest-environment happy-dom +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { I18nProvider } from "../../i18n/i18n-provider.js"; +import type { AgentChatMessage } from "../../state/agent-chat-slice.js"; +import { AgentThreadMessages } from "../agent-thread-messages.js"; + +describe("AgentThreadMessages copy actions across turns", () => { + let container: HTMLDivElement; + let root: Root; + const completedMessages: AgentChatMessage[] = [ + { id: "question-1", role: "user", content: "First question" }, + { id: "answer-1", role: "assistant", content: "First **answer**" }, + { id: "question-2", role: "user", content: "Second question" }, + { id: "answer-2", role: "assistant", content: "Second `answer`" }, + ]; + + beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + async function render(messages: AgentChatMessage[], isSending = false) { + await act(async () => { + root.render( + + + + ); + }); + } + + function copyButtons() { + return [...container.querySelectorAll(".agent-message-copy-button--left")]; + } + + it("keeps the first answer copyable after a second turn and copies each answer's raw text", async () => { + const writeText = vi.spyOn(navigator.clipboard, "writeText").mockResolvedValue(); + await render(completedMessages.slice(0, 2)); + expect(copyButtons()).toHaveLength(1); + + await render(completedMessages); + const buttons = copyButtons(); + expect(buttons).toHaveLength(2); + await act(async () => buttons[0]!.click()); + await act(async () => buttons[1]!.click()); + expect(writeText.mock.calls).toEqual([["First **answer**"], ["Second `answer`"]]); + }); + + it.each(["waiting", "streaming", "reasoning", "tools"] as const)( + "keeps both completed turns copyable while the third turn is %s", + async (phase) => { + const messages: AgentChatMessage[] = [ + ...completedMessages, + { id: "question-3", role: "user", content: "Third question" }, + ]; + if (phase !== "waiting") { + messages.push({ + id: "answer-3", role: "assistant", content: "Third draft", + isStreaming: phase === "streaming", + reasoningStreaming: phase === "reasoning", + }); + } + if (phase === "tools") { + messages.push({ id: "tool-3", role: "tool", kind: "trace", content: "read_file()", isStreaming: true }); + } + await render(messages, true); + expect(copyButtons()).toHaveLength(2); + expect(copyButtons().map((button) => button.closest(".agent-chat-bubble-frame")?.textContent)) + .toEqual([expect.stringContaining("First answer"), expect.stringContaining("Second answer")]); + }, + ); + + it("only exposes the final answer within each completed turn", async () => { + await render([ + { id: "question-1", role: "user", content: "First question" }, + { id: "continuation-1", role: "assistant", content: "Checking first" }, + { id: "tool-1", role: "tool", kind: "trace", content: "read_file()" }, + { id: "answer-1", role: "assistant", content: "First final" }, + { id: "question-2", role: "user", content: "Second question" }, + { id: "continuation-2", role: "assistant", content: "Checking second" }, + { id: "narration-2", role: "assistant", kind: "narration", content: "Still checking" }, + { id: "answer-2", role: "assistant", content: "Second final", stoppedByUser: true }, + { id: "empty-2", role: "assistant", content: " " }, + ]); + expect(copyButtons()).toHaveLength(2); + expect(copyButtons().map((button) => button.closest(".agent-chat-bubble-frame")?.textContent)) + .toEqual([expect.stringContaining("First final"), expect.stringContaining("Second final")]); + }); +}); From a69fe874ffbfdf7212e4f1d0e69a37ffddf2f421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=98=89?= Date: Fri, 9 Oct 2026 19:04:12 +0800 Subject: [PATCH 19/85] fix: reuse degraded development memory service --- .../desktop/src/main/runtime-services.ts | 6 ++- .../desktop/tests/runtime-services.test.ts | 46 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/App/shell/desktop/src/main/runtime-services.ts b/App/shell/desktop/src/main/runtime-services.ts index 332a672ec..da14738a6 100644 --- a/App/shell/desktop/src/main/runtime-services.ts +++ b/App/shell/desktop/src/main/runtime-services.ts @@ -1891,8 +1891,10 @@ async function probeMemoryService(url: string, headers: Record = signal: AbortSignal.timeout(HTTP_TIMEOUT_MS) }); if (!response.ok) return "unexpected"; - const body = await response.json() as { ok?: unknown; protocolVersion?: unknown }; - if (body.ok !== true) return "unexpected"; + const body: unknown = await response.json(); + if (!isRecord(body) || typeof body.ok !== "boolean") return "unexpected"; + // `ok` also reflects model health. A degraded dev-start service still owns + // this endpoint and must be reused instead of starting a second listener. return body.protocolVersion === SUPPORTED_MEMORY_PROTOCOL_VERSION ? "ready" : "incompatible"; } catch { return "unreachable"; diff --git a/App/shell/desktop/tests/runtime-services.test.ts b/App/shell/desktop/tests/runtime-services.test.ts index eb1d6b370..c292a6d90 100644 --- a/App/shell/desktop/tests/runtime-services.test.ts +++ b/App/shell/desktop/tests/runtime-services.test.ts @@ -554,6 +554,52 @@ describe("packaged desktop runtime config", () => { expect(children).toHaveLength(0); }); + it("reuses a compatible degraded Memory service started by dev-start", async () => { + const root = await makeTempRoot(); + const databasePath = join(root, "memory.sqlite"); + const server = createServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ + ok: false, + protocolVersion: 1, + serviceVersion: "2.1.3", + models: { summary: { lastError: "model unavailable" } } + })); + }); + testServers.push(server); + await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("expected TCP address"); + const children: ManagedChild[] = []; + + await ensureMemoryService( + { memoryEntry: join(root, "missing-memory.js"), agentEntry: join(root, "missing-agent.js") }, + { + configPath: join(root, "config.yaml"), + agentWorkspace: join(root, "workspace"), + memoryDatabasePath: databasePath, + memoryBaseUrl: `http://127.0.0.1:${address.port}`, + memoryToken: "", + memoryListenHost: "127.0.0.1", + memoryListenPort: address.port, + agentGatewayBaseUrl: "http://127.0.0.1:18980", + agentGatewayHealthHost: "127.0.0.1", + agentGatewayHealthPort: 18970, + agentGatewayBootstrapSecret: "secret" + }, + children, + { + appPath: root, + appDatabaseFile: join(root, "app.sqlite"), + resourcesPath: root, + logDirectory: root, + logLevel: "info" + } + ); + + expect(children).toHaveLength(0); + }); + it("rereads the migrated workspace instead of pinning the pre-migration legacy value", async () => { const memmyHome = await makeTempRoot(); const configPath = join(memmyHome, "config.yaml"); From 37598d50a737a163996037ed454ab635b8fb17a7 Mon Sep 17 00:00:00 2001 From: ZongYue Date: Sun, 11 Oct 2026 15:21:30 +0800 Subject: [PATCH 20/85] fix: forward native progress events over SSE --- .../src/entrypoints/openai-like-api/server.ts | 72 +++++++++++++++++++ .../openai-like-api/api-stream.test.ts | 37 +++++++++- 2 files changed, 108 insertions(+), 1 deletion(-) diff --git a/App/memmy-agent/src/entrypoints/openai-like-api/server.ts b/App/memmy-agent/src/entrypoints/openai-like-api/server.ts index 0ff0484a9..4de00a531 100644 --- a/App/memmy-agent/src/entrypoints/openai-like-api/server.ts +++ b/App/memmy-agent/src/entrypoints/openai-like-api/server.ts @@ -4,6 +4,7 @@ import { Config } from "../../config/schema.js"; import { getMediaDir } from "../../config/paths.js"; import { InboundMessage } from "../../core/runtime-messages/events.js"; import { FileSizeExceeded, saveBase64DataUrl } from "../../utils/media-decode.js"; +import { withProgressCapabilities } from "../../utils/progress-events.js"; import { EMPTY_FINAL_RESPONSE_MESSAGE } from "../../utils/runtime.js"; export const API_SESSION_KEY = "api:default"; @@ -107,6 +108,17 @@ export function sseChunk(delta: string, model: string, chunkId: string, finishRe return `data: ${JSON.stringify(payload)}\n\n`; } +export function sseMemmyEvent(type: string, payload: Record, model: string, chunkId: string): string { + return `data: ${JSON.stringify({ + id: chunkId, + object: "chat.completion.chunk", + created: Math.floor(Date.now() / 1000), + model, + choices: [], + memmy_event: { type, ...payload }, + })}\n\n`; +} + export const SSE_DONE = "data: [DONE]\n\n"; function safeFilename(name: string): string { @@ -315,6 +327,30 @@ export async function handleChatCompletions(request: Request, ctx?: ApiContext): if (token) emitted = true; write(sseChunk(token, context.modelName, chunkId)); }; + const onProgress = withProgressCapabilities(async (content: string, options: Record = {}) => { + if (options.reasoning && content) write(sseMemmyEvent("thinking_delta", { text: content }, context.modelName, chunkId)); + if (options.reasoningEnd) write(sseMemmyEvent("thinking_end", {}, context.modelName, chunkId)); + for (const event of options.toolEvents ?? []) { + const phase = String(event?.phase ?? ""); + if (phase === "start") { + write(sseMemmyEvent("tool_call", { + callId: String(event.call_id ?? event.ui_tool_call_id ?? "call"), + name: String(event.name ?? "tool"), + input: event.arguments ?? {}, + }, context.modelName, chunkId)); + } else if (phase === "end" || phase === "error") { + write(sseMemmyEvent("tool_result", { + callId: String(event.call_id ?? event.ui_tool_call_id ?? "call"), + name: String(event.name ?? "tool"), + output: phase === "error" ? event.error ?? "Tool execution failed" : event.result ?? null, + ok: phase === "end", + }, context.modelName, chunkId)); + for (const media of mediaFromToolEvent(event)) { + write(sseMemmyEvent("media", { media: [media] }, context.modelName, chunkId)); + } + } + } + }, { reasoning: true, toolEvents: true }); const onStreamEnd = async () => undefined; try { await withSessionLock(context, sessionKey, async () => { @@ -322,6 +358,7 @@ export async function handleChatCompletions(request: Request, ctx?: ApiContext): Promise.resolve( callAgent(context, { ...baseArgs, + onProgress, onStream, onStreamEnd, }), @@ -379,6 +416,41 @@ export async function handleChatCompletions(request: Request, ctx?: ApiContext): } } +function mediaFromToolEvent(event: Record): Array> { + const media: Array> = []; + const seen = new Set(); + const push = (item: Record): void => { + const key = `${item.kind}:${item.url ?? item.path ?? ""}`; + if (seen.has(key)) return; + seen.add(key); + media.push(item); + }; + const add = (value: any): void => { + if (Array.isArray(value)) { + for (const item of value) add(item); + return; + } + if (!value || typeof value !== "object") return; + if (value.type === "resource_link" && typeof value.uri === "string" && value.uri.trim()) { + const mime = typeof value.mimeType === "string" ? value.mimeType : ""; + const kind = mime.startsWith("image/") || /\.(?:png|jpe?g|gif|webp|svg)$/iu.test(value.uri) ? "image" : "file"; + push({ kind, ...(value.uri.startsWith("http") || value.uri.startsWith("data:") ? { url: value.uri } : { path: value.uri }), ...(typeof value.name === "string" && value.name ? { name: value.name } : {}) }); + return; + } + if (value.type === "image_url" && typeof value.image_url?.url === "string" && value.image_url.url.trim()) { + push({ kind: "image", url: value.image_url.url }); + return; + } + for (const child of [value.files, value.embeds, value.content, value.result]) add(child); + }; + for (const file of event.files ?? []) { + if (typeof file === "string" && file.trim()) add({ type: "resource_link", uri: file }); + } + add(event.embeds); + add(event.result); + return media; +} + export async function handleModels(request?: Request, ctx?: Pick): Promise { void request; const model = ctx?.modelName ?? "memmy"; diff --git a/App/memmy-agent/tests/entrypoints/openai-like-api/api-stream.test.ts b/App/memmy-agent/tests/entrypoints/openai-like-api/api-stream.test.ts index 19a84b06b..f547178a6 100644 --- a/App/memmy-agent/tests/entrypoints/openai-like-api/api-stream.test.ts +++ b/App/memmy-agent/tests/entrypoints/openai-like-api/api-stream.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { SSE_DONE, sseChunk, createApp } from "../../../src/entrypoints/openai-like-api/server.js"; +import { SSE_DONE, sseChunk, sseMemmyEvent, createApp } from "../../../src/entrypoints/openai-like-api/server.js"; function request(body: unknown): Request { return new Request("http://localhost/v1/chat/completions", { @@ -47,6 +47,14 @@ describe("SSE response helpers", () => { it("uses the OpenAI [DONE] SSE terminator", () => { expect(SSE_DONE).toBe("data: [DONE]\n\n"); }); + + it("encodes Memmy progress events without changing the OpenAI chunk shape", () => { + const payload = JSON.parse(sseMemmyEvent("thinking_delta", { text: "先分析" }, "m", "id1").slice(6)); + + expect(payload.choices).toEqual([]); + expect(payload.memmy_event).toEqual({ type: "thinking_delta", text: "先分析" }); + expect(payload.object).toBe("chat.completion.chunk"); + }); }); describe("SSE chat completions", () => { @@ -168,6 +176,33 @@ describe("SSE chat completions", () => { expect(captured.onStreamEnd).toBeTypeOf("function"); }); + it("streams reasoning, tool lifecycle and generated files through Memmy events", async () => { + const app = createApp({ + processDirect: async (args: any) => { + await args.onProgress("先分析", { reasoning: true }); + await args.onProgress("", { reasoningEnd: true }); + await args.onProgress("", { + toolEvents: [{ phase: "start", call_id: "call-1", name: "write_file", arguments: { path: "a.txt" } }], + }); + await args.onProgress("", { + toolEvents: [{ phase: "end", call_id: "call-1", name: "write_file", result: [{ type: "resource_link", uri: "/tmp/a.txt", mimeType: "text/plain" }], files: ["/tmp/a.txt"] }], + }); + await args.onStream("完成"); + return "完成"; + }, + }, "m"); + + const payloads = ssePayloads(await (await app.fetch(request({ messages: [{ role: "user", content: "go" }], stream: true }))).text()) + .filter((line) => line !== "[DONE]") + .map((line) => JSON.parse(line)); + expect(payloads.map((item) => item.memmy_event?.type).filter(Boolean)).toEqual([ + "thinking_delta", "thinking_end", "tool_call", "tool_result", "media", + ]); + expect(payloads.filter((item) => item.memmy_event?.type === "media").map((item) => item.memmy_event.media[0])).toEqual([ + { kind: "file", path: "/tmp/a.txt" }, + ]); + }); + it("does not close the SSE response on intermediate stream-end callbacks", async () => { const app = createApp( { From c3b8162057cfb84673f824991b78aa1580fe519a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=98=89?= Date: Mon, 12 Oct 2026 15:50:46 +0800 Subject: [PATCH 21/85] =?UTF-8?q?fix:=20=E6=94=AF=E6=8C=81=20apply=5Fpatch?= =?UTF-8?q?=20=E5=B7=A5=E4=BD=9C=E5=8C=BA=E5=86=85=E7=BB=9D=E5=AF=B9?= =?UTF-8?q?=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/agent-runtime/tools/apply-patch.ts | 34 +++- .../agent-runtime/tools/patch-envelope.ts | 28 ++-- .../tools/apply-patch-tool.test.ts | 6 +- .../agent-runtime/tools/bug-671-repro.test.ts | 146 ++++++++++++++++++ .../tools/patch-envelope.test.ts | 12 +- .../tools/tool-descriptions.test.ts | 2 +- 6 files changed, 203 insertions(+), 25 deletions(-) create mode 100644 App/memmy-agent/tests/core/agent-runtime/tools/bug-671-repro.test.ts diff --git a/App/memmy-agent/src/core/agent-runtime/tools/apply-patch.ts b/App/memmy-agent/src/core/agent-runtime/tools/apply-patch.ts index cea810679..d8b015b1f 100644 --- a/App/memmy-agent/src/core/agent-runtime/tools/apply-patch.ts +++ b/App/memmy-agent/src/core/agent-runtime/tools/apply-patch.ts @@ -7,6 +7,7 @@ import { PatchError, normalizePatchPath, parsePatchEnvelope, + validatePatchPathConflicts, type PatchFileOperation, type PatchHunk, } from "./patch-envelope.js"; @@ -284,7 +285,7 @@ export class ApplyPatchTool extends Tool { } get description(): string { - return "Apply a patch to add, update, delete, or move one or more workspace-relative files."; + return "Apply a patch to add, update, delete, or move one or more files inside the workspace."; } get parameters() { @@ -316,7 +317,7 @@ To move or rename a file, use: Start each update hunk with @@. If needed, add an exact line from the file after @@ to help locate the change, for example: @@ function calculate(). Prefix added lines with +, removed lines with -, and unchanged context lines with a space. Use *** End of File for an append or a hunk that must end at EOF. -Paths must be workspace-relative. Use at most 20 file operations in one patch.`, +Paths may be workspace-relative or absolute paths inside the workspace. Use at most 20 file operations in one patch.`, }, }, required: ["input"], @@ -327,15 +328,18 @@ Paths must be workspace-relative. Use at most 20 file operations in one patch.`, return params; } - resolve(relativePath: string): string { - const normalized = normalizePatchPath(relativePath); - const target = path.resolve(this.workspace, ...normalized.split("/")); + resolve(requestedPath: string): string { + const normalized = normalizePatchPath(requestedPath); + const target = path.isAbsolute(normalized) + ? path.resolve(normalized) + : path.resolve(this.workspace, ...normalized.split("/")); const relative = path.relative(this.workspace, target); if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { - throw new PatchError(`patch path escapes workspace: ${relativePath}`); + throw new PatchError(`patch path escapes workspace: ${requestedPath}`); } + if (!relative) throw new PatchError(`patch path must name a file inside the workspace: ${requestedPath}`); - const segments = normalized.split("/"); + const segments = relative.split(path.sep); let current = this.workspace; for (let index = 0; index < segments.length; index += 1) { current = path.join(current, segments[index]); @@ -354,6 +358,20 @@ Paths must be workspace-relative. Use at most 20 file operations in one patch.`, return target; } + private normalizeOperations(operations: PatchFileOperation[]): PatchFileOperation[] { + const relativePath = (requestedPath: string): string => + path.relative(this.workspace, this.resolve(requestedPath)).split(path.sep).join("/"); + const normalized = operations.map((operation): PatchFileOperation => { + const operationPath = relativePath(operation.path); + if (operation.kind !== "update" || !operation.moveTo) { + return { ...operation, path: operationPath }; + } + return { ...operation, path: operationPath, moveTo: relativePath(operation.moveTo) }; + }); + validatePatchPathConflicts(normalized); + return normalized; + } + private async readSnapshot(target: string, signal?: AbortSignal | null): Promise { throwIfAborted(signal); let stat: fsSync.Stats; @@ -610,7 +628,7 @@ Paths must be workspace-relative. Use at most 20 file operations in one patch.`, let plan: PatchPlan; try { throwIfAborted(signal); - const operations = parsePatchEnvelope(params.input); + const operations = this.normalizeOperations(parsePatchEnvelope(params.input)); plan = await this.planOperations(operations, signal); throwIfAborted(signal); } catch (error) { diff --git a/App/memmy-agent/src/core/agent-runtime/tools/patch-envelope.ts b/App/memmy-agent/src/core/agent-runtime/tools/patch-envelope.ts index a2b65f5ae..cc7f0a10a 100644 --- a/App/memmy-agent/src/core/agent-runtime/tools/patch-envelope.ts +++ b/App/memmy-agent/src/core/agent-runtime/tools/patch-envelope.ts @@ -1,3 +1,5 @@ +import path from "node:path"; + const BEGIN_PATCH = "*** Begin Patch"; const END_PATCH = "*** End Patch"; const END_OF_FILE = "*** End of File"; @@ -99,25 +101,27 @@ export function normalizePatchPath(raw: string): string { const value = raw.trim(); if (!value) throw new PatchError("patch path cannot be empty"); if (value.includes("\0")) throw new PatchError(`patch path contains a null byte: ${raw}`); - if ( - value.startsWith("~") || - value.startsWith("/") || - value.startsWith("\\") || - /^[A-Za-z]:/.test(value) - ) { - throw new PatchError(`patch path must be relative: ${raw}`); + if (value.startsWith("~")) throw new PatchError(`patch path must not use '~': ${raw}`); + if (/^[A-Za-z]:/.test(value) && !path.isAbsolute(value)) { + throw new PatchError(`patch path has an unsupported drive prefix: ${raw}`); + } + if (value.startsWith("\\") && !path.isAbsolute(value)) { + throw new PatchError(`patch path has an unsupported absolute form: ${raw}`); } - const segments = value.replace(/\\/g, "/").split("/"); - if (segments.some((segment) => segment === "..")) { + const absolute = path.isAbsolute(value); + const slashPath = value.replace(/\\/g, "/"); + if (!absolute && slashPath.split("/").some((segment) => segment === "..")) { throw new PatchError(`patch path must not contain '..': ${raw}`); } - const normalized = segments.filter((segment) => segment && segment !== ".").join("/"); + const normalized = absolute + ? path.normalize(value).replace(/\\/g, "/") + : path.posix.normalize(slashPath); if (!normalized) throw new PatchError("patch path cannot be empty"); return normalized; } -function validatePathConflicts(operations: PatchFileOperation[]): void { +export function validatePatchPathConflicts(operations: PatchFileOperation[]): void { const sources = new Map(); const moveTargets = new Map(); const finalTargets: Array<{ key: string; path: string; line: number }> = []; @@ -266,7 +270,7 @@ export function parsePatchEnvelope(input: string): PatchFileOperation[] { } if (operations.length === 0) fail(2, "a patch must contain at least one file operation"); - validatePathConflicts(operations); + validatePatchPathConflicts(operations); return operations; } diff --git a/App/memmy-agent/tests/core/agent-runtime/tools/apply-patch-tool.test.ts b/App/memmy-agent/tests/core/agent-runtime/tools/apply-patch-tool.test.ts index 547cb136f..adcce77ca 100644 --- a/App/memmy-agent/tests/core/agent-runtime/tools/apply-patch-tool.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/tools/apply-patch-tool.test.ts @@ -29,7 +29,7 @@ describe("ApplyPatchTool contract", () => { const tool = new ApplyPatchTool({ workspace: workspace() }); expect(tool.description).toBe( - "Apply a patch to add, update, delete, or move one or more workspace-relative files.", + "Apply a patch to add, update, delete, or move one or more files inside the workspace.", ); expect(tool.parameters).toMatchObject({ type: "object", @@ -45,7 +45,9 @@ describe("ApplyPatchTool contract", () => { expect(tool.parameters.properties.input.description).toContain("*** Move to:"); expect(tool.parameters.properties.input.description).toContain("@@"); expect(tool.parameters.properties.input.description).toContain("*** End of File"); - expect(tool.parameters.properties.input.description).toContain("workspace-relative"); + expect(tool.parameters.properties.input.description).toContain( + "workspace-relative or absolute paths inside the workspace", + ); expect(tool.parameters.properties.input.description).toContain("at most 20"); }); diff --git a/App/memmy-agent/tests/core/agent-runtime/tools/bug-671-repro.test.ts b/App/memmy-agent/tests/core/agent-runtime/tools/bug-671-repro.test.ts new file mode 100644 index 000000000..2f56d057c --- /dev/null +++ b/App/memmy-agent/tests/core/agent-runtime/tools/bug-671-repro.test.ts @@ -0,0 +1,146 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { ApplyPatchTool } from "../../../../src/core/agent-runtime/tools/apply-patch.js"; +import { EditFileTool, WriteFileTool } from "../../../../src/core/agent-runtime/tools/filesystem.js"; + +const roots: string[] = []; + +function workspace(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "memmy-bug671-")); + roots.push(root); + return root; +} + +function input(...lines: string[]): { input: string } { + return { input: ["*** Begin Patch", ...lines, "*** End Patch"].join("\n") }; +} + +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe("Bug 671 reproduction", () => { + it("accepts an in-workspace absolute path when adding", async () => { + const root = workspace(); + const target = path.join(root, "added.txt"); + const result = await new ApplyPatchTool({ workspace: root }).execute(input( + `*** Add File: ${target}`, + "+added", + )); + + expect(result).toContain("Patch applied:"); + expect(fs.readFileSync(target, "utf8")).toBe("added\n"); + }); + + it("accepts an in-workspace absolute path when updating", async () => { + const root = workspace(); + const target = path.join(root, "updated.txt"); + fs.writeFileSync(target, "before\n"); + const result = await new ApplyPatchTool({ workspace: root }).execute(input( + `*** Update File: ${target}`, + "@@", + "-before", + "+after", + )); + + expect(result).toContain("Patch applied:"); + expect(fs.readFileSync(target, "utf8")).toBe("after\n"); + }); + + it("accepts an in-workspace absolute path when deleting", async () => { + const root = workspace(); + const target = path.join(root, "deleted.txt"); + fs.writeFileSync(target, "delete\n"); + const result = await new ApplyPatchTool({ workspace: root }).execute(input( + `*** Delete File: ${target}`, + )); + + expect(result).toContain("Patch applied:"); + expect(fs.existsSync(target)).toBe(false); + }); + + it("accepts an in-workspace absolute move source", async () => { + const root = workspace(); + const source = path.join(root, "source.txt"); + fs.writeFileSync(source, "move\n"); + const result = await new ApplyPatchTool({ workspace: root }).execute(input( + `*** Update File: ${source}`, + "*** Move to: moved.txt", + )); + + expect(result).toContain("Patch applied:"); + expect(fs.readFileSync(path.join(root, "moved.txt"), "utf8")).toBe("move\n"); + }); + + it("accepts an in-workspace absolute move target", async () => { + const root = workspace(); + const source = path.join(root, "source.txt"); + const target = path.join(root, "moved.txt"); + fs.writeFileSync(source, "move\n"); + const result = await new ApplyPatchTool({ workspace: root }).execute(input( + "*** Update File: source.txt", + `*** Move to: ${target}`, + )); + + expect(result).toContain("Patch applied:"); + expect(fs.readFileSync(target, "utf8")).toBe("move\n"); + }); + + it("continues accepting relative apply_patch paths", async () => { + const root = workspace(); + const result = await new ApplyPatchTool({ workspace: root }).execute(input( + "*** Add File: relative.txt", + "+relative", + )); + + expect(result).toContain("Patch applied:"); + expect(fs.readFileSync(path.join(root, "relative.txt"), "utf8")).toBe("relative\n"); + }); + + it("matches write_file and edit_file absolute-path behavior", async () => { + const root = workspace(); + const target = path.join(root, "other-tools.txt"); + const options = { workspace: root, allowedDir: root, postWriteValidation: false } as any; + + expect(await new WriteFileTool(options).execute({ path: target, content: "before\n" })) + .toContain("Successfully wrote"); + expect(await new EditFileTool(options).execute({ + path: target, + old_text: "before", + new_text: "after", + })).toContain("Successfully edited file"); + expect(fs.readFileSync(target, "utf8")).toBe("after\n"); + }); + + it("still rejects traversal and symbolic links", async () => { + const root = workspace(); + const outside = workspace(); + const outsideFile = path.join(outside, "outside.txt"); + fs.writeFileSync(outsideFile, "outside\n"); + fs.symlinkSync(outsideFile, path.join(root, "outside-link.txt")); + const tool = new ApplyPatchTool({ workspace: root }); + + expect(await tool.execute(input("*** Add File: ../escaped.txt", "+escaped"))) + .toContain("must not contain '..'"); + expect(await tool.execute(input(`*** Delete File: ${outsideFile}`))) + .toContain("patch path escapes workspace"); + expect(await tool.execute(input("*** Delete File: outside-link.txt"))) + .toContain("symbolic links"); + expect(fs.readFileSync(outsideFile, "utf8")).toBe("outside\n"); + }); + + it("detects conflicts after absolute and relative paths are canonicalized", async () => { + const root = workspace(); + const absolute = path.join(root, "same.txt"); + const result = await new ApplyPatchTool({ workspace: root }).execute(input( + `*** Add File: ${absolute}`, + "+first", + "*** Delete File: same.txt", + )); + + expect(result).toContain("duplicate file operation path: same.txt"); + expect(fs.existsSync(absolute)).toBe(false); + }); +}); diff --git a/App/memmy-agent/tests/core/agent-runtime/tools/patch-envelope.test.ts b/App/memmy-agent/tests/core/agent-runtime/tools/patch-envelope.test.ts index 851ed76ce..fa25d76a7 100644 --- a/App/memmy-agent/tests/core/agent-runtime/tools/patch-envelope.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/tools/patch-envelope.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { describe, expect, it } from "vitest"; import { PatchError, @@ -121,12 +122,19 @@ describe("patch envelope parser", () => { ); }); - it.each(["~/a", "/a", "\\a", "C:a", "C:\\a", "../a", "a/../b", "a\0b"])( + it.each(["~/a", "C:a", "../a", "a/../b", "a\0b"])( "rejects unsafe path %s", (unsafe) => expect(() => normalizePatchPath(unsafe)).toThrow(PatchError), ); - it("normalizes separators, empty segments, and dot segments", () => { + it.runIf(process.platform !== "win32")("rejects absolute paths for another platform", () => { + expect(() => normalizePatchPath("\\a")).toThrow(PatchError); + expect(() => normalizePatchPath("C:\\a")).toThrow(PatchError); + }); + + it("accepts absolute paths and normalizes separators and dot segments", () => { + const absolute = path.resolve("workspace", "src", "..", "file.ts"); + expect(path.isAbsolute(normalizePatchPath(` ${absolute} `))).toBe(true); expect(normalizePatchPath(" ./src\\nested//./file.ts ")).toBe("src/nested/file.ts"); }); }); diff --git a/App/memmy-agent/tests/core/agent-runtime/tools/tool-descriptions.test.ts b/App/memmy-agent/tests/core/agent-runtime/tools/tool-descriptions.test.ts index b0f662368..cd7e01a81 100644 --- a/App/memmy-agent/tests/core/agent-runtime/tools/tool-descriptions.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/tools/tool-descriptions.test.ts @@ -12,7 +12,7 @@ describe("coding tool descriptions", () => { const writeFile = new WriteFileTool().description.toLowerCase(); expect(applyPatch).toBe( - "apply a patch to add, update, delete, or move one or more workspace-relative files.", + "apply a patch to add, update, delete, or move one or more files inside the workspace.", ); expect(editFile).toContain("small, exact replacement"); From 22f542f8a138d25f3b5d4f20590ee0392923aa8f Mon Sep 17 00:00:00 2001 From: ZongYue Date: Mon, 12 Oct 2026 17:55:23 +0800 Subject: [PATCH 22/85] fix: add correlated API and model latency diagnostics --- .../src/core/agent-runtime/loop.ts | 10 ++++ .../src/core/agent-runtime/runner.ts | 2 + .../src/entrypoints/openai-like-api/server.ts | 10 ++++ .../src/providers/openai-compat-provider.ts | 4 ++ App/memmy-agent/src/utils/request-timing.ts | 24 +++++++++ .../tests/utils/request-timing.test.ts | 49 +++++++++++++++++++ 6 files changed, 99 insertions(+) create mode 100644 App/memmy-agent/src/utils/request-timing.ts create mode 100644 App/memmy-agent/tests/utils/request-timing.test.ts diff --git a/App/memmy-agent/src/core/agent-runtime/loop.ts b/App/memmy-agent/src/core/agent-runtime/loop.ts index 9b379fc5d..12e6738b4 100644 --- a/App/memmy-agent/src/core/agent-runtime/loop.ts +++ b/App/memmy-agent/src/core/agent-runtime/loop.ts @@ -1,3 +1,4 @@ +import { requestTiming } from "../../utils/request-timing.js"; import { initializeDesktopScreenCapture } from '../../tools/computer-use/desktop-screen-capture.js'; import fs from "node:fs"; import path from "node:path"; @@ -3866,6 +3867,7 @@ export class AgentLoop { ctx.userPersistedEarly = Boolean(persisted.entry || ctx.msg.content.trim() || ctx.msg.media.length); } else { ctx.userPersistedEarly = this.persistUserMessageEarly(ctx.msg, ctx.session!); + requestTiming("agent.user_persisted"); } } if (ctx.userPersistedEarly && !internalGoal) { @@ -3888,6 +3890,7 @@ export class AgentLoop { if (revalidated.cwd !== sessionWorkspace || revalidated.projectId !== ctx.sessionProjectId) { throw new SessionWorkspaceError("workspace_conflict"); } + requestTiming("agent.prompt_hooks_start"); await this.lifecycleHook().beforeBuildSystemPrompt(new AgentHookContext({ session: ctx.session, sessionKey: ctx.sessionKey, @@ -3895,6 +3898,7 @@ export class AgentLoop { spec: { hostProjectId: ctx.sessionProjectId, workspace: sessionWorkspace }, metadata: { lifecycle: "system_prompt" }, })); + requestTiming("agent.prompt_hooks_end"); const compactionOptions: { replayMaxMessages: number | null; notifyOnLockWait?: boolean; @@ -3920,11 +3924,14 @@ export class AgentLoop { }; compactionOptions.onCompactionEvent = ctx.onTokenCompactionEvent; } + requestTiming("agent.compaction_start"); await (ctx.consolidator ?? this.consolidator).maybeConsolidateByTokens( ctx.session!, compactionOptions, ); + requestTiming("agent.compaction_end"); await initializeDesktopScreenCapture(); + requestTiming("agent.screen_capture_ready"); ctx.tools = this.createToolRegistry("turn", sessionWorkspace, { includeConnectedMcp: true, messageSendCallback: ctx.messageSendCallback, @@ -4035,6 +4042,7 @@ export class AgentLoop { } async stateRun(ctx: TurnContext): Promise { + requestTiming("agent.prepared"); if (ctx.slot?.state === "running") ctx.slot.acceptingSteer = true; const settleSlot = (): void => { if (!ctx.slot || ctx.slot.state === "closed") return; @@ -5477,7 +5485,9 @@ export class AgentLoop { const pendingMarker = new AsyncQueue(); this.pendingQueues.set(key, pendingMarker); try { + requestTiming("agent.tools_start"); await this.initializeRuntimeTools(); + requestTiming("agent.tools_ready"); const msg = new InboundMessage({ channel, chatId, diff --git a/App/memmy-agent/src/core/agent-runtime/runner.ts b/App/memmy-agent/src/core/agent-runtime/runner.ts index 7a28486c0..8691a5a3c 100644 --- a/App/memmy-agent/src/core/agent-runtime/runner.ts +++ b/App/memmy-agent/src/core/agent-runtime/runner.ts @@ -1,3 +1,4 @@ +import { requestTiming } from "../../utils/request-timing.js"; import crypto from "node:crypto"; import path from "node:path"; import { LLMProvider, LLMResponse, ToolCallRequest } from "../../providers/base.js"; @@ -784,6 +785,7 @@ export class AgentRunner { } private async callProvider(provider: any, args: any, stream = false): Promise { + requestTiming("model.call_start"); if (stream) { if (typeof provider.chatStreamWithRetry === "function") return provider.chatStreamWithRetry(args); if (typeof provider.chatStream === "function") return provider.chatStream(args); diff --git a/App/memmy-agent/src/entrypoints/openai-like-api/server.ts b/App/memmy-agent/src/entrypoints/openai-like-api/server.ts index 4de00a531..9217d0262 100644 --- a/App/memmy-agent/src/entrypoints/openai-like-api/server.ts +++ b/App/memmy-agent/src/entrypoints/openai-like-api/server.ts @@ -1,3 +1,4 @@ +import { requestTiming, withRequestTiming } from "../../utils/request-timing.js"; import fs from "node:fs"; import path from "node:path"; import { Config } from "../../config/schema.js"; @@ -249,6 +250,10 @@ async function callAgent(ctx: ApiContext, args: Record): Promise { + return withRequestTiming(request.headers.get("x-memmy-trace-id"), () => handleTimedChatCompletions(request, ctx)); +} + +async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): Promise { const context = ctx ?? { agentLoop: null, modelName: "memmy", @@ -324,10 +329,12 @@ export async function handleChatCompletions(request: Request, ctx?: ApiContext): void (async () => { let failed = false; const onStream = async (token: string) => { + if (token) requestTiming("api.first_content", true); if (token) emitted = true; write(sseChunk(token, context.modelName, chunkId)); }; const onProgress = withProgressCapabilities(async (content: string, options: Record = {}) => { + if (options.reasoning && content) requestTiming("api.first_reasoning", true); if (options.reasoning && content) write(sseMemmyEvent("thinking_delta", { text: content }, context.modelName, chunkId)); if (options.reasoningEnd) write(sseMemmyEvent("thinking_end", {}, context.modelName, chunkId)); for (const event of options.toolEvents ?? []) { @@ -354,6 +361,7 @@ export async function handleChatCompletions(request: Request, ctx?: ApiContext): const onStreamEnd = async () => undefined; try { await withSessionLock(context, sessionKey, async () => { + requestTiming("api.session_lock_acquired"); const response = await withTimeout( Promise.resolve( callAgent(context, { @@ -371,12 +379,14 @@ export async function handleChatCompletions(request: Request, ctx?: ApiContext): } }); } catch { + requestTiming("api.error"); failed = true; } finally { if (!failed) { write(sseChunk("", context.modelName, chunkId, "stop")); write(SSE_DONE); } + requestTiming("api.finished"); close(); } })(); diff --git a/App/memmy-agent/src/providers/openai-compat-provider.ts b/App/memmy-agent/src/providers/openai-compat-provider.ts index 7b9b72d9b..3efa5f5f3 100644 --- a/App/memmy-agent/src/providers/openai-compat-provider.ts +++ b/App/memmy-agent/src/providers/openai-compat-provider.ts @@ -1,3 +1,4 @@ +import { requestTiming } from "../utils/request-timing.js"; import crypto from "crypto"; import OpenAI from "openai"; import { @@ -1172,11 +1173,14 @@ export class OpenAICompatProvider extends LLMProvider { } if (args.signal?.aborted) throw createProviderAbortError(); const options = providerAbortOptions(args.signal); + requestTiming("model.http_start"); const stream = options ? await this.client.chat.completions.create(kwargs as any, options as any) : await this.client.chat.completions.create(kwargs as any); + requestTiming("model.http_headers"); const chunks: any[] = []; for await (const chunk of timedAsyncIterable(stream, idleTimeoutS)) { + requestTiming("model.first_chunk", true); if (args.signal?.aborted) throw createProviderAbortError(); chunks.push(chunk); const choice = diff --git a/App/memmy-agent/src/utils/request-timing.ts b/App/memmy-agent/src/utils/request-timing.ts new file mode 100644 index 000000000..2b4a80713 --- /dev/null +++ b/App/memmy-agent/src/utils/request-timing.ts @@ -0,0 +1,24 @@ +import { AsyncLocalStorage } from "node:async_hooks"; +import { randomUUID } from "node:crypto"; + +const timing = new AsyncLocalStorage<{ traceId: string; started: number; seen: Set }>(); + +/** Timing fields only: never serialize prompts, credentials, URLs or errors. */ +export function requestTiming(stage: string, once = false): void { + const context = timing.getStore(); + if (!context || (once && context.seen.has(stage))) return; + context.seen.add(stage); + process.stderr.write(`[memmy-timing] ${JSON.stringify({ + traceId: context.traceId, stage, elapsedMs: Math.round(performance.now() - context.started), + at: new Date().toISOString(), pid: process.pid, + })}\n`); +} + +export function withRequestTiming(traceId: string | null, fn: () => T): T { + if (process.env.MEMMY_TIMING_LOG !== "1") return fn(); + const safeId = traceId && /^[a-zA-Z0-9_-]{1,96}$/.test(traceId) ? traceId : randomUUID(); + return timing.run({ traceId: safeId, started: performance.now(), seen: new Set() }, () => { + requestTiming("api.received"); + return fn(); + }); +} diff --git a/App/memmy-agent/tests/utils/request-timing.test.ts b/App/memmy-agent/tests/utils/request-timing.test.ts new file mode 100644 index 000000000..86ab5952a --- /dev/null +++ b/App/memmy-agent/tests/utils/request-timing.test.ts @@ -0,0 +1,49 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { requestTiming, withRequestTiming } from "../../src/utils/request-timing.js"; +import { createApp } from "../../src/entrypoints/openai-like-api/server.js"; + +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); }); + +it("keeps concurrent request timings separate and excludes prompt and invalid trace input", async () => { + vi.stubEnv("MEMMY_TIMING_LOG", "1"); + const lines: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((chunk: any) => { lines.push(String(chunk)); return true; }); + await Promise.all(["trace-a", "trace-b"].map((id) => withRequestTiming(id, async () => { + await Promise.resolve(); + requestTiming("model.first_chunk", true); + requestTiming("model.first_chunk", true); + }))); + const records = lines.map((line) => JSON.parse(line.slice(15))); + for (const id of ["trace-a", "trace-b"]) { + expect(records.filter((row) => row.traceId === id).map((row) => row.stage)).toEqual(["api.received", "model.first_chunk"]); + } + withRequestTiming("Bearer secret\nprompt", () => requestTiming("api.finished")); + expect(lines.join("")).not.toMatch(/secret|prompt/); + vi.stubEnv("MEMMY_TIMING_LOG", "0"); + const count = lines.length; + withRequestTiming("disabled", () => requestTiming("api.finished")); + expect(lines).toHaveLength(count); +}); + +it("retains the trace through an SSE response without logging its content", async () => { + vi.stubEnv("MEMMY_TIMING_LOG", "1"); + const lines: string[] = []; + vi.spyOn(process.stderr, "write").mockImplementation((chunk: any) => { lines.push(String(chunk)); return true; }); + const app = createApp({ processDirect: async (args: any) => { + await Promise.resolve(); + requestTiming("model.http_start"); + await args.onStream("private reply"); + return "private reply"; + } }, "test"); + const response = await app.fetch(new Request("http://localhost/v1/chat/completions", { + method: "POST", headers: { "content-type": "application/json", "x-memmy-trace-id": "trace-sse" }, + body: JSON.stringify({ stream: true, messages: [{ role: "user", content: "private prompt" }] }), + })); + expect(await response.text()).toContain("private reply"); + const records = lines.map((line) => JSON.parse(line.slice(15))); + expect(records.map((row) => row.stage)).toEqual([ + "api.received", "api.session_lock_acquired", "model.http_start", "api.first_content", "api.finished", + ]); + expect(records.every((row) => row.traceId === "trace-sse" && row.elapsedMs >= 0)).toBe(true); + expect(lines.join("")).not.toContain("private"); +}); From 27e0418fb285dca877ead6239323406c7d29cbd1 Mon Sep 17 00:00:00 2001 From: mikotosuki123 Date: Mon, 12 Oct 2026 20:09:17 +0800 Subject: [PATCH 23/85] fix: wake exec waits on process completion and buffered output --- .../core/agent-runtime/tools/exec-session.ts | 103 +++++-- .../tools/exec-session-wait.test.ts | 276 ++++++++++++++++++ 2 files changed, 358 insertions(+), 21 deletions(-) create mode 100644 App/memmy-agent/tests/core/agent-runtime/tools/exec-session-wait.test.ts diff --git a/App/memmy-agent/src/core/agent-runtime/tools/exec-session.ts b/App/memmy-agent/src/core/agent-runtime/tools/exec-session.ts index 96724ad3c..acfb887e1 100644 --- a/App/memmy-agent/src/core/agent-runtime/tools/exec-session.ts +++ b/App/memmy-agent/src/core/agent-runtime/tools/exec-session.ts @@ -11,6 +11,8 @@ export const DEFAULT_MAX_OUTPUT_CHARS = 10_000; export const MAX_OUTPUT_CHARS = 50_000; const STDIO_DRAIN_GRACE_MS = 1000; const PROCESS_TERMINATE_GRACE_MS = 1000; +// Batch progress output without turning every stream chunk into a tool return. +const OUTPUT_COALESCE_MS = 1000; export const EXEC_LIFECYCLE_ERROR = "Error: Command process exited but descendants kept stdout/stderr open; the managed process tree was terminated."; @@ -41,10 +43,6 @@ export type ExecSessionInfo = { returncode: number | null; }; -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - function processExited(child: ChildProcess): boolean { return child.exitCode != null || child.signalCode != null; } @@ -169,18 +167,17 @@ export async function drainExitedProcessStreams({ streamsClosed, forceCloseStreams, options, + abortSignal, }: { child: ChildProcess; closePromise: Promise; streamsClosed: () => boolean; forceCloseStreams: () => void; options?: ManagedProcessOptions; + abortSignal?: AbortSignal | null; }): Promise { if (streamsClosed()) return null; - const closedDuringDrain = await Promise.race([ - closePromise.then(() => true), - sleep(STDIO_DRAIN_GRACE_MS).then(() => streamsClosed()), - ]); + const closedDuringDrain = await waitForDrain(closePromise, streamsClosed, abortSignal); if (closedDuringDrain || streamsClosed()) return null; await terminateManagedProcessTree(child, options); if (!streamsClosed()) forceCloseStreams(); @@ -201,20 +198,27 @@ function throwIfAborted(signal?: AbortSignal | null): void { if (signal?.aborted) throw createToolAbortError(); } -function abortableSleep(ms: number, signal?: AbortSignal | null): Promise { - if (ms <= 0) return Promise.resolve(); +function waitForDrain( + closePromise: Promise, + streamsClosed: () => boolean, + signal?: AbortSignal | null, +): Promise { throwIfAborted(signal); - if (!signal) return sleep(ms); return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - signal.removeEventListener("abort", onAbort); - resolve(); - }, ms); - const onAbort = () => { + let settled = false; + const finish = (closed: boolean, error?: Error) => { + if (settled) return; + settled = true; clearTimeout(timer); - reject(createToolAbortError()); + signal?.removeEventListener("abort", onAbort); + if (error) reject(error); + else resolve(closed); }; - signal.addEventListener("abort", onAbort, { once: true }); + const onAbort = () => finish(false, createToolAbortError()); + const timer = setTimeout(() => finish(streamsClosed()), STDIO_DRAIN_GRACE_MS); + signal?.addEventListener("abort", onAbort, { once: true }); + void closePromise.then(() => finish(true)); + if (signal?.aborted) onAbort(); }); } @@ -241,6 +245,7 @@ export class ExecSession { deadlineMs: number; lastAccess = Date.now(); private chunks: string[] = []; + private outputStartedAt: number | null = null; private timedOut = false; private stderrStarted = false; private readonly stdoutDecoder: CommandOutputDecoder; @@ -251,6 +256,7 @@ export class ExecSession { private lifecycleError: string | null = null; private readonly closePromise: Promise; private resolveClose!: () => void; + private readonly outputWaiters = new Set<() => void>(); constructor({ command, @@ -296,7 +302,7 @@ export class ExecSession { args.push("-c", command); this.process = spawnProcess(resolvedShell, args, { cwd, env, detached: true }); } - this.process.on("error", (error) => this.chunks.push(`Error executing command: ${error.message}`)); + this.process.on("error", (error) => this.appendStdout(`Error executing command: ${error.message}`)); this.process.stdout.on("data", (chunk: Buffer) => { this.stdoutDecoder.push(chunk); this.appendStdout(this.stdoutDecoder.read()); @@ -348,7 +354,7 @@ export class ExecSession { ): Promise { this.lastAccess = Date.now(); throwIfAborted(abortSignal); - if (yieldTimeMs > 0 && !this.isDone()) await abortableSleep(Math.min(yieldTimeMs, MAX_YIELD_MS), abortSignal); + if (yieldTimeMs > 0) await this.waitForActivity(Math.min(yieldTimeMs, MAX_YIELD_MS), abortSignal); throwIfAborted(abortSignal); if (!this.isDone() && Date.now() >= this.deadlineMs) { this.timedOut = true; @@ -360,11 +366,13 @@ export class ExecSession { closePromise: this.closePromise, streamsClosed: () => this.streamsClosed, forceCloseStreams: () => this.forceCloseStreams(), + abortSignal, }); } throwIfAborted(abortSignal); const raw = this.chunks.join(""); this.chunks = []; + this.outputStartedAt = null; const [output, truncated] = truncateOutput(raw, maxOutputChars); const elapsed = Math.max(0, (Date.now() - this.startedAt) / 1000); const done = this.streamsClosed; @@ -381,14 +389,66 @@ export class ExecSession { }; } + private waitForActivity(ms: number, signal?: AbortSignal | null): Promise { + throwIfAborted(signal); + if (this.isDone() || this.streamsClosed) return Promise.resolve(); + return new Promise((resolve, reject) => { + let settled = false; + let outputTimer: ReturnType | undefined; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(waitTimer); + clearTimeout(outputTimer); + this.outputWaiters.delete(onOutput); + this.process.removeListener("exit", onDone); + this.process.removeListener("close", onDone); + signal?.removeEventListener("abort", onAbort); + if (error) reject(error); + else resolve(); + }; + const onDone = () => finish(); + const onAbort = () => finish(createToolAbortError()); + const onOutput = () => { + if (this.isDone() || this.streamsClosed) { + finish(); + } else if (this.chunks.length > 0 && outputTimer === undefined) { + // Anchor at the first readable output, never debounce continuous logs. + const remaining = Math.max(0, (this.outputStartedAt ?? Date.now()) + OUTPUT_COALESCE_MS - Date.now()); + if (remaining === 0) finish(); + else outputTimer = setTimeout(onDone, remaining); + } + }; + const waitMs = Math.max(0, Math.min(ms, this.deadlineMs - Date.now())); + const waitTimer = setTimeout(onDone, waitMs); + this.process.once("exit", onDone); + this.process.once("close", onDone); + this.outputWaiters.add(onOutput); + signal?.addEventListener("abort", onAbort, { once: true }); + // Recheck after subscribing so completion, cancellation and buffered + // output cannot fall between checking state and installing listeners. + if (signal?.aborted) onAbort(); + else onOutput(); + }); + } + + private notifyOutputWaiters(): void { + for (const wake of this.outputWaiters) wake(); + } + private appendStdout(text: string): void { - if (text) this.chunks.push(text); + if (!text) return; + this.chunks.push(text); + this.outputStartedAt ??= Date.now(); + this.notifyOutputWaiters(); } private appendStderr(text: string): void { if (!text) return; this.chunks.push(this.stderrStarted ? text : `STDERR:\n${text}`); + this.outputStartedAt ??= Date.now(); this.stderrStarted = true; + this.notifyOutputWaiters(); } private finishStdoutOnce(): void { @@ -412,6 +472,7 @@ export class ExecSession { if (this.streamsClosed) return; this.streamsClosed = true; this.resolveClose(); + this.notifyOutputWaiters(); } } diff --git a/App/memmy-agent/tests/core/agent-runtime/tools/exec-session-wait.test.ts b/App/memmy-agent/tests/core/agent-runtime/tools/exec-session-wait.test.ts new file mode 100644 index 000000000..620a9bb65 --- /dev/null +++ b/App/memmy-agent/tests/core/agent-runtime/tools/exec-session-wait.test.ts @@ -0,0 +1,276 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ExecSession, type SessionPoll } from "../../../../src/core/agent-runtime/tools/exec-session.js"; + +const children: any[] = []; + +function childProcess() { + const child = new EventEmitter() as any; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.exitCode = null; + child.signalCode = null; + child.kill = vi.fn(() => { + child.signalCode = "SIGTERM"; + child.stdout.destroy(); + child.stderr.destroy(); + child.emit("exit", null, "SIGTERM"); + close(child); + return true; + }); + children.push(child); + return child; +} + +function close(child: any) { + child.stdout.end(); + child.stderr.end(); + child.emit("close", child.exitCode, child.signalCode); +} + +function exit(child: any, code = 0) { + child.exitCode = code; + child.emit("exit", code, null); + close(child); +} + +function sessionFor(child: any, timeoutS?: number) { + return new ExecSession({ command: "controlled child", timeoutS, spawnProcess: (() => child) as any }); +} + +function observe(pending: Promise) { + const state: { settled: boolean; poll?: SessionPoll; error?: Error } = { settled: false }; + void pending.then((poll) => { state.settled = true; state.poll = poll; }, (error) => { state.settled = true; state.error = error; }); + return state; +} + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(0); +}); + +afterEach(async () => { + for (const child of children.splice(0)) exit(child); + await vi.runOnlyPendingTimersAsync(); + vi.clearAllTimers(); + vi.useRealTimers(); +}); + +describe("exec session waiting", () => { + it("returns when a silent process completes instead of waiting 30 seconds", async () => { + const child = childProcess(); + const state = observe(sessionFor(child).poll(30_000, 10_000)); + await vi.advanceTimersByTimeAsync(100); + exit(child); + await vi.advanceTimersByTimeAsync(0); + expect(state.poll).toMatchObject({ done: true, exitCode: 0, output: "", elapsedS: 0.1 }); + expect(vi.getTimerCount()).toBe(0); + expect(child.listenerCount("exit")).toBe(0); + }); + + it("wakes on signal exit with a null exit code", async () => { + const child = childProcess(); + const state = observe(sessionFor(child).poll(30_000, 10_000)); + await vi.advanceTimersByTimeAsync(50); + child.kill(); + await vi.advanceTimersByTimeAsync(0); + expect(state.poll).toMatchObject({ done: true, exitCode: null, elapsedS: 0.05 }); + expect(vi.getTimerCount()).toBe(0); + }); + + it("returns immediately if the process has already completed", async () => { + const child = childProcess(); + const session = sessionFor(child); + child.stdout.write("finished\n"); + exit(child); + expect(await session.poll(30_000, 10_000)).toMatchObject({ done: true, output: "finished\n", elapsedS: 0 }); + expect(vi.getTimerCount()).toBe(0); + }); + + it("coalesces stdout and stderr for one second from the first readable output", async () => { + const child = childProcess(); + const session = sessionFor(child); + const state = observe(session.poll(30_000, 10_000)); + await vi.advanceTimersByTimeAsync(100); + child.stdout.write("first\n"); + await vi.advanceTimersByTimeAsync(500); + child.stderr.write("second\n"); + await vi.advanceTimersByTimeAsync(499); + expect(state.settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(state.poll).toMatchObject({ done: false, output: "first\nSTDERR:\nsecond\n", elapsedS: 1.1 }); + expect(vi.getTimerCount()).toBe(0); + expect(child.listenerCount("exit")).toBe(0); + expect(child.listenerCount("close")).toBe(1); + child.stdout.write("third\n"); + expect((await session.poll(0, 10_000)).output).toBe("third\n"); + expect((await session.poll(0, 10_000)).output).toBe(""); + }); + + it("also coalesces output that was buffered before polling", async () => { + const child = childProcess(); + const session = sessionFor(child); + child.stdout.write("buffered\n"); + const state = observe(session.poll(30_000, 10_000)); + await vi.advanceTimersByTimeAsync(999); + expect(state.settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(state.poll).toMatchObject({ done: false, output: "buffered\n", elapsedS: 1 }); + }); + + it("returns stderr-only output without waiting the full yield", async () => { + const child = childProcess(); + const session = sessionFor(child); + const state = observe(session.poll(30_000, 10_000)); + child.stderr.write("diagnostic\n"); + await vi.advanceTimersByTimeAsync(1000); + expect(state.poll).toMatchObject({ done: false, output: "STDERR:\ndiagnostic\n" }); + }); + + it("does not restart an expired output window when a later poll begins", async () => { + const child = childProcess(); + const session = sessionFor(child); + child.stdout.write("ready-before-poll\n"); + await vi.advanceTimersByTimeAsync(1200); + expect(await session.poll(30_000, 10_000)).toMatchObject({ done: false, output: "ready-before-poll\n", elapsedS: 1.2 }); + expect(vi.getTimerCount()).toBe(0); + }); + + it("does not restart the coalescing window for continuous small chunks", async () => { + const child = childProcess(); + const state = observe(sessionFor(child).poll(30_000, 10_000)); + for (let i = 1; i <= 80; i++) { + await vi.advanceTimersByTimeAsync(10); + child.stdout.write(`chunk-${i}\n`); + expect(state.settled).toBe(false); + } + await vi.advanceTimersByTimeAsync(209); + expect(state.settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(state.poll?.output).toBe(Array.from({ length: 80 }, (_, i) => `chunk-${i + 1}\n`).join("")); + expect(state.poll?.elapsedS).toBe(1.01); + expect(vi.getTimerCount()).toBe(0); + }); + + it("respects a shorter yield instead of extending it for coalescing", async () => { + const child = childProcess(); + const state = observe(sessionFor(child).poll(200, 10_000)); + await vi.advanceTimersByTimeAsync(100); + child.stdout.write("progress\n"); + await vi.advanceTimersByTimeAsync(100); + expect(state.poll).toMatchObject({ done: false, output: "progress\n", elapsedS: 0.2 }); + expect(vi.getTimerCount()).toBe(0); + }); + + it("returns at the yield deadline when no output arrives", async () => { + const state = observe(sessionFor(childProcess()).poll(1000, 10_000)); + await vi.advanceTimersByTimeAsync(999); + expect(state.settled).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(state.poll).toMatchObject({ done: false, output: "", elapsedS: 1 }); + }); + + it("keeps zero-yield reads immediate and preserves the output limit", async () => { + const child = childProcess(); + const session = sessionFor(child); + child.stdout.write("a".repeat(2000)); + expect(await session.poll(0, 1000)).toMatchObject({ done: false, truncatedChars: 1000, elapsedS: 0 }); + expect((await session.poll(0, 1000)).output).toBe(""); + expect(vi.getTimerCount()).toBe(0); + }); + + it("bypasses coalescing on completion and waits for the last stream bytes", async () => { + const child = childProcess(); + const session = sessionFor(child); + const state = observe(session.poll(30_000, 10_000)); + child.stdout.write("head|"); + await vi.advanceTimersByTimeAsync(100); + child.exitCode = 0; + child.emit("exit", 0, null); + await vi.advanceTimersByTimeAsync(0); + expect(state.settled).toBe(false); + await vi.advanceTimersByTimeAsync(50); + child.stdout.write("tail\n"); + child.stderr.write("final-error\n"); + close(child); + await vi.advanceTimersByTimeAsync(0); + expect(state.poll).toMatchObject({ done: true, output: "head|tail\nSTDERR:\nfinal-error\n", elapsedS: 0.15 }); + expect((await session.poll(0, 10_000)).output).toBe(""); + expect(vi.getTimerCount()).toBe(0); + }); + + it("cancels during coalescing and preserves unread output for a subsequent poll", async () => { + const child = childProcess(); + const session = sessionFor(child); + const controller = new AbortController(); + const state = observe(session.poll(30_000, 10_000, {}, controller.signal)); + child.stdout.write("keep-this\n"); + await vi.advanceTimersByTimeAsync(50); + controller.abort(); + await vi.advanceTimersByTimeAsync(0); + expect(state.error?.name).toBe("AbortError"); + expect(child.kill).not.toHaveBeenCalled(); + expect(vi.getTimerCount()).toBe(0); + expect(child.listenerCount("exit")).toBe(0); + expect((await session.poll(0, 10_000)).output).toBe("keep-this\n"); + }); + + it("cancels while draining an exited process without consuming its final output", async () => { + const child = childProcess(); + const session = sessionFor(child); + const controller = new AbortController(); + child.exitCode = 0; + child.emit("exit", 0, null); + const state = observe(session.poll(30_000, 10_000, {}, controller.signal)); + await vi.advanceTimersByTimeAsync(50); + controller.abort(); + await vi.advanceTimersByTimeAsync(0); + expect(state.error?.name).toBe("AbortError"); + expect(vi.getTimerCount()).toBe(0); + child.stdout.write("late-tail\n"); + close(child); + expect(await session.poll(0, 10_000)).toMatchObject({ done: true, output: "late-tail\n" }); + }); + + it("rejects an already-cancelled poll without installing wait listeners", async () => { + const child = childProcess(); + const controller = new AbortController(); + controller.abort(); + await expect(sessionFor(child).poll(30_000, 10_000, {}, controller.signal)).rejects.toMatchObject({ name: "AbortError" }); + expect(vi.getTimerCount()).toBe(0); + expect(child.listenerCount("exit")).toBe(0); + }); + + it("honors the command timeout before a longer yield expires", async () => { + const child = childProcess(); + const state = observe(sessionFor(child, 0.1).poll(30_000, 10_000)); + await vi.advanceTimersByTimeAsync(100); + expect(state.poll).toMatchObject({ done: true, timedOut: true, elapsedS: 0.1 }); + expect(child.kill).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); + + it("wakes when a spawn failure closes streams even without an exit code", async () => { + const child = childProcess(); + const state = observe(sessionFor(child).poll(30_000, 10_000)); + child.emit("error", new Error("spawn failed")); + close(child); + await vi.advanceTimersByTimeAsync(0); + expect(state.poll).toMatchObject({ done: true, exitCode: null, output: "Error executing command: spawn failed" }); + expect(vi.getTimerCount()).toBe(0); + }); + + it("settles once when completion and the yield deadline coincide", async () => { + const child = childProcess(); + const session = sessionFor(child); + setTimeout(() => exit(child), 1000); + const settled = vi.fn(); + const pending = session.poll(1000, 10_000).then((poll) => { settled(); return poll; }); + await vi.advanceTimersByTimeAsync(1000); + expect(await pending).toMatchObject({ done: true, exitCode: 0 }); + expect(settled).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + }); +}); From ca38419ff1741bc649ddcc10a61fedc1f371363c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Sat, 10 Oct 2026 13:24:37 +0800 Subject: [PATCH 24/85] fix: locate bundled Codex CLI for hook trust installation --- .../outbound/skill-writer/codex/hook-trust.ts | 14 ++++++++++++-- .../agent-source/integration/codex/hook-trust.ts | 14 ++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/App/backend/src/adapters/outbound/skill-writer/codex/hook-trust.ts b/App/backend/src/adapters/outbound/skill-writer/codex/hook-trust.ts index a46425ca0..27edba3f5 100644 --- a/App/backend/src/adapters/outbound/skill-writer/codex/hook-trust.ts +++ b/App/backend/src/adapters/outbound/skill-writer/codex/hook-trust.ts @@ -1,6 +1,7 @@ /** Codex hook trust persistence through the Codex app-server protocol. */ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { accessSync, constants, statSync } from "node:fs"; +import { homedir } from "node:os"; import { basename, join, normalize } from "node:path"; const APP_SERVER_REQUEST_TIMEOUT_MS = 10_000; @@ -283,8 +284,17 @@ function handleResponseLine(line: string, pending: Map): function resolveCodexExecutable(codexHomeDirectory: string): string { const executableName = process.platform === "win32" ? "codex.exe" : "codex"; - const bundledExecutable = join(codexHomeDirectory, "plugins", ".plugin-appserver", executableName); - return isExecutableFile(bundledExecutable) ? bundledExecutable : executableName; + const candidates = [join(codexHomeDirectory, "plugins", ".plugin-appserver", executableName)]; + if (process.platform === "darwin") { + // The desktop process may not inherit the shell PATH where ChatGPT exposes Codex CLI. + const codexCliPath = join("Contents", "Resources", "codex-cli", "CodexCLI.app", "Contents", "MacOS", "codex"); + candidates.push( + join("/Applications", "ChatGPT.app", codexCliPath), + join(homedir(), "Applications", "ChatGPT.app", codexCliPath) + ); + } + + return candidates.find(isExecutableFile) ?? executableName; } function isExecutableFile(filePath: string): boolean { diff --git a/Memory/src/agent-source/integration/codex/hook-trust.ts b/Memory/src/agent-source/integration/codex/hook-trust.ts index a46425ca0..27edba3f5 100644 --- a/Memory/src/agent-source/integration/codex/hook-trust.ts +++ b/Memory/src/agent-source/integration/codex/hook-trust.ts @@ -1,6 +1,7 @@ /** Codex hook trust persistence through the Codex app-server protocol. */ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { accessSync, constants, statSync } from "node:fs"; +import { homedir } from "node:os"; import { basename, join, normalize } from "node:path"; const APP_SERVER_REQUEST_TIMEOUT_MS = 10_000; @@ -283,8 +284,17 @@ function handleResponseLine(line: string, pending: Map): function resolveCodexExecutable(codexHomeDirectory: string): string { const executableName = process.platform === "win32" ? "codex.exe" : "codex"; - const bundledExecutable = join(codexHomeDirectory, "plugins", ".plugin-appserver", executableName); - return isExecutableFile(bundledExecutable) ? bundledExecutable : executableName; + const candidates = [join(codexHomeDirectory, "plugins", ".plugin-appserver", executableName)]; + if (process.platform === "darwin") { + // The desktop process may not inherit the shell PATH where ChatGPT exposes Codex CLI. + const codexCliPath = join("Contents", "Resources", "codex-cli", "CodexCLI.app", "Contents", "MacOS", "codex"); + candidates.push( + join("/Applications", "ChatGPT.app", codexCliPath), + join(homedir(), "Applications", "ChatGPT.app", codexCliPath) + ); + } + + return candidates.find(isExecutableFile) ?? executableName; } function isExecutableFile(filePath: string): boolean { From f0bd53b9da7d2af343464d1c856a11c38da48e85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Mon, 12 Oct 2026 11:15:05 +0800 Subject: [PATCH 25/85] fix: preserve hook removals and roll back failed Codex trust --- .../outbound/skill-writer/codex/target.ts | 105 ++++++++++++++---- .../skill-writer/codex/tests/target.test.ts | 43 ++++++- .../agent-source-store/repository.ts | 21 ++++ .../tests/repository.test.ts | 19 ++++ ...0-agent-source-auto-inject-suppression.sql | 3 + .../app-state-store/tests/index.test.ts | 4 +- .../agent-source-auto-inject-service.ts | 23 +++- .../src/services/agent-source-service.ts | 21 +++- App/backend/src/services/index.ts | 1 + .../agent-source-auto-inject-service.test.ts | 54 +++++++++ .../tests/agent-source-service.test.ts | 5 + .../agent-source/integration/codex/target.ts | 105 ++++++++++++++---- Memory/src/agent-source/runtime.ts | 30 ++++- Memory/tests/agent-source-runtime.test.ts | 62 ++++++++++- 14 files changed, 436 insertions(+), 60 deletions(-) create mode 100644 App/backend/src/infrastructure/app-state-store/migrations/0030-agent-source-auto-inject-suppression.sql diff --git a/App/backend/src/adapters/outbound/skill-writer/codex/target.ts b/App/backend/src/adapters/outbound/skill-writer/codex/target.ts index a292d4d33..2c0c65ab5 100644 --- a/App/backend/src/adapters/outbound/skill-writer/codex/target.ts +++ b/App/backend/src/adapters/outbound/skill-writer/codex/target.ts @@ -1,5 +1,5 @@ /** Target module. */ -import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { lstat, mkdir, readFile, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import { resolveCodexHomeDirectory } from "../../agent-paths.js"; @@ -118,18 +118,26 @@ export function createCodexSkillTarget(deps: CreateCodexSkillTargetDeps = {}): S memmy_config_path: memmyConfigPath, ...(await readMemmyMemoryServiceConfig(memmyConfigPath)) }; - await mkdir(hookDirectory, { recursive: true }); - await writeFileAtomically(hookConfigPath, `${JSON.stringify(hookConfig, null, 2)}\n`); - await writeFileAtomically(hookScriptPath, renderMemmyResumeHookScript({ source: CODEX_TARGET_ID, mode: "codex" })); - await writeFileAtomically( - join(hookDirectory, WORKSPACE_BRIDGE_FILE_NAME), - await loadMemmyWorkspaceBridgeRuntimeAsset() - ); const hooksFilePath = join(root, HOOKS_FILE_NAME); + const bridgePath = join(hookDirectory, WORKSPACE_BRIDGE_FILE_NAME); const hookCommand = createNodeHookCommand(hookScriptPath); - await upsertCodexHookConfig(hooksFilePath, hookCommand); - await rm(join(root, HOOK_DIRECTORY_NAME, LEGACY_HOOK_SCRIPT_FILE_NAME), { force: true }); + await withCodexHookFileRollback( + [hookConfigPath, hookScriptPath, bridgePath, hooksFilePath], + async () => { + await mkdir(hookDirectory, { recursive: true }); + await writeFileAtomically(hookConfigPath, `${JSON.stringify(hookConfig, null, 2)}\n`); + await writeFileAtomically(hookScriptPath, renderMemmyResumeHookScript({ source: CODEX_TARGET_ID, mode: "codex" })); + await writeFileAtomically(bridgePath, await loadMemmyWorkspaceBridgeRuntimeAsset()); + await upsertCodexHookConfig(hooksFilePath, hookCommand); + await trustHooks({ codexHomeDirectory: root, hooksFilePath, hookCommand }); + await writeFileAtomically( + hookConfigPath, + `${JSON.stringify({ ...hookConfig, ...(await memmyHookRevisionField(CODEX_TARGET_ID, "codex")) }, null, 2)}\n` + ); + } + ); + await rm(join(hookDirectory, LEGACY_HOOK_SCRIPT_FILE_NAME), { force: true }); const manifest = renderMemmyPluginSkillManifest(_targetId); const filePath = join(root, TARGET_FILE_NAME); await writeFileAtomically( @@ -138,15 +146,6 @@ export function createCodexSkillTarget(deps: CreateCodexSkillTargetDeps = {}): S ); await replaceMemmySkillDirectory(root, manifest); await replaceMemmyResumeSkillDirectory(root, CODEX_TARGET_ID); - await trustHooks({ - codexHomeDirectory: root, - hooksFilePath, - hookCommand - }); - await writeFileAtomically( - hookConfigPath, - `${JSON.stringify({ ...hookConfig, ...(await memmyHookRevisionField(CODEX_TARGET_ID, "codex")) }, null, 2)}\n` - ); }, async uninstallPlugin(_targetId) { @@ -335,11 +334,73 @@ function createMarkerBlockPattern(startMarker: string, endMarker = END_MARKER): return new RegExp(`${escapeRegExp(startMarker)}\\n[\\s\\S]*?${escapeRegExp(endMarker)}\\n?`, "m"); } -async function writeFileAtomically(filePath: string, content: string): Promise { +async function writeFileAtomically(filePath: string, content: string | Buffer, mode?: number): Promise { await mkdir(dirname(filePath), { recursive: true }); const tempPath = join(dirname(filePath), `.${basename(filePath)}.${process.pid}.${Date.now()}.tmp`); - await writeFile(tempPath, content, "utf8"); - await rename(tempPath, filePath); + try { + await writeFile(tempPath, content, { encoding: "utf8", ...(mode === undefined ? {} : { mode }) }); + await rename(tempPath, filePath); + } finally { + await rm(tempPath, { force: true }); + } +} + +type CodexHookFileSnapshot = + | { path: string; kind: "missing" } + | { path: string; kind: "file"; content: Buffer; mode: number } + | { path: string; kind: "symlink"; target: string }; + +async function withCodexHookFileRollback(paths: string[], operation: () => Promise): Promise { + const snapshots = await Promise.all(paths.map(snapshotCodexHookFile)); + try { + return await operation(); + } catch (error) { + const rollbackResults = await Promise.allSettled(snapshots.map(restoreCodexHookFile)); + const rollbackErrors = rollbackResults.flatMap((result) => result.status === "rejected" ? [result.reason] : []); + if (rollbackErrors.length > 0) { + throw new AggregateError( + [error, ...rollbackErrors], + "Codex hook installation failed and its files could not be fully rolled back" + ); + } + + throw error; + } +} + +async function snapshotCodexHookFile(filePath: string): Promise { + try { + const metadata = await lstat(filePath); + if (metadata.isSymbolicLink()) { + return { path: filePath, kind: "symlink", target: await readlink(filePath) }; + } + if (!metadata.isFile()) { + throw new Error(`Codex hook path is not a regular file: ${filePath}`); + } + + return { path: filePath, kind: "file", content: await readFile(filePath), mode: metadata.mode & 0o777 }; + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + return { path: filePath, kind: "missing" }; + } + + throw error; + } +} + +async function restoreCodexHookFile(snapshot: CodexHookFileSnapshot): Promise { + if (snapshot.kind === "missing") { + await rm(snapshot.path, { force: true }); + return; + } + + if (snapshot.kind === "symlink") { + await rm(snapshot.path, { force: true }); + await symlink(snapshot.target, snapshot.path); + return; + } + + await writeFileAtomically(snapshot.path, snapshot.content, snapshot.mode); } function escapeRegExp(input: string): string { diff --git a/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts index 5a3e6855e..3db0efef8 100644 --- a/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts @@ -1,5 +1,5 @@ /** Target tests. */ -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { spawn } from "node:child_process"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; @@ -184,6 +184,47 @@ describe("codex skill target", () => { }); await expect(target.installPlugin?.("codex")).rejects.toThrow("trust failed"); + expect(existsSync(join(rootDirectory, "hooks.json"))).toBe(false); + expect(existsSync(join(rootDirectory, "hooks", "memmy-resume-hook.mjs"))).toBe(false); + expect(existsSync(join(rootDirectory, "hooks", "memmy-memory-config.json"))).toBe(false); + expect(existsSync(join(rootDirectory, "hooks", "memmy-workspace-bridge.mjs"))).toBe(false); + expect(existsSync(join(rootDirectory, "AGENTS.md"))).toBe(false); + expect(existsSync(join(rootDirectory, "skills", "memmy-memory"))).toBe(false); + }); + + it("restores existing hook files and links when Codex cannot persist hook trust", async () => { + const { rootDirectory, memmyConfigPath } = createFixture(); + const hookDirectory = join(rootDirectory, "hooks"); + mkdirSync(hookDirectory); + const hooksFilePath = join(rootDirectory, "hooks.json"); + const hookConfigPath = join(hookDirectory, "memmy-memory-config.json"); + const hookScriptPath = join(hookDirectory, "memmy-resume-hook.mjs"); + const bridgePath = join(hookDirectory, "memmy-workspace-bridge.mjs"); + const bridgeTargetPath = join(rootDirectory, "previous-workspace-bridge.mjs"); + const originalHooks = '{"hooks":{"Stop":[]}}\n'; + writeFileSync(hooksFilePath, originalHooks); + writeFileSync(hookConfigPath, "previous hook config\n"); + writeFileSync(hookScriptPath, "previous hook script\n"); + writeFileSync(bridgeTargetPath, "previous bridge\n"); + symlinkSync(bridgeTargetPath, bridgePath); + const target = createCodexSkillTarget({ + rootDirectory, + memmyConfigPath, + trustHooks: async () => { + throw new Error("trust failed"); + } + }); + + await expect(target.installPlugin?.("codex")).rejects.toThrow("trust failed"); + + expect(readFileSync(hooksFilePath, "utf8")).toBe(originalHooks); + expect(readFileSync(hookConfigPath, "utf8")).toBe("previous hook config\n"); + expect(readFileSync(hookScriptPath, "utf8")).toBe("previous hook script\n"); + expect(lstatSync(bridgePath).isSymbolicLink()).toBe(true); + expect(readlinkSync(bridgePath)).toBe(bridgeTargetPath); + expect(readFileSync(bridgePath, "utf8")).toBe("previous bridge\n"); + expect(existsSync(join(rootDirectory, "AGENTS.md"))).toBe(false); + expect(existsSync(join(rootDirectory, "skills", "memmy-memory"))).toBe(false); }); it("installs a UserPromptSubmit hook that blocks resume commands with top L1 candidates", async () => { diff --git a/App/backend/src/infrastructure/agent-source-store/repository.ts b/App/backend/src/infrastructure/agent-source-store/repository.ts index 12a4fcf58..be0dbf7c0 100644 --- a/App/backend/src/infrastructure/agent-source-store/repository.ts +++ b/App/backend/src/infrastructure/agent-source-store/repository.ts @@ -60,6 +60,8 @@ export interface AgentSourceRepository { upsertSource(input: UpsertAgentSourceInput): void; removeSource(sourceId: string): void; setStatus(sourceId: string, status: AgentSourceStatus): void; + setConnectionState(sourceId: string, status: AgentSourceStatus, autoInjectSuppressed: boolean): void; + isAutoInjectSuppressed(sourceId: string): boolean; setLastScannedAt(sourceId: string, scannedAt: string): void; getScanWatermark(sourceId: string): AgentSourceScanWatermark | null; upsertScanWatermark(input: UpsertAgentSourceScanWatermarkInput): void; @@ -167,6 +169,25 @@ export function createAgentSourceRepository( ); }, + setConnectionState(sourceId, status, autoInjectSuppressed) { + db.prepare( + "UPDATE account_agent_sources SET status = ?, auto_inject_suppressed = ?, updated_at = ? WHERE uuid = ? AND source_id = ?" + ).run( + status, + autoInjectSuppressed ? 1 : 0, + new Date().toISOString(), + INSTALLATION_SCAN_SCOPE_UUID, + sourceId + ); + }, + + isAutoInjectSuppressed(sourceId) { + const row = db.prepare( + "SELECT auto_inject_suppressed FROM account_agent_sources WHERE uuid = ? AND source_id = ?" + ).get(INSTALLATION_SCAN_SCOPE_UUID, sourceId) as { auto_inject_suppressed: 0 | 1 } | undefined; + return row?.auto_inject_suppressed === 1; + }, + setLastScannedAt(sourceId, scannedAt) { db.prepare("UPDATE account_agent_sources SET last_scanned_at = ?, updated_at = ? WHERE uuid = ? AND source_id = ?").run( scannedAt, diff --git a/App/backend/src/infrastructure/agent-source-store/tests/repository.test.ts b/App/backend/src/infrastructure/agent-source-store/tests/repository.test.ts index 159ff5e98..7469b68c8 100644 --- a/App/backend/src/infrastructure/agent-source-store/tests/repository.test.ts +++ b/App/backend/src/infrastructure/agent-source-store/tests/repository.test.ts @@ -90,6 +90,24 @@ describe("agent source repository", () => { expect(repository.listSources()).toEqual([]); }); + it("persists auto-inject suppression across status-only updates", () => { + const repository = createRepository(); + repository.upsertSource({ + sourceId: "cursor", + displayName: "Cursor", + dataPath: "/Users/test/Cursor", + builtin: true + }); + + repository.setConnectionState("cursor", "not_connected", true); + expect(repository.isAutoInjectSuppressed("cursor")).toBe(true); + repository.setStatus("cursor", "plugin_installed"); + expect(repository.isAutoInjectSuppressed("cursor")).toBe(true); + + repository.setConnectionState("cursor", "plugin_installed", false); + expect(repository.isAutoInjectSuppressed("cursor")).toBe(false); + }); + it("tracks ingestion dedupe keys without double-counting repeated marks", () => { const repository = createRepository(); repository.upsertSource({ @@ -184,6 +202,7 @@ function createRepository(): AgentSourceRepository { data_path TEXT NOT NULL, builtin INTEGER NOT NULL CHECK(builtin IN (0,1)), status TEXT NOT NULL DEFAULT 'not_connected', + auto_inject_suppressed INTEGER NOT NULL DEFAULT 0 CHECK(auto_inject_suppressed IN (0,1)), last_scanned_at TEXT, sync_recipe_json TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), diff --git a/App/backend/src/infrastructure/app-state-store/migrations/0030-agent-source-auto-inject-suppression.sql b/App/backend/src/infrastructure/app-state-store/migrations/0030-agent-source-auto-inject-suppression.sql new file mode 100644 index 000000000..55412239c --- /dev/null +++ b/App/backend/src/infrastructure/app-state-store/migrations/0030-agent-source-auto-inject-suppression.sql @@ -0,0 +1,3 @@ +ALTER TABLE account_agent_sources + ADD COLUMN auto_inject_suppressed INTEGER NOT NULL DEFAULT 0 + CHECK(auto_inject_suppressed IN (0,1)); diff --git a/App/backend/src/infrastructure/app-state-store/tests/index.test.ts b/App/backend/src/infrastructure/app-state-store/tests/index.test.ts index 9ffebda22..c46ac36c9 100644 --- a/App/backend/src/infrastructure/app-state-store/tests/index.test.ts +++ b/App/backend/src/infrastructure/app-state-store/tests/index.test.ts @@ -131,8 +131,8 @@ describe("app state store migrations", () => { expect(settings.menuBarIconEnabled).toBe(true); expect(settings.stopMemoryServiceOnExit).toBe(false); expect(agentSources).toEqual([]); - expect(firstMigrationCount).toBe(34); - expect(secondMigrationCount).toBe(34); + expect(firstMigrationCount).toBe(35); + expect(secondMigrationCount).toBe(35); }); it("preserves the authenticated account when upgrading the legacy 0007 database", () => { diff --git a/App/backend/src/services/agent-source-auto-inject-service.ts b/App/backend/src/services/agent-source-auto-inject-service.ts index c97eed893..a5ddd7dfe 100644 --- a/App/backend/src/services/agent-source-auto-inject-service.ts +++ b/App/backend/src/services/agent-source-auto-inject-service.ts @@ -23,9 +23,11 @@ export interface AgentSourceAutoInjectService { } export interface CreateAgentSourceAutoInjectServiceOptions { - agentSources: Pick; + agentSources: Pick + & Partial>; permissionManager: Pick; getScanPreferences: () => ScanPreferences; + isAutoInjectSuppressed?: (sourceId: string) => boolean; } /** Creates create agent source auto inject service. */ @@ -54,6 +56,7 @@ export function createAgentSourceAutoInjectService( for (const source of sources) { if (!isUpdatableInstalledHook(source)) continue; + if (options.isAutoInjectSuppressed?.(source.sourceId)) continue; if (!(await options.permissionManager.canWriteAgentSkill({ agentSourceId: source.sourceId }))) { continue; } @@ -61,6 +64,11 @@ export function createAgentSourceAutoInjectService( try { if (await options.agentSources.isInstalledHookCurrent(source.sourceId)) continue; await options.agentSources.installPlugin(source.sourceId, { installType: "auto_inject" }); + if (options.isAutoInjectSuppressed?.(source.sourceId)) { + if (!options.agentSources.uninstallPlugin) throw new Error("Cannot clean up an auto-updated source removed during installation"); + await options.agentSources.uninstallPlugin(source.sourceId, { installType: "auto_inject" }); + continue; + } installed.push(source.sourceId); } catch (error) { failed.push({ @@ -85,6 +93,7 @@ export function createAgentSourceAutoInjectService( if (!AUTO_INJECT_AGENT_SOURCE_IDS.has(source.sourceId) || !source.builtin || !source.available || source.status !== "not_connected") { continue; } + if (options.isAutoInjectSuppressed?.(source.sourceId)) continue; if (!(await options.permissionManager.canWriteAgentSkill({ agentSourceId: source.sourceId }))) { continue; @@ -94,7 +103,17 @@ export function createAgentSourceAutoInjectService( if (HOOK_OR_PLUGIN_AGENT_SOURCE_IDS.has(source.sourceId)) { await options.agentSources.installPlugin(source.sourceId, { installType: "auto_inject" }); } else { - await options.agentSources.installSkill(source.sourceId); + await options.agentSources.installSkill(source.sourceId, { installType: "auto_inject" }); + } + if (options.isAutoInjectSuppressed?.(source.sourceId)) { + if (HOOK_OR_PLUGIN_AGENT_SOURCE_IDS.has(source.sourceId)) { + if (!options.agentSources.uninstallPlugin) throw new Error("Cannot clean up an auto-injected source removed during installation"); + await options.agentSources.uninstallPlugin(source.sourceId, { installType: "auto_inject" }); + } else { + if (!options.agentSources.uninstallSkill) throw new Error("Cannot clean up an auto-injected source removed during installation"); + await options.agentSources.uninstallSkill(source.sourceId); + } + continue; } installed.push(source.sourceId); } catch (error) { diff --git a/App/backend/src/services/agent-source-service.ts b/App/backend/src/services/agent-source-service.ts index cbecc0dfe..2c7355553 100644 --- a/App/backend/src/services/agent-source-service.ts +++ b/App/backend/src/services/agent-source-service.ts @@ -99,7 +99,7 @@ export interface AgentSourceService { syncManaged(sourceId: string): Promise; updateManaged(sourceId: string, input: ManagedAgentSourceUpdateInput): Promise; remove(sourceId: string): Promise; - installSkill(sourceId: string): Promise; + installSkill(sourceId: string, action?: Pick): Promise; uninstallSkill(sourceId: string): Promise; installPlugin(sourceId: string, action?: AgentSourcePluginActionInput): Promise; uninstallPlugin(sourceId: string, action?: AgentSourcePluginActionInput): Promise; @@ -352,8 +352,9 @@ export function createAgentSourceService(options: CreateAgentSourceServiceOption options.agentSourceRepository.removeSource(sourceId); }, - async installSkill(sourceId) { + async installSkill(sourceId, action = {}) { const startedAt = Date.now(); + const installType = normalizePluginInstallType(action.installType); const statusBefore = readSourceStatus(options, sourceId); const permission = await readScanPermission(options); const builtin = readSourceBuiltin(options, sourceId); @@ -361,7 +362,11 @@ export function createAgentSourceService(options: CreateAgentSourceServiceOption await ensureSourceAvailable(options, sourceId); await options.skillDistributionService.install(sourceId); ensureSourceExists(options, sourceId); - options.agentSourceRepository.setStatus(sourceId, "skill_installed"); + if (installType === "auto_inject") { + options.agentSourceRepository.setStatus(sourceId, "skill_installed"); + } else { + options.agentSourceRepository.setConnectionState(sourceId, "skill_installed", false); + } agentSourceAnalytics.trackSkillInstalled({ sourceId, builtin, @@ -394,7 +399,7 @@ export function createAgentSourceService(options: CreateAgentSourceServiceOption try { await options.skillDistributionService.uninstall(sourceId); ensureSourceExists(options, sourceId); - options.agentSourceRepository.setStatus(sourceId, "not_connected"); + options.agentSourceRepository.setConnectionState(sourceId, "not_connected", true); agentSourceAnalytics.trackSkillUninstalled({ sourceId, builtin, @@ -428,7 +433,11 @@ export function createAgentSourceService(options: CreateAgentSourceServiceOption await ensureSourceAvailable(options, sourceId); await options.skillDistributionService.installPlugin(sourceId); ensureSourceExists(options, sourceId); - options.agentSourceRepository.setStatus(sourceId, "plugin_installed"); + if (installType === "auto_inject") { + options.agentSourceRepository.setStatus(sourceId, "plugin_installed"); + } else { + options.agentSourceRepository.setConnectionState(sourceId, "plugin_installed", false); + } agentSourceAnalytics.trackPluginInstalled({ sourceId, permission, @@ -465,7 +474,7 @@ export function createAgentSourceService(options: CreateAgentSourceServiceOption try { await options.skillDistributionService.uninstallPlugin(sourceId); ensureSourceExists(options, sourceId); - options.agentSourceRepository.setStatus(sourceId, "not_connected"); + options.agentSourceRepository.setConnectionState(sourceId, "not_connected", true); agentSourceAnalytics.trackPluginUninstalled({ sourceId, permission, diff --git a/App/backend/src/services/index.ts b/App/backend/src/services/index.ts index 606e1ce35..65f47e091 100644 --- a/App/backend/src/services/index.ts +++ b/App/backend/src/services/index.ts @@ -216,6 +216,7 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba agentSourceAutoInject: createAgentSourceAutoInjectService({ agentSources, permissionManager: options.permissionManager, + isAutoInjectSuppressed: (sourceId) => options.appStateStore.repositories.agentSources.isAutoInjectSuppressed(sourceId), getScanPreferences: () => options.scanPreferencesStore?.getScanPreferences() ?? options.appStateStore.repositories.bootstrap.getScanPreferences() }), diff --git a/App/backend/src/services/tests/agent-source-auto-inject-service.test.ts b/App/backend/src/services/tests/agent-source-auto-inject-service.test.ts index a79b18a5b..6d9a80016 100644 --- a/App/backend/src/services/tests/agent-source-auto-inject-service.test.ts +++ b/App/backend/src/services/tests/agent-source-auto-inject-service.test.ts @@ -53,6 +53,60 @@ describe("agent source auto inject service", () => { ]); }); + it("does not reinstall manually uninstalled sources", async () => { + const calls: string[] = []; + const service = createAgentSourceAutoInjectService({ + agentSources: { + ...createAgentSources(calls), + async list() { + return [source("cursor", "not_connected", true)]; + } + }, + permissionManager: { async canWriteAgentSkill() { return true; } }, + getScanPreferences: () => enabledPreferences, + isAutoInjectSuppressed: (sourceId) => sourceId === "cursor" + }); + + await expect(service.runOnce()).resolves.toMatchObject({ + ok: true, + skipped: false, + installed: [], + failed: [] + }); + expect(calls).toEqual([]); + }); + + it("removes an auto-injected hook when manual uninstallation races with installation", async () => { + const calls: string[] = []; + let suppressed = false; + const service = createAgentSourceAutoInjectService({ + agentSources: { + ...createAgentSources(calls), + async list() { + return [source("cursor", "not_connected", true)]; + }, + async installPlugin(sourceId) { + calls.push(`plugin:${sourceId}:auto_inject`); + suppressed = true; + }, + async uninstallPlugin(sourceId, action) { + calls.push(`uninstall:${sourceId}:${action?.installType ?? "manual"}`); + } + }, + permissionManager: { async canWriteAgentSkill() { return true; } }, + getScanPreferences: () => enabledPreferences, + isAutoInjectSuppressed: () => suppressed + }); + + await expect(service.runOnce()).resolves.toMatchObject({ + ok: true, + skipped: false, + installed: [], + failed: [] + }); + expect(calls).toEqual(["plugin:cursor:auto_inject", "uninstall:cursor:auto_inject"]); + }); + it("skips unavailable builtin agents", async () => { const calls: string[] = []; const service = createAgentSourceAutoInjectService({ diff --git a/App/backend/src/services/tests/agent-source-service.test.ts b/App/backend/src/services/tests/agent-source-service.test.ts index 42a22ed6c..b068771e4 100644 --- a/App/backend/src/services/tests/agent-source-service.test.ts +++ b/App/backend/src/services/tests/agent-source-service.test.ts @@ -1312,10 +1312,12 @@ describe("agent source service", () => { }); await service.installSkill("cursor"); + expect(repository.isAutoInjectSuppressed("cursor")).toBe(false); await service.uninstallSkill("cursor"); expect(calls).toEqual(["install:cursor", "uninstall:cursor"]); expect(repository.listSources()[0]?.status).toBe("not_connected"); + expect(repository.isAutoInjectSuppressed("cursor")).toBe(true); }); it("delegates native plugin install then updates source status", async () => { @@ -1347,10 +1349,12 @@ describe("agent source service", () => { }); await service.installPlugin("openclaw"); + expect(repository.isAutoInjectSuppressed("openclaw")).toBe(false); await service.uninstallPlugin("openclaw"); expect(calls).toEqual(["plugin:openclaw", "unplugin:openclaw"]); expect(repository.listSources()[0]?.status).toBe("not_connected"); + expect(repository.isAutoInjectSuppressed("openclaw")).toBe(true); }); it("delegates memory plugin conflict detection to the skill distribution service", async () => { @@ -1785,6 +1789,7 @@ function createRepository(): AgentSourceRepository { data_path TEXT NOT NULL, builtin INTEGER NOT NULL CHECK(builtin IN (0,1)), status TEXT NOT NULL DEFAULT 'not_connected', + auto_inject_suppressed INTEGER NOT NULL DEFAULT 0 CHECK(auto_inject_suppressed IN (0,1)), last_scanned_at TEXT, sync_recipe_json TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), diff --git a/Memory/src/agent-source/integration/codex/target.ts b/Memory/src/agent-source/integration/codex/target.ts index 54e75627c..46501dec6 100644 --- a/Memory/src/agent-source/integration/codex/target.ts +++ b/Memory/src/agent-source/integration/codex/target.ts @@ -1,5 +1,5 @@ /** Target module. */ -import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { lstat, mkdir, readFile, readlink, rename, rm, stat, symlink, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, dirname, join } from "node:path"; import { resolveCodexHomeDirectory } from "../../agent-paths.js"; @@ -99,21 +99,27 @@ export function createCodexSkillTarget(deps: CreateCodexSkillTargetDeps = {}): S const hookDirectory = join(root, HOOK_DIRECTORY_NAME); const hookScriptPath = join(hookDirectory, HOOK_SCRIPT_FILE_NAME); - await mkdir(hookDirectory, { recursive: true }); - await writeFileAtomically( - join(hookDirectory, HOOK_CONFIG_FILE_NAME), - `${JSON.stringify({ memmy_config_path: memmyConfigPath, ...(await readMemmyMemoryServiceConfig(memmyConfigPath)) }, null, 2)}\n` - ); - await writeFileAtomically(hookScriptPath, renderMemmyResumeHookScript({ source: CODEX_TARGET_ID, mode: "codex" })); - await writeFileAtomically( - join(hookDirectory, WORKSPACE_BRIDGE_FILE_NAME), - await loadMemmyWorkspaceBridgeRuntimeAsset() - ); + const hookConfigPath = join(hookDirectory, HOOK_CONFIG_FILE_NAME); + const bridgePath = join(hookDirectory, WORKSPACE_BRIDGE_FILE_NAME); const hooksFilePath = join(root, HOOKS_FILE_NAME); const hookCommand = createNodeHookCommand(hookScriptPath); - await upsertCodexHookConfig(hooksFilePath, hookCommand); - await rm(join(root, HOOK_DIRECTORY_NAME, LEGACY_HOOK_SCRIPT_FILE_NAME), { force: true }); + const hookConfig = { + memmy_config_path: memmyConfigPath, + ...(await readMemmyMemoryServiceConfig(memmyConfigPath)) + }; + await withCodexHookFileRollback( + [hookConfigPath, hookScriptPath, bridgePath, hooksFilePath], + async () => { + await mkdir(hookDirectory, { recursive: true }); + await writeFileAtomically(hookConfigPath, `${JSON.stringify(hookConfig, null, 2)}\n`); + await writeFileAtomically(hookScriptPath, renderMemmyResumeHookScript({ source: CODEX_TARGET_ID, mode: "codex" })); + await writeFileAtomically(bridgePath, await loadMemmyWorkspaceBridgeRuntimeAsset()); + await upsertCodexHookConfig(hooksFilePath, hookCommand); + await trustHooks({ codexHomeDirectory: root, hooksFilePath, hookCommand }); + } + ); + await rm(join(hookDirectory, LEGACY_HOOK_SCRIPT_FILE_NAME), { force: true }); const manifest = renderMemmyPluginSkillManifest(_targetId); const filePath = join(root, TARGET_FILE_NAME); await writeFileAtomically( @@ -122,11 +128,6 @@ export function createCodexSkillTarget(deps: CreateCodexSkillTargetDeps = {}): S ); await replaceMemmySkillDirectory(root, manifest); await replaceMemmyResumeSkillDirectory(root, CODEX_TARGET_ID); - await trustHooks({ - codexHomeDirectory: root, - hooksFilePath, - hookCommand - }); }, async uninstallPlugin(_targetId) { @@ -315,11 +316,73 @@ function createMarkerBlockPattern(startMarker: string, endMarker = END_MARKER): return new RegExp(`${escapeRegExp(startMarker)}\\n[\\s\\S]*?${escapeRegExp(endMarker)}\\n?`, "m"); } -async function writeFileAtomically(filePath: string, content: string): Promise { +async function writeFileAtomically(filePath: string, content: string | Buffer, mode?: number): Promise { await mkdir(dirname(filePath), { recursive: true }); const tempPath = join(dirname(filePath), `.${basename(filePath)}.${process.pid}.${Date.now()}.tmp`); - await writeFile(tempPath, content, "utf8"); - await rename(tempPath, filePath); + try { + await writeFile(tempPath, content, { encoding: "utf8", ...(mode === undefined ? {} : { mode }) }); + await rename(tempPath, filePath); + } finally { + await rm(tempPath, { force: true }); + } +} + +type CodexHookFileSnapshot = + | { path: string; kind: "missing" } + | { path: string; kind: "file"; content: Buffer; mode: number } + | { path: string; kind: "symlink"; target: string }; + +async function withCodexHookFileRollback(paths: string[], operation: () => Promise): Promise { + const snapshots = await Promise.all(paths.map(snapshotCodexHookFile)); + try { + return await operation(); + } catch (error) { + const rollbackResults = await Promise.allSettled(snapshots.map(restoreCodexHookFile)); + const rollbackErrors = rollbackResults.flatMap((result) => result.status === "rejected" ? [result.reason] : []); + if (rollbackErrors.length > 0) { + throw new AggregateError( + [error, ...rollbackErrors], + "Codex hook installation failed and its files could not be fully rolled back" + ); + } + + throw error; + } +} + +async function snapshotCodexHookFile(filePath: string): Promise { + try { + const metadata = await lstat(filePath); + if (metadata.isSymbolicLink()) { + return { path: filePath, kind: "symlink", target: await readlink(filePath) }; + } + if (!metadata.isFile()) { + throw new Error(`Codex hook path is not a regular file: ${filePath}`); + } + + return { path: filePath, kind: "file", content: await readFile(filePath), mode: metadata.mode & 0o777 }; + } catch (error) { + if (isNodeError(error) && error.code === "ENOENT") { + return { path: filePath, kind: "missing" }; + } + + throw error; + } +} + +async function restoreCodexHookFile(snapshot: CodexHookFileSnapshot): Promise { + if (snapshot.kind === "missing") { + await rm(snapshot.path, { force: true }); + return; + } + + if (snapshot.kind === "symlink") { + await rm(snapshot.path, { force: true }); + await symlink(snapshot.target, snapshot.path); + return; + } + + await writeFileAtomically(snapshot.path, snapshot.content, snapshot.mode); } function escapeRegExp(input: string): string { diff --git a/Memory/src/agent-source/runtime.ts b/Memory/src/agent-source/runtime.ts index 755f27014..863404f00 100644 --- a/Memory/src/agent-source/runtime.ts +++ b/Memory/src/agent-source/runtime.ts @@ -105,13 +105,19 @@ export interface AgentSourceExecutor { pauseScan(): Promise<{ ok: true }>; cancelScan(): Promise<{ ok: true }>; scanResults?(jobId: string, cursor?: string, limit?: number): Promise<{ items: Array<{ sourceId: string; conversationId: string; memoryId?: string; error?: string }>; nextCursor: string | null }>; - mutateConnection(sourceId: string, kind: "plugin" | "skill", method: "POST" | "DELETE"): Promise; + mutateConnection( + sourceId: string, + kind: "plugin" | "skill", + method: "POST" | "DELETE", + installType?: "manual" | "auto_inject" + ): Promise; startAutomation(): void; dispose(): void | Promise; } interface PersistedSourceState { status: AgentConnectionStatus; + autoInjectSuppressed?: boolean; messageCount: number; lastScannedAt: string | null; latestSeenAt: string | null; @@ -489,7 +495,8 @@ export function createAgentSourceExecutor(options: CreateAgentSourceExecutorOpti async function mutateConnection( sourceId: string, kind: "plugin" | "skill", - method: "POST" | "DELETE" + method: "POST" | "DELETE", + installType: "manual" | "auto_inject" = "manual" ): Promise { const adapter = registry.require(sourceId); if (!(await adapter.detect())) { @@ -515,10 +522,16 @@ export function createAgentSourceExecutor(options: CreateAgentSourceExecutorOpti } const state = await readState(); const stored = state.sources[sourceId] ?? emptySourceState(); - state.sources[sourceId] = { + const nextState: PersistedSourceState = { ...stored, status: method === "POST" ? connectionStatus(sourceId) : "not_connected" }; + if (method === "DELETE") { + nextState.autoInjectSuppressed = true; + } else if (installType === "manual") { + delete nextState.autoInjectSuppressed; + } + state.sources[sourceId] = nextState; await persist(state); logger.info(method === "POST" ? "connection.installed" : "connection.removed", { sourceId, kind }); return { ok: true, sourceId, status: state.sources[sourceId].status }; @@ -542,12 +555,18 @@ export function createAgentSourceExecutor(options: CreateAgentSourceExecutorOpti if (disposed || scan.running) return; const config = loadMemmyConfig(configPath).config.agentAccess; if (config.autoInjectSkill) { + const state = await readState(); const discovered = await list(); for (const source of discovered.sources) { if (disposed) return; - if (!source.available || source.status !== "not_connected") continue; + if (!source.available || source.status !== "not_connected" || state.sources[source.sourceId]?.autoInjectSuppressed === true) continue; try { - await mutateConnection(source.sourceId, agentConnectionKind(source.sourceId), "POST"); + const kind = agentConnectionKind(source.sourceId); + await mutateConnection(source.sourceId, kind, "POST", "auto_inject"); + const currentState = await readState(); + if (currentState.sources[source.sourceId]?.autoInjectSuppressed === true) { + await mutateConnection(source.sourceId, kind, "DELETE", "auto_inject"); + } } catch (error) { logger.warn("connection.auto_install_failed", { sourceId: source.sourceId, ...memoryErrorFields(error) }); } @@ -1286,6 +1305,7 @@ async function loadState(path: string): Promise { const status = source.status === "skill_installed" || source.status === "plugin_installed" ? source.status : "not_connected"; return [sourceId, { status, + ...(source.autoInjectSuppressed === true ? { autoInjectSuppressed: true } : {}), messageCount: typeof source.messageCount === "number" && Number.isFinite(source.messageCount) ? Math.max(0, Math.floor(source.messageCount)) : 0, lastScannedAt: typeof source.lastScannedAt === "string" ? source.lastScannedAt : null, latestSeenAt: typeof source.latestSeenAt === "string" ? source.latestSeenAt : null, diff --git a/Memory/tests/agent-source-runtime.test.ts b/Memory/tests/agent-source-runtime.test.ts index 4474711f7..1c87afb6d 100644 --- a/Memory/tests/agent-source-runtime.test.ts +++ b/Memory/tests/agent-source-runtime.test.ts @@ -10,6 +10,7 @@ import type { SourceAdapter } from "../src/agent-source/adapters/types.js"; import { createSourceRegistry } from "../src/agent-source/adapters/source-registry.js"; import { createCursorSkillTarget } from "../src/agent-source/integration/cursor/index.js"; import { createSkillTargetRegistry } from "../src/agent-source/integration/target-registry.js"; +import type { SkillTarget } from "../src/agent-source/integration/types.js"; import type { MemoryService } from "../src/service/memory-service.js"; const roots: string[] = []; @@ -671,6 +672,7 @@ describe("standalone Agent source executor", () => { const cursorRoot = join(root, ".cursor"); mkdirSync(cursorRoot, { recursive: true }); const configPath = join(root, "config.yaml"); + const statePath = join(root, "agent-sources.json"); writeFileSync(configPath, "memmyMemory:\n storage:\n endpoint: http://127.0.0.1:18960\n"); const adapter: SourceAdapter = { descriptor: { sourceId: "cursor", displayName: "Cursor", builtin: true, dataPath: cursorRoot }, @@ -684,7 +686,7 @@ describe("standalone Agent source executor", () => { const executor = createAgentSourceExecutor({ service, configPath, - statePath: join(root, "agent-sources.json"), + statePath, sourceRegistry: createSourceRegistry([adapter]), integrationRegistry: createSkillTargetRegistry([ createCursorSkillTarget({ rootDirectory: cursorRoot, memmyConfigPath: configPath }) @@ -699,6 +701,64 @@ describe("standalone Agent source executor", () => { await executor.mutateConnection("cursor", "plugin", "DELETE"); expect(readFileSync(join(cursorRoot, "hooks.json"), "utf8")).not.toContain("memmy-resume-hook.mjs"); expect((await executor.list()).sources[0]?.status).toBe("not_connected"); + expect(JSON.parse(readFileSync(statePath, "utf8")).sources.cursor.autoInjectSuppressed).toBe(true); + + await executor.mutateConnection("cursor", "plugin", "POST", "auto_inject"); + expect(JSON.parse(readFileSync(statePath, "utf8")).sources.cursor.autoInjectSuppressed).toBe(true); + await executor.mutateConnection("cursor", "plugin", "DELETE"); + await executor.mutateConnection("cursor", "plugin", "POST", "manual"); + expect(JSON.parse(readFileSync(statePath, "utf8")).sources.cursor.autoInjectSuppressed).toBeUndefined(); + }); + + it("removes a hook when manual uninstall races with standalone auto injection", async () => { + const root = tempRoot(); + const codexRoot = join(root, ".codex"); + mkdirSync(codexRoot, { recursive: true }); + const configPath = join(root, "config.yaml"); + const statePath = join(root, "agent-sources.json"); + writeFileSync(configPath, JSON.stringify({ memmyMemory: { + agentAccess: { autoScanKnownAgents: true, watchFileChanges: false, autoInjectSkill: true }, + } })); + let executor!: ReturnType; + let autoInstallFinished = false; + let hookPresent = false; + const target: SkillTarget = { + targetId: "codex", + displayName: "Codex", + async resolveRootDirectory() { return codexRoot; }, + async install() {}, + async uninstall() {}, + async isInstalled() { return hookPresent; }, + async installPlugin() { + await executor.mutateConnection("codex", "plugin", "DELETE"); + hookPresent = true; + autoInstallFinished = true; + }, + async uninstallPlugin() { hookPresent = false; }, + }; + const adapter: SourceAdapter = { + descriptor: { sourceId: "codex", displayName: "Codex", builtin: true, dataPath: codexRoot }, + detect: async () => true, + async *scan() {}, + }; + executor = createAgentSourceExecutor({ + service: {} as MemoryService, + configPath, + statePath, + initialScanDelayMs: 0, + sourceRegistry: createSourceRegistry([adapter]), + integrationRegistry: createSkillTargetRegistry([target]), + }); + + try { + executor.startAutomation(); + await vi.waitFor(() => expect(autoInstallFinished).toBe(true)); + await vi.waitFor(() => expect(hookPresent).toBe(false)); + expect((await executor.list()).sources[0]?.status).toBe("not_connected"); + expect(JSON.parse(readFileSync(statePath, "utf8")).sources.codex.autoInjectSuppressed).toBe(true); + } finally { + await executor.dispose(); + } }); }); From 516ba152d91bcfb6e3d8d4cc219b54680a99620b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Mon, 12 Oct 2026 13:57:58 +0800 Subject: [PATCH 26/85] fix: allow models to share identical endpoints --- .../memmy-config/model-config-catalog.ts | 25 ++++-- .../tests/model-config-catalog.test.ts | 83 +++++++++++++++++-- App/frontend/desktop/src/api/config-client.ts | 65 +++++++++++++-- .../src/api/tests/config-client.test.ts | 59 +++++++++++++ 4 files changed, 211 insertions(+), 21 deletions(-) diff --git a/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts b/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts index c1d25dc37..e7e360e77 100644 --- a/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts +++ b/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts @@ -134,10 +134,10 @@ function mergeModelConfig(config: ConfigRecord, input: ModelConfigInput): Config if (providerInput.provider === ACCOUNT_PROVIDER) continue; const previousProvider = record(existingProviders[providerInput.provider]); const provider = mergeProvider(previousProvider, providerInput); + const modelInputs = canonicalizeEndpointDefinitions(providerInput, provider); nextProviders[providerInput.provider] = provider; - validateEndpointDefinitions(providerInput, provider); - for (const modelInput of providerInput.models) { + for (const modelInput of modelInputs) { if (modelInput.source !== "byok" || modelInput.ownerAccountId) { throw new InvalidModelConfigError("Desktop model settings may only create ownerless BYOK presets"); } @@ -446,10 +446,12 @@ function mergeEndpoint(previous: ConfigRecord, input: CatalogEndpointInput): Con return next; } -function validateEndpointDefinitions(input: TextModelProviderInput, provider: ConfigRecord): void { - const signatures = new Set(); +function canonicalizeEndpointDefinitions(input: TextModelProviderInput, provider: ConfigRecord): TextModelItemInput[] { + const canonicalIds = new Map(); + const redirects = new Map(); + const endpoints = record(provider.endpoints); for (const endpoint of input.endpoints) { - const merged = record(record(provider.endpoints)[endpoint.endpointId]); + const merged = record(endpoints[endpoint.endpointId]); const signature = stableJson({ provider: input.provider, protocol: merged.protocol, @@ -458,11 +460,18 @@ function validateEndpointDefinitions(input: TextModelProviderInput, provider: Co extraHeaders: merged.extraHeaders ?? provider.extraHeaders ?? null, extraBody: merged.extraBody ?? provider.extraBody ?? null }); - if (signatures.has(signature)) { - throw new InvalidModelConfigError(`Duplicate endpoint definition for Provider ${input.provider}`); + const canonicalId = canonicalIds.get(signature); + if (canonicalId) { + redirects.set(endpoint.endpointId, canonicalId); + delete endpoints[endpoint.endpointId]; + } else { + canonicalIds.set(signature, endpoint.endpointId); } - signatures.add(signature); } + return input.models.map((model) => { + const endpointId = redirects.get(model.endpointId); + return endpointId ? { ...model, endpointId } : model; + }); } function validatePresetEndpoint(providerId: CatalogProviderId, model: TextModelItemInput, endpoint: ConfigRecord): void { diff --git a/App/backend/src/infrastructure/memmy-config/tests/model-config-catalog.test.ts b/App/backend/src/infrastructure/memmy-config/tests/model-config-catalog.test.ts index 4515c5ab6..a52b1d8d6 100644 --- a/App/backend/src/infrastructure/memmy-config/tests/model-config-catalog.test.ts +++ b/App/backend/src/infrastructure/memmy-config/tests/model-config-catalog.test.ts @@ -501,17 +501,90 @@ describe("model config catalog", () => { expect((YAML.parse(readFileSync(file, "utf8")) as any).memmyMemory.roleRouting.summary).toBe("follow"); }); - it("rejects duplicate endpoint definitions, invalid protocol capabilities, and duplicate models", async () => { + it("coalesces identical endpoint definitions and redirects their model references", async () => { const file = fixture(); - const revision = (await readModelConfigCatalog(file)).configRevision; - const duplicateEndpoint = openAiInput(revision); - duplicateEndpoint.providers[0]!.endpoints.push({ + const input = openAiInput((await readModelConfigCatalog(file)).configRevision); + input.providers[0]!.endpoints.push({ endpointId: "chat-copy", apiBase: "https://api.example.test/v1", protocol: "openai-chat-completions" }); - await expect(writeModelConfigCatalog(file, duplicateEndpoint)).rejects.toThrow(/Duplicate endpoint definition/); + input.providers[0]!.models.push({ + endpointId: "chat-copy", + model: "gpt-5-alias", + source: "byok", + capabilities: ["agent"] + }); + + const saved = await writeModelConfigCatalog(file, input); + const raw = YAML.parse(readFileSync(file, "utf8")) as any; + expect(Object.keys(raw.providers.openai.endpoints)).toEqual(["chat"]); + expect(Object.values(raw.modelPresets).map((preset: any) => preset.endpoint)).toEqual(["chat", "chat"]); + expect(saved.providers[0]?.endpoints).toHaveLength(1); + }); + + it("coalesces duplicate embedding endpoints created while adding models", async () => { + const file = fixture(); + const input = openAiInput((await readModelConfigCatalog(file)).configRevision); + input.providers[0]!.endpoints = [ + { + endpointId: "embedding", + apiBase: "https://api.example.test/v1/", + protocol: "openai-embeddings" + }, + { + endpointId: "embedding-copy", + apiBase: "https://api.example.test/v1", + protocol: "openai-embeddings" + } + ]; + input.providers[0]!.models = [ + { + endpointId: "embedding", + model: "embedding-a", + source: "byok", + capabilities: ["embedding"] + }, + { + endpointId: "embedding-copy", + model: "embedding-b", + source: "byok", + capabilities: ["embedding"] + } + ]; + + const saved = await writeModelConfigCatalog(file, input); + const raw = YAML.parse(readFileSync(file, "utf8")) as any; + expect(Object.keys(raw.providers.openai.endpoints)).toEqual(["embedding"]); + expect(Object.values(raw.modelPresets).map((preset: any) => preset.endpoint)).toEqual(["embedding", "embedding"]); + expect(saved.providers[0]?.models.map((model) => model.model)).toEqual(["embedding-a", "embedding-b"]); + }); + + it("keeps endpoints with different credentials even when their route is the same", async () => { + const file = fixture(); + const input = openAiInput((await readModelConfigCatalog(file)).configRevision); + input.providers[0]!.endpoints.push({ + endpointId: "chat-other-key", + apiBase: "https://api.example.test/v1", + protocol: "openai-chat-completions", + apiKey: "sk-another-secret" + }); + input.providers[0]!.models.push({ + endpointId: "chat-other-key", + model: "gpt-5-alias", + source: "byok", + capabilities: ["agent"] + }); + await writeModelConfigCatalog(file, input); + const raw = YAML.parse(readFileSync(file, "utf8")) as any; + expect(Object.keys(raw.providers.openai.endpoints)).toEqual(["chat", "chat-other-key"]); + expect(raw.providers.openai.endpoints["chat-other-key"].apiKey).toBe("sk-another-secret"); + }); + + it("rejects invalid protocol capabilities and duplicate models", async () => { + const file = fixture(); + const revision = (await readModelConfigCatalog(file)).configRevision; const wrongProtocol = openAiInput(revision); wrongProtocol.providers[0]!.models[0]!.capabilities = ["embedding"]; await expect(writeModelConfigCatalog(file, wrongProtocol)).rejects.toThrow(/does not support capability embedding/); diff --git a/App/frontend/desktop/src/api/config-client.ts b/App/frontend/desktop/src/api/config-client.ts index 00c147c71..2446bf2aa 100644 --- a/App/frontend/desktop/src/api/config-client.ts +++ b/App/frontend/desktop/src/api/config-client.ts @@ -604,24 +604,33 @@ interface PendingPreset { clientId: string; provider: string; endpointId: string; + endpointApiBase: string | null; + endpointProtocol: ModelEndpointProtocol | null; + endpointApiKey: string; + providerApiKey: string; model: string; source: "account" | "byok"; capabilities: ModelCapability[]; } function pendingPresets(input: ModelConfigInput): PendingPreset[] { - return input.providers.flatMap((provider) => provider.models.flatMap((model) => ( - model.presetId?.startsWith(CLIENT_PRESET_ID_PREFIX) + return input.providers.flatMap((provider) => provider.models.flatMap((model) => { + const endpoint = provider.endpoints.find((item) => item.endpointId === model.endpointId); + return model.presetId?.startsWith(CLIENT_PRESET_ID_PREFIX) ? [{ clientId: model.presetId, provider: provider.provider, endpointId: model.endpointId, + endpointApiBase: endpoint?.apiBase ?? null, + endpointProtocol: endpoint?.protocol ?? null, + endpointApiKey: endpoint?.apiKey ?? "", + providerApiKey: provider.apiKey ?? "", model: model.model, source: model.source, capabilities: [...model.capabilities] }] - : [] - ))); + : []; + })); } function withoutPendingPresetAssignments(input: ModelConfigInput, pendingIds: ReadonlySet): ModelConfigInput { @@ -646,13 +655,22 @@ function withoutPendingPresetAssignments(input: ModelConfigInput, pendingIds: Re function resolvePendingPresetIds(pending: PendingPreset[], view: ModelConfigView): Map { const mapping = new Map(); for (const item of pending) { - const matches = view.providers.flatMap((provider) => provider.models).filter((model) => ( + const provider = view.providers.find((candidate) => candidate.provider === item.provider); + const matchesModel = (model: ModelConfigView["providers"][number]["models"][number]) => ( model.provider === item.provider - && model.endpointId === item.endpointId && model.model === item.model && model.source === item.source && sameStringSet(model.capabilities, item.capabilities) - )); + ); + const exactMatches = provider?.models.filter((model) => ( + matchesModel(model) && model.endpointId === item.endpointId + )) ?? []; + const canonicalizedMatches = exactMatches.length === 0 && provider + ? provider.models.filter((model) => ( + matchesModel(model) && pendingEndpointMatches(item, provider, model.endpointId) + )) + : []; + const matches = exactMatches.length > 0 ? exactMatches : canonicalizedMatches; if (matches.length !== 1) { throw new Error(`Unable to resolve server preset for ${item.provider}/${item.endpointId}/${item.model}`); } @@ -661,6 +679,35 @@ function resolvePendingPresetIds(pending: PendingPreset[], view: ModelConfigView return mapping; } +function pendingEndpointMatches( + item: PendingPreset, + provider: ModelConfigView["providers"][number], + endpointId: string +): boolean { + if (!item.endpointApiBase || !item.endpointProtocol) return false; + const endpoint = provider.endpoints.find((candidate) => candidate.endpointId === endpointId); + if ( + !endpoint + || normalizeApiBase(endpoint.apiBase) !== normalizeApiBase(item.endpointApiBase) + || endpoint.protocol !== item.endpointProtocol + ) { + return false; + } + const expectedApiKey = maskSecret(item.endpointApiKey || item.providerApiKey); + const actualApiKey = endpoint.hasApiKey ? endpoint.apiKeyMasked : provider.apiKeyMasked; + return !expectedApiKey || expectedApiKey === actualApiKey; +} + +function normalizeApiBase(value: string): string { + return value.trim().replace(/\/+$/u, ""); +} + +function maskSecret(value: string): string { + if (!value) return ""; + if (value.length <= 8) return "••••••••"; + return `${value.slice(0, 3)}••••${value.slice(-4)}`; +} + function assignmentPhaseInput( base: ModelConfigView, requested: ModelConfigInput, @@ -704,10 +751,12 @@ function assertResolvedPresetsStillExist( const byId = new Map(view.providers.flatMap((provider) => provider.models).map((model) => [model.presetId, model])); for (const item of pending) { const preset = byId.get(mapping.get(item.clientId) ?? ""); + const provider = view.providers.find((candidate) => candidate.provider === item.provider); if ( !preset || preset.provider !== item.provider - || preset.endpointId !== item.endpointId + || !provider + || (preset.endpointId !== item.endpointId && !pendingEndpointMatches(item, provider, preset.endpointId)) || preset.model !== item.model || preset.source !== item.source || !sameStringSet(preset.capabilities, item.capabilities) diff --git a/App/frontend/desktop/src/api/tests/config-client.test.ts b/App/frontend/desktop/src/api/tests/config-client.test.ts index eae3aa81a..4940a39ba 100644 --- a/App/frontend/desktop/src/api/tests/config-client.test.ts +++ b/App/frontend/desktop/src/api/tests/config-client.test.ts @@ -209,6 +209,65 @@ describe("config-client canonical model catalog", () => { expect(saved.catalog?.modelAssignments.byok.agent.default).toBe(serverPresetId); }); + it("后端合并相同 endpoint 定义后仍能解析新模型并完成 assignment", async () => { + const bodies: any[] = []; + const serverPresetId = "5c8872cf-28c8-4cf3-bf51-9a3a2a0fb42a"; + vi.stubGlobal("fetch", vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + bodies.push(body); + const response = catalog(bodies.length === 1 ? "revision-2" : "revision-3"); + response.providers[0]!.models.push({ + presetId: serverPresetId, + provider: "openai", + endpointId: "chat", + protocol: "openai-chat-completions", + model: "gpt-new", + source: "byok", + capabilities: ["agent"], + available: true + }); + if (bodies.length > 1) { + response.modelAssignments.byok.agent = { + candidates: ["byok-agent", serverPresetId], + default: serverPresetId + }; + } + return jsonResponse(response); + })); + const input = inputFromCatalog(catalog("revision-1")); + const clientPresetId = `${CLIENT_PRESET_ID_PREFIX}canonicalized-endpoint`; + input.providers[0]!.endpoints.push({ + endpointId: "chat-duplicate", + apiBase: "https://api.openai.com/v1/", + protocol: "openai-chat-completions" + }); + input.providers[0]!.models.push({ + presetId: clientPresetId, + endpointId: "chat-duplicate", + model: "gpt-new", + source: "byok", + capabilities: ["agent"] + }); + input.modelAssignments.byok.agent = { + candidates: ["byok-agent", clientPresetId], + default: clientPresetId + }; + + const saved = await createHttpConfigClient(config).saveModelCatalog(input); + + expect(bodies).toHaveLength(2); + expect(bodies[0].providers[0].endpoints.some((endpoint: any) => endpoint.endpointId === "chat-duplicate")).toBe(true); + expect(bodies[1].providers[0].models.find((model: any) => model.model === "gpt-new")).toMatchObject({ + presetId: serverPresetId, + endpointId: "chat" + }); + expect(bodies[1].modelAssignments.byok.agent).toEqual({ + candidates: ["byok-agent", serverPresetId], + default: serverPresetId + }); + expect(saved.catalog?.modelAssignments.byok.agent.default).toBe(serverPresetId); + }); + it("首次成功 GET 后只清理旧 workspace cache", async () => { const removeItem = vi.fn(); vi.stubGlobal("window", { localStorage: { removeItem } }); From b5ac0666e1153f01d5827107be91287be8246023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Tue, 13 Oct 2026 12:12:28 +0800 Subject: [PATCH 27/85] fix: reuse saved BYOK endpoint in setup connection tests --- .../src/pages/api-key-optional-page.tsx | 20 +++- .../desktop/src/pages/api-key-page.tsx | 8 +- App/frontend/desktop/src/pages/model-page.tsx | 17 +++- ...k-setup-save-feedback.interaction.test.tsx | 99 +++++++++++++++++++ 4 files changed, 135 insertions(+), 9 deletions(-) diff --git a/App/frontend/desktop/src/pages/api-key-optional-page.tsx b/App/frontend/desktop/src/pages/api-key-optional-page.tsx index c8dda7386..c0c56cb01 100644 --- a/App/frontend/desktop/src/pages/api-key-optional-page.tsx +++ b/App/frontend/desktop/src/pages/api-key-optional-page.tsx @@ -61,6 +61,9 @@ export function ApiKeyOptionalPage() { const { t } = useTranslation(); const { track } = useAnalytics(); const initialModelForm = hydrateModelConfigForm(state.modelConfig, "local"); + const initialWorkspace = createModelWorkspace(state.modelConfig); + const initialAsrEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "asr"); + const initialImageEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "image_generation"); const initialAsrModel = initialModelForm.asrModelId; const initialAsrEndpoint = initialModelForm.asrEndpoint || ASR_DEFAULT_ENDPOINT; const [asrModel, setAsrModel] = useState(initialAsrModel); @@ -70,7 +73,10 @@ export function ApiKeyOptionalPage() { const [asrValidation, setAsrValidation] = useState(initialModelForm.asrValidation); const [asrWarningAcknowledged, setAsrWarningAcknowledged] = useState(false); const [asrApiKeyMasked] = useState(initialModelForm.asrApiKeyMasked); - const asrFormValues = createAsrModelFormValues(asrModel, asrEndpoint, asrApiKey, asrApiKeyMasked); + const asrFormValues = { + ...createAsrModelFormValues(asrModel, asrEndpoint, asrApiKey, asrApiKeyMasked), + endpointId: !asrApiKey.trim() && asrApiKeyMasked ? initialAsrEndpointId : undefined + }; const isAsrUsable = canSaveModelConfig(asrFormValues, asrValidation); const asrTestKey = createModelConfigValidationKey(asrFormValues); const isAsrTestStale = Boolean(asrValidation.testedKey && asrValidation.testedKey !== asrTestKey); @@ -82,7 +88,14 @@ export function ApiKeyOptionalPage() { const [imageGenValidation, setImageGenValidation] = useState(initialModelForm.imageGenValidation); const [imageGenWarningAcknowledged, setImageGenWarningAcknowledged] = useState(false); const [imageGenApiKeyMasked] = useState(initialModelForm.imageGenApiKeyMasked); - const imageGenFormValues = createImageGenModelFormValues(imageGenProtocol, imageGenModel, imageGenEndpoint, imageGenApiKey, imageGenApiKeyMasked); + const imageGenFormValues = { + ...createImageGenModelFormValues(imageGenProtocol, imageGenModel, imageGenEndpoint, imageGenApiKey, imageGenApiKeyMasked), + endpointId: !imageGenApiKey.trim() + && imageGenApiKeyMasked + && imageGenProtocol === initialModelForm.imageGenProtocol + ? initialImageEndpointId + : undefined + }; const isImageGenUsable = canSaveModelConfig(imageGenFormValues, imageGenValidation); const imageGenTestKey = createModelConfigValidationKey(imageGenFormValues); const isImageGenTestStale = Boolean(imageGenValidation.testedKey && imageGenValidation.testedKey !== imageGenTestKey); @@ -108,9 +121,6 @@ export function ApiKeyOptionalPage() { imageGeneration: isImageGenUsable ? imageGenTestKey : null }); const savedCatalogSignatureRef = useRef(null); - const initialWorkspace = createModelWorkspace(state.modelConfig); - const initialAsrEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "asr"); - const initialImageEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "image_generation"); const savedEndpointIdentitiesRef = useRef>>({ ...(initialAsrEndpointId ? { asr: { endpointId: initialAsrEndpointId, credentialSignature: asrCredentialSignature } } diff --git a/App/frontend/desktop/src/pages/api-key-page.tsx b/App/frontend/desktop/src/pages/api-key-page.tsx index 40f6fb6ed..6e917ad2d 100644 --- a/App/frontend/desktop/src/pages/api-key-page.tsx +++ b/App/frontend/desktop/src/pages/api-key-page.tsx @@ -83,6 +83,9 @@ export function ApiKeyPage() { const { t } = useTranslation(); const { track } = useAnalytics(); const initialModelForm = hydrateModelConfigForm(state.modelConfig, "local"); + const initialWorkspace = createModelWorkspace(state.modelConfig); + const initialAgentEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "agent"); + const initialEmbeddingEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "embedding"); const initialProvider = fromProtocol(initialModelForm.protocol); const [provider, setProvider] = useState(initialProvider); const selectedProvider = providerOptions.find((option) => option.value === provider) ?? defaultProvider; @@ -93,6 +96,7 @@ export function ApiKeyPage() { const [showApiKey, setShowApiKey] = useState(false); const modelFormValues = { provider, + endpointId: !apiKey.trim() && apiKeyMasked ? initialAgentEndpointId : undefined, endpoint, model, apiKey, @@ -100,7 +104,6 @@ export function ApiKeyPage() { hasExistingApiKey: Boolean(apiKeyMasked) }; const [llmValidation, setLlmValidation] = useState(initialModelForm.llmValidation); - const initialWorkspace = createModelWorkspace(state.modelConfig); const initialEmbeddingMode: EmbeddingMode = initialWorkspace.catalog.modelAssignments.byok.embedding ? "custom" : "local"; @@ -114,6 +117,7 @@ export function ApiKeyPage() { const [showEmbeddingApiKey, setShowEmbeddingApiKey] = useState(false); const embeddingFormValues = { provider: "openai", + endpointId: !embeddingConfig.apiKey.trim() && embeddingConfig.apiKeyMasked ? initialEmbeddingEndpointId : undefined, endpoint: embeddingConfig.endpoint, model: embeddingConfig.model, apiKey: embeddingConfig.apiKey, @@ -145,8 +149,6 @@ export function ApiKeyPage() { ); const saveSignature = `${embeddingMode}\n${testedKey}\n${embeddingTestKey}`; const savedCatalogSignatureRef = useRef(null); - const initialAgentEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "agent"); - const initialEmbeddingEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "embedding"); const savedEndpointIdentitiesRef = useRef>>({ ...(initialAgentEndpointId ? { agent: { endpointId: initialAgentEndpointId, credentialSignature: agentCredentialSignature } } diff --git a/App/frontend/desktop/src/pages/model-page.tsx b/App/frontend/desktop/src/pages/model-page.tsx index aef50d6a9..b541878d7 100644 --- a/App/frontend/desktop/src/pages/model-page.tsx +++ b/App/frontend/desktop/src/pages/model-page.tsx @@ -84,6 +84,8 @@ export function ModelPage() { const { t } = useTranslation(); const { track } = useAnalytics(); const initialModelForm = hydrateModelConfigForm(state.modelConfig, "local"); + const initialWorkspace = createModelWorkspace(state.modelConfig); + const primaryEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "agent"); const [primaryModel] = useState(() => ({ protocol: initialModelForm.protocol, modelId: initialModelForm.modelId, @@ -99,6 +101,14 @@ export function ModelPage() { const skillValues = createModelFormValues(skill, primaryModel); const evolutionModel = modelFormValuesAsPrimary(skillValues); const memoryValues = createModelFormValues(mem, evolutionModel); + const skillEndpointId = skill.reuse + ? primaryEndpointId + : assignedCatalogEndpointId(initialWorkspace, "byok", "memory_evolution") + ?? (skillValues.apiKeyMasked ? primaryEndpointId : undefined); + const memoryEndpointId = mem.reuse + ? skillEndpointId + : assignedCatalogEndpointId(initialWorkspace, "byok", "memory_summary") + ?? (memoryValues.apiKeyMasked ? skillEndpointId : undefined); const canContinue = canUseModelConfig(mem, memoryValues) && canUseModelConfig(skill, skillValues); /** Handles patch mem. */ @@ -121,7 +131,12 @@ export function ModelPage() { const values = createModelFormValues(config, inheritedModel); testModelConnection({ configClient: clients?.config, - values, + values: { + ...values, + endpointId: values.apiKeyMasked + ? secretTarget === "skill" ? skillEndpointId : memoryEndpointId + : undefined + }, setValidation: (validation) => patch({ validation }), secretTarget, messages: createTestModelConnectionMessages(t) diff --git a/App/frontend/desktop/src/pages/tests/byok-setup-save-feedback.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/byok-setup-save-feedback.interaction.test.tsx index f6cd72a99..2011fb782 100644 --- a/App/frontend/desktop/src/pages/tests/byok-setup-save-feedback.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/tests/byok-setup-save-feedback.interaction.test.tsx @@ -77,6 +77,42 @@ describe("BYOK setup save feedback", () => { expect(container.querySelector('[role="alert"]')).toBeNull(); }); + it("retests masked first-step keys through their saved endpoints", async () => { + const catalog = configuredCatalog(); + const workspace = createModelWorkspace(catalog); + const agentEndpointId = assignedCatalogEndpointId(workspace, "byok", "agent"); + const embeddingEndpointId = assignedCatalogEndpointId(workspace, "byok", "embedding"); + const testModelConfig = vi.fn(async () => ({ + ok: true, + message: "ok", + checkedAt: "2026-10-13T00:00:00.000Z" + })); + mocks.state = { ...createInitialAppState(), modelConfig: maskedSavedModelConfig(catalog) }; + mocks.clients = createClients(vi.fn(async () => maskedSavedModelConfig(catalog)), { testModelConfig }); + await render(); + + const primaryInput = [...container.querySelectorAll("label")] + .find((candidate) => candidate.textContent === "apiKey.key") + ?.parentElement?.querySelector("input"); + expect(primaryInput?.placeholder).toBe(maskApiKey("sk-primary")); + + await click(testButton()); + await vi.waitFor(() => expect(testModelConfig).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ endpointId: agentEndpointId, apiKey: "" }), + "chat", + "primary" + )); + + await click(testButton(1)); + await vi.waitFor(() => expect(testModelConfig).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ endpointId: embeddingEndpointId, apiKey: "" }), + "embedding", + "embedding" + )); + }); + it("defaults a genuinely absent BYOK embedding assignment to the local model", async () => { const catalog = configuredCatalog(false); catalog.modelAssignments.byok.embedding = null; @@ -438,6 +474,69 @@ describe("BYOK setup save feedback", () => { .toBe(initialImageEndpointId); }); + it("retests masked optional keys through their saved endpoints", async () => { + const catalog = configuredCatalog(); + const workspace = createModelWorkspace(catalog); + const asrEndpointId = assignedCatalogEndpointId(workspace, "byok", "asr"); + const imageEndpointId = assignedCatalogEndpointId(workspace, "byok", "image_generation"); + const testModelConfig = vi.fn(async () => ({ + ok: true, + message: "ok", + checkedAt: "2026-10-13T00:00:00.000Z" + })); + mocks.state = { ...createInitialAppState(), modelConfig: maskedSavedModelConfig(catalog) }; + mocks.clients = createClients(vi.fn(async () => maskedSavedModelConfig(catalog)), { testModelConfig }); + await render(); + + await click(testButton()); + await vi.waitFor(() => expect(testModelConfig).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ endpointId: asrEndpointId, apiKey: "" }), + "asr", + "asr" + )); + + await click(testButton(1)); + await vi.waitFor(() => expect(testModelConfig).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ endpointId: imageEndpointId, apiKey: "" }), + "image", + "image" + )); + }); + + it("retests masked inherited middle-step keys through the saved agent endpoint", async () => { + const catalog = configuredCatalog(false); + const agentEndpointId = assignedCatalogEndpointId(createModelWorkspace(catalog), "byok", "agent"); + const testModelConfig = vi.fn(async () => ({ + ok: true, + message: "ok", + checkedAt: "2026-10-13T00:00:00.000Z" + })); + mocks.state = { ...createInitialAppState(), modelConfig: maskedSavedModelConfig(catalog) }; + mocks.clients = createClients(vi.fn(async () => maskedSavedModelConfig(catalog)), { testModelConfig }); + await render(); + + const reuseButtons = () => [...container.querySelectorAll('button[role="checkbox"]')]; + await click(reuseButtons()[1]!); + await click(testButton()); + await vi.waitFor(() => expect(testModelConfig).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ endpointId: agentEndpointId, apiKey: "" }), + "chat", + "skill" + )); + + await click(reuseButtons()[0]!); + await click(testButton()); + await vi.waitFor(() => expect(testModelConfig).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ endpointId: agentEndpointId, apiKey: "" }), + "chat", + "memory" + )); + }); + it("keeps the middle step pending and retryable when its catalog save fails", async () => { const firstSave = deferred(); const saveModelCatalog = vi.fn() From b433ef36b37c4a20ea98506f3c6a90106ad9cb43 Mon Sep 17 00:00:00 2001 From: ZongYue Date: Tue, 13 Oct 2026 14:33:31 +0800 Subject: [PATCH 28/85] fix: apply explicit API model selection to resumed sessions --- .../src/entrypoints/openai-like-api/server.ts | 12 ++++++++++- .../openai-like-api/api-stream.test.ts | 12 +++++++++++ .../openai-like-api/openai-api.test.ts | 20 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/App/memmy-agent/src/entrypoints/openai-like-api/server.ts b/App/memmy-agent/src/entrypoints/openai-like-api/server.ts index 9217d0262..5abfd2dd1 100644 --- a/App/memmy-agent/src/entrypoints/openai-like-api/server.ts +++ b/App/memmy-agent/src/entrypoints/openai-like-api/server.ts @@ -225,6 +225,7 @@ async function callAgent(ctx: ApiContext, args: Record): Promise): Promise { expect(response.status).toBe(200); expect(body).not.toContain('"finish_reason":"stop"'); expect(body).not.toContain("[DONE]"); + expect(JSON.parse(ssePayloads(body)[0]).error.code).toBe("server_error"); + expect(body).not.toContain("backend blew up"); + }); + + it("reports an unavailable session model instead of silently closing the stream", async () => { + const app = createApp({ processDirect: async () => { + throw new Error("model_selection_unavailable"); + } }, "m"); + const response = await app.fetch(request({ messages: [{ role: "user", content: "hi" }], stream: true })); + const body = await response.text(); + expect(JSON.parse(ssePayloads(body)[0]).error).toMatchObject({ code: "model_selection_unavailable" }); + expect(body).not.toContain("[DONE]"); }); }); diff --git a/App/memmy-agent/tests/entrypoints/openai-like-api/openai-api.test.ts b/App/memmy-agent/tests/entrypoints/openai-like-api/openai-api.test.ts index c81b232d8..fe01a530e 100644 --- a/App/memmy-agent/tests/entrypoints/openai-like-api/openai-api.test.ts +++ b/App/memmy-agent/tests/entrypoints/openai-like-api/openai-api.test.ts @@ -70,6 +70,26 @@ describe("OpenAI-compatible API response helpers", () => { }); describe("OpenAI-compatible API request validation", () => { + it("overrides a persisted model binding only when the request explicitly selects the configured model", async () => { + const calls: any[] = []; + const app = createApp({ + modelPreset: "glm-preset", + processMessageInternal() {}, + processDirect: async (_content: string, options: any) => { + calls.push(options); + return "ok"; + }, + }, "glm-5.2"); + for (const stream of [false, true]) { + const response = await app.fetch(request({ model: "glm-5.2", session_id: "existing", + messages: [{ role: "user", content: "hello" }], stream })); + await response.text(); + expect(calls.at(-1)).toMatchObject({ sessionKey: "api:existing", metadata: { model_preset: "glm-preset" } }); + } + await app.fetch(request({ session_id: "existing", messages: [{ role: "user", content: "hello" }] })); + expect(calls.at(-1).metadata).toBeUndefined(); + }); + it("returns 400 when messages are missing", async () => { const app = createApp({ processDirect: async () => "unused" }, "test-model"); From 1d09b69a05e28955c0eab611df773d168025b0d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=98=89?= Date: Tue, 13 Oct 2026 15:30:45 +0800 Subject: [PATCH 29/85] =?UTF-8?q?fix:=20=E6=8B=92=E7=BB=9D=E7=BB=9D?= =?UTF-8?q?=E5=AF=B9=E8=B7=AF=E5=BE=84=E4=B8=AD=E7=9A=84=E7=88=B6=E7=BA=A7?= =?UTF-8?q?=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/core/agent-runtime/tools/patch-envelope.ts | 2 +- .../tests/core/agent-runtime/tools/bug-671-repro.test.ts | 4 ++++ .../tests/core/agent-runtime/tools/patch-envelope.test.ts | 7 ++++++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/App/memmy-agent/src/core/agent-runtime/tools/patch-envelope.ts b/App/memmy-agent/src/core/agent-runtime/tools/patch-envelope.ts index cc7f0a10a..09b78f8b6 100644 --- a/App/memmy-agent/src/core/agent-runtime/tools/patch-envelope.ts +++ b/App/memmy-agent/src/core/agent-runtime/tools/patch-envelope.ts @@ -111,7 +111,7 @@ export function normalizePatchPath(raw: string): string { const absolute = path.isAbsolute(value); const slashPath = value.replace(/\\/g, "/"); - if (!absolute && slashPath.split("/").some((segment) => segment === "..")) { + if (slashPath.split("/").some((segment) => segment === "..")) { throw new PatchError(`patch path must not contain '..': ${raw}`); } const normalized = absolute diff --git a/App/memmy-agent/tests/core/agent-runtime/tools/bug-671-repro.test.ts b/App/memmy-agent/tests/core/agent-runtime/tools/bug-671-repro.test.ts index 2f56d057c..88ebbac3d 100644 --- a/App/memmy-agent/tests/core/agent-runtime/tools/bug-671-repro.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/tools/bug-671-repro.test.ts @@ -118,12 +118,16 @@ describe("Bug 671 reproduction", () => { const root = workspace(); const outside = workspace(); const outsideFile = path.join(outside, "outside.txt"); + const absoluteTraversal = `${root}${path.sep}nested${path.sep}..${path.sep}escaped.txt`; fs.writeFileSync(outsideFile, "outside\n"); fs.symlinkSync(outsideFile, path.join(root, "outside-link.txt")); const tool = new ApplyPatchTool({ workspace: root }); expect(await tool.execute(input("*** Add File: ../escaped.txt", "+escaped"))) .toContain("must not contain '..'"); + expect(await tool.execute(input(`*** Add File: ${absoluteTraversal}`, "+escaped"))) + .toContain("must not contain '..'"); + expect(fs.existsSync(path.join(root, "escaped.txt"))).toBe(false); expect(await tool.execute(input(`*** Delete File: ${outsideFile}`))) .toContain("patch path escapes workspace"); expect(await tool.execute(input("*** Delete File: outside-link.txt"))) diff --git a/App/memmy-agent/tests/core/agent-runtime/tools/patch-envelope.test.ts b/App/memmy-agent/tests/core/agent-runtime/tools/patch-envelope.test.ts index fa25d76a7..2897ca56a 100644 --- a/App/memmy-agent/tests/core/agent-runtime/tools/patch-envelope.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/tools/patch-envelope.test.ts @@ -133,10 +133,15 @@ describe("patch envelope parser", () => { }); it("accepts absolute paths and normalizes separators and dot segments", () => { - const absolute = path.resolve("workspace", "src", "..", "file.ts"); + const absolute = path.resolve("workspace", "file.ts"); expect(path.isAbsolute(normalizePatchPath(` ${absolute} `))).toBe(true); expect(normalizePatchPath(" ./src\\nested//./file.ts ")).toBe("src/nested/file.ts"); }); + + it("rejects parent traversal in absolute paths before normalization", () => { + const absolute = `${path.resolve("workspace")}${path.sep}nested${path.sep}..${path.sep}file.ts`; + expect(() => normalizePatchPath(absolute)).toThrow("must not contain '..'"); + }); }); describe("patch envelope prefix scanner", () => { From 94e3eb9889b0b623575a58598ac14c0a5ca40ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=98=89?= Date: Sat, 10 Oct 2026 19:22:58 +0800 Subject: [PATCH 30/85] =?UTF-8?q?fix:=20=E6=81=A2=E5=A4=8D=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E8=B4=A6=E5=8F=B7=E7=9A=84=E5=86=85=E7=BD=AE=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/infrastructure/memmy-config/index.ts | 14 +++++ .../services/runtime-config-sync-service.ts | 32 ++++++++++++ .../tests/runtime-config-sync-service.test.ts | 51 +++++++++++++++++++ 3 files changed, 97 insertions(+) diff --git a/App/backend/src/infrastructure/memmy-config/index.ts b/App/backend/src/infrastructure/memmy-config/index.ts index 050475886..27f640778 100644 --- a/App/backend/src/infrastructure/memmy-config/index.ts +++ b/App/backend/src/infrastructure/memmy-config/index.ts @@ -334,6 +334,20 @@ export async function readRuntimeMemmyConfigState( return deriveRuntimeMemmyConfigState(parsed, configPath, mode); } +/** Reads the persisted runtime user mode without deriving a model selection. */ +export async function readRuntimeMemmyConfigUserMode( + configPath = resolveDefaultMemmyConfigPath() +): Promise { + const content = await readMemmyConfigContent(configPath); + if (!content?.trim()) return undefined; + const parsed = YAML.parse(content) as unknown; + if (!isRecord(parsed)) return undefined; + const userMode = existingString(asRecord(parsed.app)?.userMode); + return userMode === "account" || userMode === "byok" || userMode === "unset" + ? userMode + : undefined; +} + /** Reads agents.defaults.timezone without inventing a configured value. */ export async function readConfiguredAgentTimeZone( configPath = resolveDefaultMemmyConfigPath() diff --git a/App/backend/src/services/runtime-config-sync-service.ts b/App/backend/src/services/runtime-config-sync-service.ts index a5a80473d..ab510e37e 100644 --- a/App/backend/src/services/runtime-config-sync-service.ts +++ b/App/backend/src/services/runtime-config-sync-service.ts @@ -6,6 +6,7 @@ import { } from "../infrastructure/app-state-store/index.js"; import { clearAccountModelProjectionFromMemmyConfig, + readRuntimeMemmyConfigUserMode, readRuntimeMemmyConfigState, writeAccountModelProjectionToMemmyConfig, type RuntimeMemmyConfigState @@ -90,6 +91,7 @@ export async function syncRuntimeConfigWithAppState( } return accountChannelMismatchResult(options.appStateStore, wroteConfig); } + state = await restoreMissingActiveAccountProjection(options, state); switch (state.status) { case "valid_byok": { const clearedDormantProjection = await clearUntrustedAccountProjection(options, state.accountProjection); @@ -135,6 +137,36 @@ export async function syncRuntimeConfigWithAppState( } } +async function restoreMissingActiveAccountProjection( + options: SyncRuntimeConfigWithAppStateOptions, + state: RuntimeMemmyConfigState +): Promise { + if (state.status === "valid_account" || state.status === "invalid_yaml" || state.status === "conflict") { + return state; + } + + const session = options.appStateStore.repositories.accountSession.get(); + if (!session.authenticated) return state; + if (options.appStateStore.repositories.bootstrap.getAppSettings().userMode !== "account") { + return state; + } + + // An explicit BYOK selection remains authoritative even while the user is signed in. + // The repair is only for an account-mode database paired with an unset/incomplete runtime projection. + if (await readRuntimeMemmyConfigUserMode(options.memmyConfigPath) === "byok") { + return state; + } + + const cloudUuid = options.appStateStore.repositories.accountSession.getCloudUuid(); + if (!cloudUuid) return state; + await writeAccountModelProjectionToMemmyConfig({ + cloudUuid, + userId: session.profile.userId, + preserveAccountByokSelection: true + }, options.memmyConfigPath); + return readRuntimeMemmyConfigState(options.memmyConfigPath); +} + /** Handles sync runtime config for startup. */ export async function syncRuntimeConfigForStartup( options: SyncRuntimeConfigForStartupOptions diff --git a/App/backend/src/services/tests/runtime-config-sync-service.test.ts b/App/backend/src/services/tests/runtime-config-sync-service.test.ts index 2bc3c5828..e7000923a 100644 --- a/App/backend/src/services/tests/runtime-config-sync-service.test.ts +++ b/App/backend/src/services/tests/runtime-config-sync-service.test.ts @@ -51,6 +51,57 @@ describe("syncRuntimeConfigWithAppState", () => { expect(context.store.db.prepare("SELECT * FROM account_model_config ORDER BY uuid").all()).toEqual(legacyBefore); }); + it("restores built-in models when an authenticated account has an unset runtime projection", async () => { + const context = createContext(); + seedAccountSession(context, "phone"); + const config = currentByokCatalog() as any; + config.app.userMode = "unset"; + config.modelAssignments.account = { + ownerAccountId: "owner-a", + agent: { candidates: ["missing-account-preset"], default: "missing-account-preset" } + }; + context.writeConfig(config); + + await expect(syncRuntimeConfigWithAppState({ + ...context, + accountChannel: "phone" + })).resolves.toMatchObject({ + source: "runtime_config", + mode: "account", + provider: "memmy_account", + model: "agent_chat", + hydratedAppState: true + }); + + const saved = YAML.parse(readFileSync(context.memmyConfigPath, "utf8")); + expect(saved.providers.memmy_account).toMatchObject({ + ownerAccountId: "owner-a", + apiKey: "cloud-token-a" + }); + expect(saved.modelAssignments.account.agent.candidates).toContainEqual( + expect.stringMatching(/^memmy-account-.+-agent$/) + ); + }); + + it("keeps an explicit BYOK runtime selection while the account remains authenticated", async () => { + const context = createContext(); + seedAccountSession(context); + context.writeConfig(currentByokCatalog()); + + await expect(syncRuntimeConfigWithAppState({ + ...context, + accountChannel: "email" + })).resolves.toMatchObject({ + source: "runtime_config", + mode: "byok", + provider: "openai", + model: "gpt-5" + }); + + const saved = YAML.parse(readFileSync(context.memmyConfigPath, "utf8")); + expect(saved.providers.memmy_account).toBeUndefined(); + }); + it("hydrates account mode only from a current owner-bound projection", async () => { const context = createContext(); context.store.repositories.accountSession.upsert({ From 3ba189ac058f0883addcf75be0d9897273381870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=98=89?= Date: Tue, 13 Oct 2026 22:10:42 +0800 Subject: [PATCH 31/85] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E8=B4=A6=E5=8F=B7=E5=86=85=E7=BD=AE=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/infrastructure/memmy-config/index.ts | 1 + .../tests/account-model-projection.test.ts | 1 + .../services/runtime-config-sync-service.ts | 56 ++++++++++++------- .../tests/runtime-config-sync-service.test.ts | 5 +- 4 files changed, 41 insertions(+), 22 deletions(-) diff --git a/App/backend/src/infrastructure/memmy-config/index.ts b/App/backend/src/infrastructure/memmy-config/index.ts index 27f640778..d04c1f30d 100644 --- a/App/backend/src/infrastructure/memmy-config/index.ts +++ b/App/backend/src/infrastructure/memmy-config/index.ts @@ -585,6 +585,7 @@ export async function writeAccountModelProjectionToMemmyConfig( appConfig, LEGACY_ACCOUNT_BYOK_LOCAL_SELECTION_BASELINE ); + appConfig.userMode = "account"; if (normalizedCloudUuid) appConfig.cloudUuid = normalizedCloudUuid; if (normalizedUserId) appConfig.userId = normalizedUserId; delete appConfig[LEGACY_ACCOUNT_BYOK_LOCAL_SELECTION_BASELINE]; diff --git a/App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts b/App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts index 15e43fde3..61031302e 100644 --- a/App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts +++ b/App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts @@ -97,6 +97,7 @@ describe("account model projection current catalog", () => { expect(result).toEqual({ changed: true, memoryConfigAffected: true }); const saved = await readConfig(file); + expect(saved.app.userMode).toBe("account"); expect(saved.providers.memmy_account).toMatchObject({ ownerAccountId: "owner-a", apiKey: "cloud-token", diff --git a/App/backend/src/services/runtime-config-sync-service.ts b/App/backend/src/services/runtime-config-sync-service.ts index ab510e37e..80f32bc5d 100644 --- a/App/backend/src/services/runtime-config-sync-service.ts +++ b/App/backend/src/services/runtime-config-sync-service.ts @@ -9,7 +9,8 @@ import { readRuntimeMemmyConfigUserMode, readRuntimeMemmyConfigState, writeAccountModelProjectionToMemmyConfig, - type RuntimeMemmyConfigState + type RuntimeMemmyConfigState, + type RuntimeProjectionResult } from "../infrastructure/memmy-config/index.js"; export interface RuntimeConfigMigrationConsistency { @@ -91,7 +92,8 @@ export async function syncRuntimeConfigWithAppState( } return accountChannelMismatchResult(options.appStateStore, wroteConfig); } - state = await restoreMissingActiveAccountProjection(options, state); + const restored = await restoreMissingActiveAccountProjection(options, state); + state = restored.state; switch (state.status) { case "valid_byok": { const clearedDormantProjection = await clearUntrustedAccountProjection(options, state.accountProjection); @@ -101,7 +103,7 @@ export async function syncRuntimeConfigWithAppState( : hydrated; } case "valid_account": - return hydrateAccountRuntimeConfig(options, state); + return hydrateAccountRuntimeConfig(options, state, restored.projection); case "missing": case "empty": return { @@ -140,31 +142,37 @@ export async function syncRuntimeConfigWithAppState( async function restoreMissingActiveAccountProjection( options: SyncRuntimeConfigWithAppStateOptions, state: RuntimeMemmyConfigState -): Promise { +): Promise<{ + state: RuntimeMemmyConfigState; + projection?: RuntimeProjectionResult; +}> { if (state.status === "valid_account" || state.status === "invalid_yaml" || state.status === "conflict") { - return state; + return { state }; } const session = options.appStateStore.repositories.accountSession.get(); - if (!session.authenticated) return state; + if (!session.authenticated) return { state }; if (options.appStateStore.repositories.bootstrap.getAppSettings().userMode !== "account") { - return state; + return { state }; } // An explicit BYOK selection remains authoritative even while the user is signed in. // The repair is only for an account-mode database paired with an unset/incomplete runtime projection. if (await readRuntimeMemmyConfigUserMode(options.memmyConfigPath) === "byok") { - return state; + return { state }; } const cloudUuid = options.appStateStore.repositories.accountSession.getCloudUuid(); - if (!cloudUuid) return state; - await writeAccountModelProjectionToMemmyConfig({ + if (!cloudUuid) return { state }; + const projection = await writeAccountModelProjectionToMemmyConfig({ cloudUuid, userId: session.profile.userId, preserveAccountByokSelection: true }, options.memmyConfigPath); - return readRuntimeMemmyConfigState(options.memmyConfigPath); + return { + state: await readRuntimeMemmyConfigState(options.memmyConfigPath), + projection + }; } /** Handles sync runtime config for startup. */ @@ -279,7 +287,8 @@ function hydrateByokRuntimeConfig( async function hydrateAccountRuntimeConfig( options: SyncRuntimeConfigWithAppStateOptions, - state: Extract + state: Extract, + restoredProjection?: RuntimeProjectionResult ): Promise { const { appStateStore } = options; const activated = appStateStore.repositories.accountSession.activateByCloudUuid( @@ -303,15 +312,18 @@ async function hydrateAccountRuntimeConfig( source: "none", mode: appStateStore.repositories.bootstrap.getAppSettings().userMode, hydratedAppState: false, - wroteConfig: projection.changed, + wroteConfig: projection.changed || restoredProjection?.changed === true, reason: "account_projection_has_no_matching_local_session" }; } - const projection = await writeAccountModelProjectionToMemmyConfig({ - cloudUuid: state.cloudUuid, - userId: session.profile.userId, - preserveAccountByokSelection: true - }, options.memmyConfigPath); + const projection = restoredProjection ?? await writeAccountModelProjectionToMemmyConfig( + { + cloudUuid: state.cloudUuid, + userId: session.profile.userId, + preserveAccountByokSelection: true + }, + options.memmyConfigPath + ); appStateStore.repositories.bootstrap.updateAppSettings({ userMode: "account" }); return { source: "runtime_config", @@ -320,9 +332,11 @@ async function hydrateAccountRuntimeConfig( model: "agent_chat", hydratedAppState: true, wroteConfig: projection.changed, - reason: projection.changed - ? "refreshed_account_projection_and_hydrated_account" - : "hydrated_account_from_runtime_config" + reason: restoredProjection?.changed + ? "restored_account_projection_and_hydrated_account" + : projection.changed + ? "refreshed_account_projection_and_hydrated_account" + : "hydrated_account_from_runtime_config" }; } diff --git a/App/backend/src/services/tests/runtime-config-sync-service.test.ts b/App/backend/src/services/tests/runtime-config-sync-service.test.ts index e7000923a..3eb7bec58 100644 --- a/App/backend/src/services/tests/runtime-config-sync-service.test.ts +++ b/App/backend/src/services/tests/runtime-config-sync-service.test.ts @@ -70,10 +70,13 @@ describe("syncRuntimeConfigWithAppState", () => { mode: "account", provider: "memmy_account", model: "agent_chat", - hydratedAppState: true + hydratedAppState: true, + wroteConfig: true, + reason: "restored_account_projection_and_hydrated_account" }); const saved = YAML.parse(readFileSync(context.memmyConfigPath, "utf8")); + expect(saved.app.userMode).toBe("account"); expect(saved.providers.memmy_account).toMatchObject({ ownerAccountId: "owner-a", apiKey: "cloud-token-a" From 516e2df02cb0dde37cdcda15053a613ec263169a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=98=89?= Date: Wed, 14 Oct 2026 09:20:49 +0800 Subject: [PATCH 32/85] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E6=A8=A1=E5=9E=8B=E6=81=A2=E5=A4=8D=E7=BB=93=E6=9E=9C?= =?UTF-8?q?=E5=B9=B6=E6=A0=A1=E9=AA=8C=E8=B4=A6=E5=8F=B7=E4=B8=80=E8=87=B4?= =?UTF-8?q?=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/runtime-config-sync-service.ts | 23 +++-- .../tests/runtime-config-sync-service.test.ts | 91 ++++++++++++++++++- 2 files changed, 106 insertions(+), 8 deletions(-) diff --git a/App/backend/src/services/runtime-config-sync-service.ts b/App/backend/src/services/runtime-config-sync-service.ts index 80f32bc5d..d58357bb5 100644 --- a/App/backend/src/services/runtime-config-sync-service.ts +++ b/App/backend/src/services/runtime-config-sync-service.ts @@ -54,6 +54,12 @@ type RuntimeConfigSyncErrorState = { reason: string; }; +interface RestoredAccountProjection { + cloudUuid: string; + userId: string; + result: RuntimeProjectionResult; +} + /** * Hydrate current AppState from config.yaml. Missing runtime config is left untouched: * importing SQLite model rows into YAML belongs exclusively to startup migrations. @@ -144,7 +150,7 @@ async function restoreMissingActiveAccountProjection( state: RuntimeMemmyConfigState ): Promise<{ state: RuntimeMemmyConfigState; - projection?: RuntimeProjectionResult; + projection?: RestoredAccountProjection; }> { if (state.status === "valid_account" || state.status === "invalid_yaml" || state.status === "conflict") { return { state }; @@ -171,7 +177,7 @@ async function restoreMissingActiveAccountProjection( }, options.memmyConfigPath); return { state: await readRuntimeMemmyConfigState(options.memmyConfigPath), - projection + projection: { cloudUuid, userId: session.profile.userId, result: projection } }; } @@ -288,7 +294,7 @@ function hydrateByokRuntimeConfig( async function hydrateAccountRuntimeConfig( options: SyncRuntimeConfigWithAppStateOptions, state: Extract, - restoredProjection?: RuntimeProjectionResult + restoredProjection?: RestoredAccountProjection ): Promise { const { appStateStore } = options; const activated = appStateStore.repositories.accountSession.activateByCloudUuid( @@ -312,11 +318,14 @@ async function hydrateAccountRuntimeConfig( source: "none", mode: appStateStore.repositories.bootstrap.getAppSettings().userMode, hydratedAppState: false, - wroteConfig: projection.changed || restoredProjection?.changed === true, + wroteConfig: projection.changed || restoredProjection?.result.changed === true, reason: "account_projection_has_no_matching_local_session" }; } - const projection = restoredProjection ?? await writeAccountModelProjectionToMemmyConfig( + // Reuse a completed repair only after validating the session and its account identity. + const restoredForCurrentAccount = restoredProjection?.cloudUuid === state.cloudUuid + && restoredProjection.userId === session.profile.userId; + const projection = restoredForCurrentAccount ? restoredProjection.result : await writeAccountModelProjectionToMemmyConfig( { cloudUuid: state.cloudUuid, userId: session.profile.userId, @@ -331,8 +340,8 @@ async function hydrateAccountRuntimeConfig( provider: "memmy_account", model: "agent_chat", hydratedAppState: true, - wroteConfig: projection.changed, - reason: restoredProjection?.changed + wroteConfig: projection.changed || restoredProjection?.result.changed === true, + reason: restoredForCurrentAccount && restoredProjection.result.changed ? "restored_account_projection_and_hydrated_account" : projection.changed ? "refreshed_account_projection_and_hydrated_account" diff --git a/App/backend/src/services/tests/runtime-config-sync-service.test.ts b/App/backend/src/services/tests/runtime-config-sync-service.test.ts index 3eb7bec58..1c941259b 100644 --- a/App/backend/src/services/tests/runtime-config-sync-service.test.ts +++ b/App/backend/src/services/tests/runtime-config-sync-service.test.ts @@ -3,8 +3,9 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import YAML from "yaml"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createAppStateStore, type AppStateStore } from "../../infrastructure/app-state-store/index.js"; +import * as memmyConfig from "../../infrastructure/memmy-config/index.js"; import { createMemmyConfigWriter, writeAccountModelProjectionToMemmyConfig @@ -21,6 +22,7 @@ beforeEach(() => { }); afterEach(() => { + vi.restoreAllMocks(); store?.close(); store = undefined; if (tempDir) rmSync(tempDir, { recursive: true, force: true }); @@ -62,6 +64,10 @@ describe("syncRuntimeConfigWithAppState", () => { }; context.writeConfig(config); + const writeProjection = vi.spyOn(memmyConfig, "writeAccountModelProjectionToMemmyConfig"); + const activateSession = vi.spyOn(context.store.repositories.accountSession, "activateByCloudUuid"); + const updateSettings = vi.spyOn(context.store.repositories.bootstrap, "updateAppSettings"); + await expect(syncRuntimeConfigWithAppState({ ...context, accountChannel: "phone" @@ -74,6 +80,9 @@ describe("syncRuntimeConfigWithAppState", () => { wroteConfig: true, reason: "restored_account_projection_and_hydrated_account" }); + expect(writeProjection).toHaveBeenCalledTimes(1); + expect(activateSession).toHaveBeenCalledWith("cloud-token-a", "phone"); + expect(updateSettings).toHaveBeenCalledWith({ userMode: "account" }); const saved = YAML.parse(readFileSync(context.memmyConfigPath, "utf8")); expect(saved.app.userMode).toBe("account"); @@ -84,6 +93,86 @@ describe("syncRuntimeConfigWithAppState", () => { expect(saved.modelAssignments.account.agent.candidates).toContainEqual( expect.stringMatching(/^memmy-account-.+-agent$/) ); + + const repairedConfig = readFileSync(context.memmyConfigPath, "utf8"); + await expect(syncRuntimeConfigWithAppState({ + ...context, + accountChannel: "phone" + })).resolves.toMatchObject({ + mode: "account", + hydratedAppState: true, + wroteConfig: false, + reason: "hydrated_account_from_runtime_config" + }); + // A normal restart still refreshes the projection, but makes no redundant changes. + expect(writeProjection).toHaveBeenCalledTimes(2); + expect(readFileSync(context.memmyConfigPath, "utf8")).toBe(repairedConfig); + }); + + it("validates the session after repairing a missing account projection", async () => { + const context = createContext(); + seedAccountSession(context); + const config = currentByokCatalog() as any; + config.app.userMode = "unset"; + context.writeConfig(config); + const writeProjection = vi.spyOn(memmyConfig, "writeAccountModelProjectionToMemmyConfig"); + vi.spyOn(context.store.repositories.accountSession, "activateByCloudUuid").mockReturnValue(false); + const updateSettings = vi.spyOn(context.store.repositories.bootstrap, "updateAppSettings"); + + await expect(syncRuntimeConfigWithAppState({ ...context, accountChannel: "email" })) + .resolves.toMatchObject({ + source: "none", + hydratedAppState: false, + wroteConfig: true, + reason: "account_projection_has_no_matching_local_session" + }); + expect(writeProjection).toHaveBeenCalledTimes(1); + expect(updateSettings).not.toHaveBeenCalled(); + const saved = YAML.parse(readFileSync(context.memmyConfigPath, "utf8")); + expect(saved.providers.memmy_account).toBeUndefined(); + }); + + it("refreshes a different account if its projection replaces the repaired config before hydration", async () => { + const context = createContext(); + seedAccountSession(context); + const session = context.store.repositories.accountSession.get(); + if (!session.authenticated) throw new Error("Expected the seeded account session"); + context.store.repositories.accountSession.upsert({ + profile: { ...session.profile, userId: "owner-b" }, + uuid: "account-b", + cloudUuid: "cloud-token-b", + authChannel: "email" + }); + context.store.repositories.accountSession.activateByCloudUuid("cloud-token-a", "email"); + const config = currentByokCatalog() as any; + config.app.userMode = "unset"; + context.writeConfig(config); + const originalWrite = memmyConfig.writeAccountModelProjectionToMemmyConfig; + const writeProjection = vi.spyOn(memmyConfig, "writeAccountModelProjectionToMemmyConfig") + .mockImplementationOnce(async (...args) => { + const result = await originalWrite(...args); + // Model a concurrent account switch while the repaired config is being re-read. + context.writeRaw(YAML.stringify(currentAccountCatalog()) + .replaceAll("owner-a", "owner-b") + .replaceAll("cloud-token-a", "cloud-token-b")); + return result; + }); + + await expect(syncRuntimeConfigWithAppState({ ...context, accountChannel: "email" })) + .resolves.toMatchObject({ mode: "account", hydratedAppState: true, wroteConfig: true }); + expect(writeProjection).toHaveBeenCalledTimes(2); + expect(writeProjection).toHaveBeenLastCalledWith({ + cloudUuid: "cloud-token-b", userId: "owner-b", preserveAccountByokSelection: true + }, context.memmyConfigPath); + expect(context.store.repositories.accountSession.get()).toMatchObject({ + authenticated: true, profile: { userId: "owner-b" } + }); + const saved = YAML.parse(readFileSync(context.memmyConfigPath, "utf8")); + expect(saved.providers.memmy_account.ownerAccountId).toBe("owner-b"); + expect(saved.modelAssignments.account.ownerAccountId).toBe("owner-b"); + expect(saved.modelPresets[saved.modelAssignments.account.memorySummary]).toMatchObject({ + source: "account", ownerAccountId: "owner-b", capabilities: ["memory_summary"] + }); }); it("keeps an explicit BYOK runtime selection while the account remains authenticated", async () => { From 679b0ec530d18fdf79ab4450f056a514e046c3ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=98=89?= Date: Wed, 14 Oct 2026 10:32:31 +0800 Subject: [PATCH 33/85] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=E8=A1=A5?= =?UTF-8?q?=E4=B8=81=E8=A7=84=E8=8C=83=E5=8C=96=E8=B7=AF=E5=BE=84=E4=B8=AD?= =?UTF-8?q?=E7=9A=84=E6=96=87=E4=BB=B6=E5=90=8D=E7=A9=BA=E6=A0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/agent-runtime/tools/apply-patch.ts | 11 +- .../tools/apply-patch-tool.test.ts | 58 ++++++- .../agent-runtime/tools/bug-671-repro.test.ts | 150 ------------------ 3 files changed, 63 insertions(+), 156 deletions(-) delete mode 100644 App/memmy-agent/tests/core/agent-runtime/tools/bug-671-repro.test.ts diff --git a/App/memmy-agent/src/core/agent-runtime/tools/apply-patch.ts b/App/memmy-agent/src/core/agent-runtime/tools/apply-patch.ts index d8b015b1f..c9d671e45 100644 --- a/App/memmy-agent/src/core/agent-runtime/tools/apply-patch.ts +++ b/App/memmy-agent/src/core/agent-runtime/tools/apply-patch.ts @@ -328,8 +328,7 @@ Paths may be workspace-relative or absolute paths inside the workspace. Use at m return params; } - resolve(requestedPath: string): string { - const normalized = normalizePatchPath(requestedPath); + private resolveNormalizedPath(normalized: string, requestedPath = normalized): string { const target = path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(this.workspace, ...normalized.split("/")); @@ -358,6 +357,10 @@ Paths may be workspace-relative or absolute paths inside the workspace. Use at m return target; } + resolve(requestedPath: string): string { + return this.resolveNormalizedPath(normalizePatchPath(requestedPath), requestedPath); + } + private normalizeOperations(operations: PatchFileOperation[]): PatchFileOperation[] { const relativePath = (requestedPath: string): string => path.relative(this.workspace, this.resolve(requestedPath)).split(path.sep).join("/"); @@ -395,9 +398,9 @@ Paths may be workspace-relative or absolute paths inside the workspace. Use at m const resolved = new Map(); for (const operation of operations) { throwIfAborted(signal); - resolved.set(operation.path, this.resolve(operation.path)); + resolved.set(operation.path, this.resolveNormalizedPath(operation.path)); if (operation.kind === "update" && operation.moveTo) { - resolved.set(operation.moveTo, this.resolve(operation.moveTo)); + resolved.set(operation.moveTo, this.resolveNormalizedPath(operation.moveTo)); } } diff --git a/App/memmy-agent/tests/core/agent-runtime/tools/apply-patch-tool.test.ts b/App/memmy-agent/tests/core/agent-runtime/tools/apply-patch-tool.test.ts index adcce77ca..1e234b010 100644 --- a/App/memmy-agent/tests/core/agent-runtime/tools/apply-patch-tool.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/tools/apply-patch-tool.test.ts @@ -127,6 +127,53 @@ describe("ApplyPatchTool operations", () => { expect(fs.readFileSync(path.join(root, "moved.txt"), "utf8")).toBe("after\n"); }); + it("supports absolute paths for every operation and preserves filename spaces", async () => { + const root = workspace(); + const added = path.join(root, " added.txt"); + const updated = path.join(root, " updated.txt"); + const deleted = path.join(root, " deleted.txt"); + const moveSource = path.join(root, " move-source.txt"); + const moveTarget = path.join(root, " moved-target.txt"); + fs.writeFileSync(updated, "before\n"); + fs.writeFileSync(deleted, "delete\n"); + fs.writeFileSync(moveSource, "move\n"); + + const result = await new ApplyPatchTool({ workspace: root }).execute(input( + `*** Add File: ${added}`, + "+added", + `*** Update File: ${updated}`, + "@@", + "-before", + "+after", + `*** Delete File: ${deleted}`, + `*** Update File: ${moveSource}`, + `*** Move to: ${moveTarget}`, + )); + + expect(result).toContain("Patch applied:"); + expect(fs.readFileSync(added, "utf8")).toBe("added\n"); + expect(fs.existsSync(path.join(root, "added.txt"))).toBe(false); + expect(fs.readFileSync(updated, "utf8")).toBe("after\n"); + expect(fs.existsSync(path.join(root, "updated.txt"))).toBe(false); + expect(fs.existsSync(deleted)).toBe(false); + expect(fs.existsSync(moveSource)).toBe(false); + expect(fs.readFileSync(moveTarget, "utf8")).toBe("move\n"); + expect(fs.existsSync(path.join(root, "moved-target.txt"))).toBe(false); + }); + + it("detects conflicts after absolute and relative paths are canonicalized", async () => { + const root = workspace(); + const target = path.join(root, "same.txt"); + const result = await new ApplyPatchTool({ workspace: root }).execute(input( + `*** Add File: ${target}`, + "+first", + "*** Delete File: same.txt", + )); + + expect(result).toContain("duplicate file operation path: same.txt"); + expect(fs.existsSync(target)).toBe(false); + }); + it("creates an empty file and preserves extra blank lines", async () => { const root = workspace(); const result = await new ApplyPatchTool({ workspace: root }).execute(input( @@ -184,16 +231,23 @@ describe("ApplyPatchTool operations", () => { it("rejects symlink paths and non-directory parents before reading content", async () => { const root = workspace(); const outside = workspace(); - fs.writeFileSync(path.join(outside, "outside.txt"), "outside\n"); - fs.symlinkSync(path.join(outside, "outside.txt"), path.join(root, "link.txt")); + const outsideFile = path.join(outside, "outside.txt"); + const absoluteTraversal = `${root}${path.sep}nested${path.sep}..${path.sep}escaped.txt`; + fs.writeFileSync(outsideFile, "outside\n"); + fs.symlinkSync(outsideFile, path.join(root, "link.txt")); fs.writeFileSync(path.join(root, "parent"), "file\n"); const tool = new ApplyPatchTool({ workspace: root }); + expect(await tool.execute(input(`*** Add File: ${absoluteTraversal}`, "+escaped"))) + .toContain("must not contain '..'"); + expect(await tool.execute(input(`*** Delete File: ${outsideFile}`))) + .toContain("patch path escapes workspace"); expect(await tool.execute(input("*** Delete File: link.txt"))).toContain("symbolic links"); expect(await tool.execute(input("*** Add File: parent/child.txt", "+x"))).toContain( "parent is not a directory", ); expect(fs.readFileSync(path.join(outside, "outside.txt"), "utf8")).toBe("outside\n"); + expect(fs.existsSync(path.join(root, "escaped.txt"))).toBe(false); }); it("plans every operation before the first write", async () => { diff --git a/App/memmy-agent/tests/core/agent-runtime/tools/bug-671-repro.test.ts b/App/memmy-agent/tests/core/agent-runtime/tools/bug-671-repro.test.ts deleted file mode 100644 index 88ebbac3d..000000000 --- a/App/memmy-agent/tests/core/agent-runtime/tools/bug-671-repro.test.ts +++ /dev/null @@ -1,150 +0,0 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; -import { ApplyPatchTool } from "../../../../src/core/agent-runtime/tools/apply-patch.js"; -import { EditFileTool, WriteFileTool } from "../../../../src/core/agent-runtime/tools/filesystem.js"; - -const roots: string[] = []; - -function workspace(): string { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "memmy-bug671-")); - roots.push(root); - return root; -} - -function input(...lines: string[]): { input: string } { - return { input: ["*** Begin Patch", ...lines, "*** End Patch"].join("\n") }; -} - -afterEach(() => { - for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); -}); - -describe("Bug 671 reproduction", () => { - it("accepts an in-workspace absolute path when adding", async () => { - const root = workspace(); - const target = path.join(root, "added.txt"); - const result = await new ApplyPatchTool({ workspace: root }).execute(input( - `*** Add File: ${target}`, - "+added", - )); - - expect(result).toContain("Patch applied:"); - expect(fs.readFileSync(target, "utf8")).toBe("added\n"); - }); - - it("accepts an in-workspace absolute path when updating", async () => { - const root = workspace(); - const target = path.join(root, "updated.txt"); - fs.writeFileSync(target, "before\n"); - const result = await new ApplyPatchTool({ workspace: root }).execute(input( - `*** Update File: ${target}`, - "@@", - "-before", - "+after", - )); - - expect(result).toContain("Patch applied:"); - expect(fs.readFileSync(target, "utf8")).toBe("after\n"); - }); - - it("accepts an in-workspace absolute path when deleting", async () => { - const root = workspace(); - const target = path.join(root, "deleted.txt"); - fs.writeFileSync(target, "delete\n"); - const result = await new ApplyPatchTool({ workspace: root }).execute(input( - `*** Delete File: ${target}`, - )); - - expect(result).toContain("Patch applied:"); - expect(fs.existsSync(target)).toBe(false); - }); - - it("accepts an in-workspace absolute move source", async () => { - const root = workspace(); - const source = path.join(root, "source.txt"); - fs.writeFileSync(source, "move\n"); - const result = await new ApplyPatchTool({ workspace: root }).execute(input( - `*** Update File: ${source}`, - "*** Move to: moved.txt", - )); - - expect(result).toContain("Patch applied:"); - expect(fs.readFileSync(path.join(root, "moved.txt"), "utf8")).toBe("move\n"); - }); - - it("accepts an in-workspace absolute move target", async () => { - const root = workspace(); - const source = path.join(root, "source.txt"); - const target = path.join(root, "moved.txt"); - fs.writeFileSync(source, "move\n"); - const result = await new ApplyPatchTool({ workspace: root }).execute(input( - "*** Update File: source.txt", - `*** Move to: ${target}`, - )); - - expect(result).toContain("Patch applied:"); - expect(fs.readFileSync(target, "utf8")).toBe("move\n"); - }); - - it("continues accepting relative apply_patch paths", async () => { - const root = workspace(); - const result = await new ApplyPatchTool({ workspace: root }).execute(input( - "*** Add File: relative.txt", - "+relative", - )); - - expect(result).toContain("Patch applied:"); - expect(fs.readFileSync(path.join(root, "relative.txt"), "utf8")).toBe("relative\n"); - }); - - it("matches write_file and edit_file absolute-path behavior", async () => { - const root = workspace(); - const target = path.join(root, "other-tools.txt"); - const options = { workspace: root, allowedDir: root, postWriteValidation: false } as any; - - expect(await new WriteFileTool(options).execute({ path: target, content: "before\n" })) - .toContain("Successfully wrote"); - expect(await new EditFileTool(options).execute({ - path: target, - old_text: "before", - new_text: "after", - })).toContain("Successfully edited file"); - expect(fs.readFileSync(target, "utf8")).toBe("after\n"); - }); - - it("still rejects traversal and symbolic links", async () => { - const root = workspace(); - const outside = workspace(); - const outsideFile = path.join(outside, "outside.txt"); - const absoluteTraversal = `${root}${path.sep}nested${path.sep}..${path.sep}escaped.txt`; - fs.writeFileSync(outsideFile, "outside\n"); - fs.symlinkSync(outsideFile, path.join(root, "outside-link.txt")); - const tool = new ApplyPatchTool({ workspace: root }); - - expect(await tool.execute(input("*** Add File: ../escaped.txt", "+escaped"))) - .toContain("must not contain '..'"); - expect(await tool.execute(input(`*** Add File: ${absoluteTraversal}`, "+escaped"))) - .toContain("must not contain '..'"); - expect(fs.existsSync(path.join(root, "escaped.txt"))).toBe(false); - expect(await tool.execute(input(`*** Delete File: ${outsideFile}`))) - .toContain("patch path escapes workspace"); - expect(await tool.execute(input("*** Delete File: outside-link.txt"))) - .toContain("symbolic links"); - expect(fs.readFileSync(outsideFile, "utf8")).toBe("outside\n"); - }); - - it("detects conflicts after absolute and relative paths are canonicalized", async () => { - const root = workspace(); - const absolute = path.join(root, "same.txt"); - const result = await new ApplyPatchTool({ workspace: root }).execute(input( - `*** Add File: ${absolute}`, - "+first", - "*** Delete File: same.txt", - )); - - expect(result).toContain("duplicate file operation path: same.txt"); - expect(fs.existsSync(absolute)).toBe(false); - }); -}); From 43ead001ef22dafe92a58542b6aad17393af4637 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Wed, 14 Oct 2026 12:05:24 +0800 Subject: [PATCH 34/85] fix: trim API keys when matching pending presets --- App/frontend/desktop/src/api/config-client.ts | 28 +++-- .../src/api/tests/config-client.test.ts | 111 ++++++++++-------- 2 files changed, 77 insertions(+), 62 deletions(-) diff --git a/App/frontend/desktop/src/api/config-client.ts b/App/frontend/desktop/src/api/config-client.ts index 2446bf2aa..8ad8e5242 100644 --- a/App/frontend/desktop/src/api/config-client.ts +++ b/App/frontend/desktop/src/api/config-client.ts @@ -606,8 +606,8 @@ interface PendingPreset { endpointId: string; endpointApiBase: string | null; endpointProtocol: ModelEndpointProtocol | null; - endpointApiKey: string; - providerApiKey: string; + endpointApiKey?: string; + providerApiKey?: string; model: string; source: "account" | "byok"; capabilities: ModelCapability[]; @@ -616,6 +616,8 @@ interface PendingPreset { function pendingPresets(input: ModelConfigInput): PendingPreset[] { return input.providers.flatMap((provider) => provider.models.flatMap((model) => { const endpoint = provider.endpoints.find((item) => item.endpointId === model.endpointId); + const endpointApiKey = trimNonEmpty(endpoint?.apiKey); + const providerApiKey = trimNonEmpty(provider.apiKey); return model.presetId?.startsWith(CLIENT_PRESET_ID_PREFIX) ? [{ clientId: model.presetId, @@ -623,8 +625,8 @@ function pendingPresets(input: ModelConfigInput): PendingPreset[] { endpointId: model.endpointId, endpointApiBase: endpoint?.apiBase ?? null, endpointProtocol: endpoint?.protocol ?? null, - endpointApiKey: endpoint?.apiKey ?? "", - providerApiKey: provider.apiKey ?? "", + ...(endpointApiKey ? { endpointApiKey } : {}), + ...(providerApiKey ? { providerApiKey } : {}), model: model.model, source: model.source, capabilities: [...model.capabilities] @@ -693,8 +695,8 @@ function pendingEndpointMatches( ) { return false; } - const expectedApiKey = maskSecret(item.endpointApiKey || item.providerApiKey); - const actualApiKey = endpoint.hasApiKey ? endpoint.apiKeyMasked : provider.apiKeyMasked; + const expectedApiKey = maskSecret(item.endpointApiKey ?? item.providerApiKey); + const actualApiKey = trimNonEmpty(endpoint.hasApiKey ? endpoint.apiKeyMasked : provider.apiKeyMasked); return !expectedApiKey || expectedApiKey === actualApiKey; } @@ -702,10 +704,16 @@ function normalizeApiBase(value: string): string { return value.trim().replace(/\/+$/u, ""); } -function maskSecret(value: string): string { - if (!value) return ""; - if (value.length <= 8) return "••••••••"; - return `${value.slice(0, 3)}••••${value.slice(-4)}`; +function trimNonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed || undefined; +} + +function maskSecret(value: string | undefined): string | undefined { + const secret = trimNonEmpty(value); + if (!secret) return undefined; + if (secret.length <= 8) return "••••••••"; + return `${secret.slice(0, 3)}••••${secret.slice(-4)}`; } function assignmentPhaseInput( diff --git a/App/frontend/desktop/src/api/tests/config-client.test.ts b/App/frontend/desktop/src/api/tests/config-client.test.ts index 4940a39ba..76d1efb9e 100644 --- a/App/frontend/desktop/src/api/tests/config-client.test.ts +++ b/App/frontend/desktop/src/api/tests/config-client.test.ts @@ -209,64 +209,71 @@ describe("config-client canonical model catalog", () => { expect(saved.catalog?.modelAssignments.byok.agent.default).toBe(serverPresetId); }); - it("后端合并相同 endpoint 定义后仍能解析新模型并完成 assignment", async () => { - const bodies: any[] = []; - const serverPresetId = "5c8872cf-28c8-4cf3-bf51-9a3a2a0fb42a"; - vi.stubGlobal("fetch", vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { - const body = JSON.parse(String(init?.body)); - bodies.push(body); - const response = catalog(bodies.length === 1 ? "revision-2" : "revision-3"); - response.providers[0]!.models.push({ - presetId: serverPresetId, - provider: "openai", - endpointId: "chat", + it.each(["endpoint apiKey", "provider apiKey", "missing apiKey"] as const)( + "后端合并相同 endpoint 定义后可用 %s 解析新模型并完成 assignment", + async (keyLocation) => { + const bodies: any[] = []; + const serverPresetId = "5c8872cf-28c8-4cf3-bf51-9a3a2a0fb42a"; + vi.stubGlobal("fetch", vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + const body = JSON.parse(String(init?.body)); + bodies.push(body); + const response = catalog(bodies.length === 1 ? "revision-2" : "revision-3"); + response.providers[0]!.apiKeyMasked = "sk-••••1234"; + for (const endpoint of response.providers[0]!.endpoints) endpoint.apiKeyMasked = "sk-••••1234"; + response.providers[0]!.models.push({ + presetId: serverPresetId, + provider: "openai", + endpointId: "chat", + protocol: "openai-chat-completions", + model: "gpt-new", + source: "byok", + capabilities: ["agent"], + available: true + }); + if (bodies.length > 1) { + response.modelAssignments.byok.agent = { + candidates: ["byok-agent", serverPresetId], + default: serverPresetId + }; + } + return jsonResponse(response); + })); + const input = inputFromCatalog(catalog("revision-1")); + const clientPresetId = `${CLIENT_PRESET_ID_PREFIX}canonicalized-endpoint`; + input.providers[0]!.endpoints.push({ + endpointId: "chat-duplicate", + apiBase: "https://api.openai.com/v1/", protocol: "openai-chat-completions", + ...(keyLocation === "endpoint apiKey" ? { apiKey: " sk-live1234 " } : {}) + }); + if (keyLocation === "provider apiKey") input.providers[0]!.apiKey = " sk-live1234 "; + input.providers[0]!.models.push({ + presetId: clientPresetId, + endpointId: "chat-duplicate", model: "gpt-new", source: "byok", - capabilities: ["agent"], - available: true + capabilities: ["agent"] }); - if (bodies.length > 1) { - response.modelAssignments.byok.agent = { - candidates: ["byok-agent", serverPresetId], - default: serverPresetId - }; - } - return jsonResponse(response); - })); - const input = inputFromCatalog(catalog("revision-1")); - const clientPresetId = `${CLIENT_PRESET_ID_PREFIX}canonicalized-endpoint`; - input.providers[0]!.endpoints.push({ - endpointId: "chat-duplicate", - apiBase: "https://api.openai.com/v1/", - protocol: "openai-chat-completions" - }); - input.providers[0]!.models.push({ - presetId: clientPresetId, - endpointId: "chat-duplicate", - model: "gpt-new", - source: "byok", - capabilities: ["agent"] - }); - input.modelAssignments.byok.agent = { - candidates: ["byok-agent", clientPresetId], - default: clientPresetId - }; + input.modelAssignments.byok.agent = { + candidates: ["byok-agent", clientPresetId], + default: clientPresetId + }; - const saved = await createHttpConfigClient(config).saveModelCatalog(input); + const saved = await createHttpConfigClient(config).saveModelCatalog(input); - expect(bodies).toHaveLength(2); - expect(bodies[0].providers[0].endpoints.some((endpoint: any) => endpoint.endpointId === "chat-duplicate")).toBe(true); - expect(bodies[1].providers[0].models.find((model: any) => model.model === "gpt-new")).toMatchObject({ - presetId: serverPresetId, - endpointId: "chat" - }); - expect(bodies[1].modelAssignments.byok.agent).toEqual({ - candidates: ["byok-agent", serverPresetId], - default: serverPresetId - }); - expect(saved.catalog?.modelAssignments.byok.agent.default).toBe(serverPresetId); - }); + expect(bodies).toHaveLength(2); + expect(bodies[0].providers[0].endpoints.some((endpoint: any) => endpoint.endpointId === "chat-duplicate")).toBe(true); + expect(bodies[1].providers[0].models.find((model: any) => model.model === "gpt-new")).toMatchObject({ + presetId: serverPresetId, + endpointId: "chat" + }); + expect(bodies[1].modelAssignments.byok.agent).toEqual({ + candidates: ["byok-agent", serverPresetId], + default: serverPresetId + }); + expect(saved.catalog?.modelAssignments.byok.agent.default).toBe(serverPresetId); + } + ); it("首次成功 GET 后只清理旧 workspace cache", async () => { const removeItem = vi.fn(); From f83cdf1283e369237726b44cf5b2bc9244247775 Mon Sep 17 00:00:00 2001 From: ZongYue Date: Wed, 14 Oct 2026 12:16:46 +0800 Subject: [PATCH 35/85] fix: build bundled agent and memory runtimes in Memmy --- App/memmy-agent/scripts/build-npm-package.mjs | 2 + App/memmy-agent/scripts/build-runtime.mjs | 64 ++++++ App/memmy-agent/src/runtime-main.ts | 39 ++++ Memory/src/cli/scripts/build-runtime.mjs | 13 +- package-lock.json | 1 + package.json | 4 +- scripts/internal/shared/bundle-runtime.mjs | 183 ++++++++++++++++++ scripts/internal/shared/npm-invocation.mjs | 15 ++ scripts/internal/shared/runtime-payload.mjs | 85 ++++++++ tests/runtime-bundle.test.ts | 124 ++++++++++++ 10 files changed, 527 insertions(+), 3 deletions(-) create mode 100644 App/memmy-agent/scripts/build-runtime.mjs create mode 100644 App/memmy-agent/src/runtime-main.ts create mode 100644 scripts/internal/shared/bundle-runtime.mjs create mode 100644 scripts/internal/shared/npm-invocation.mjs create mode 100644 scripts/internal/shared/runtime-payload.mjs create mode 100644 tests/runtime-bundle.test.ts diff --git a/App/memmy-agent/scripts/build-npm-package.mjs b/App/memmy-agent/scripts/build-npm-package.mjs index ef6432e0d..6ff720a20 100644 --- a/App/memmy-agent/scripts/build-npm-package.mjs +++ b/App/memmy-agent/scripts/build-npm-package.mjs @@ -1,4 +1,5 @@ import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { npmInvocation } from "../../../scripts/internal/shared/npm-invocation.mjs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; @@ -83,6 +84,7 @@ async function exists(path) { } function run(command, args, cwd) { + if (command === "npm") ({ command, args } = npmInvocation(args)); return new Promise((resolvePromise, reject) => { const child = spawn(command, args, { cwd, stdio: ["ignore", "pipe", "inherit"] }); let stdout = ""; diff --git a/App/memmy-agent/scripts/build-runtime.mjs b/App/memmy-agent/scripts/build-runtime.mjs new file mode 100644 index 000000000..4ca5bd08e --- /dev/null +++ b/App/memmy-agent/scripts/build-runtime.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +import { npmInvocation } from "../../../scripts/internal/shared/npm-invocation.mjs"; +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { cp, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; +import { spawnSync } from "node:child_process"; +import { bundleRuntime } from "../../../scripts/internal/shared/bundle-runtime.mjs"; + +if (Number(process.versions.node.split(".")[0]) < 22) throw new Error("Agent runtime requires Node 22 or newer."); + +const agentRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const { values } = parseArgs({ options: { + target: { type: "string" }, output: { type: "string" }, "skip-build": { type: "boolean" } +} }); +const hostTarget = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`; +const target = values.target ?? hostTarget; +// Native modules must be installed on the release runner for the target OS/architecture. +if (!/^(darwin|linux|windows)-(arm64|x64)$/.test(target) || target !== hostTarget) { + throw new Error(`Build ${target} on a matching runner (current: ${hostTarget}).`); +} +const outputRoot = resolve(values.output ?? join(agentRoot, "dist", "releases")); +const temporaryRoot = await mkdtemp(join(tmpdir(), "memmy-agent-runtime-")); +try { + if (!values["skip-build"]) run("npm", ["run", "build"], agentRoot); + run(process.execPath, [join(agentRoot, "scripts", "build-npm-package.mjs")], agentRoot); + const source = join(temporaryRoot, "staged"); + const packageRoot = join(source, "package"); + await mkdir(source, { recursive: true }); + await cp(join(agentRoot, "npm-packages", "memmy-agent"), packageRoot, { recursive: true }); + run("npm", ["install", "--omit=dev", "--no-audit", "--no-fund"], packageRoot); + const destination = join(temporaryRoot, "bundled"); + await bundleRuntime({ source, destination, packageDirectory: "package", kind: "agent", target: { platform: process.platform, arch: process.arch } }); + const manifest = JSON.parse(await readFile(join(packageRoot, "package.json"), "utf8")); + await writeJson(join(destination, "package", "agent-runtime.json"), { + name: "memmy-agent-runtime", version: manifest.version, formatVersion: 1, target, + entrypoint: "dist/main.js", nodeAbi: process.versions.modules + }); + await mkdir(outputRoot, { recursive: true }); + const name = `memmy-agent-runtime-${manifest.version}-${target}.tgz`; + const archive = join(outputRoot, name); + run("tar", ["-czf", archive, "-C", destination, "package"], agentRoot); + const hash = createHash("sha256"); + for await (const chunk of createReadStream(archive)) hash.update(chunk); + await writeJson(join(outputRoot, `agent-release-${target}.json`), { + version: manifest.version, assets: { [target]: { name, sha256: hash.digest("hex"), size: (await stat(archive)).size } } + }); + console.log(archive); +} finally { + await rm(temporaryRoot, { recursive: true, force: true }); +} + +function run(command, args, cwd) { + if (command === "npm") ({ command, args } = npmInvocation(args)); + const result = spawnSync(command, args, { cwd, stdio: "inherit", env: { ...process.env, PATH: `${dirname(process.execPath)}${process.platform === "win32" ? ";" : ":"}${process.env.PATH ?? ""}` } }); + if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} failed`); +} + +async function writeJson(file, value) { + await writeFile(file, `${JSON.stringify(value, null, 2)}\n`); +} diff --git a/App/memmy-agent/src/runtime-main.ts b/App/memmy-agent/src/runtime-main.ts new file mode 100644 index 000000000..5fc531d58 --- /dev/null +++ b/App/memmy-agent/src/runtime-main.ts @@ -0,0 +1,39 @@ +#!/usr/bin/env node +import "./load-env.js"; +import { serve, runInternalCommand } from "./entrypoints/cli/commands.js"; +import { prepareStartupMigrations } from "./entrypoints/cli/startup-migrations.js"; +import { ConfigError } from "./config/loader.js"; +import { VERSION } from "./version.js"; +import { parseArgs } from "node:util"; +if (await runInternalCommand(process.argv)) { + // Preserve internal browser preparation without bringing in gateway/onboarding. +} else if (["-V", "--version"].includes(process.argv[2])) console.log(VERSION); +else { + const args = process.argv.slice(2); + if (args[0] === "serve") args.shift(); + const { values } = parseArgs({ + args, + options: { + port: { type: "string", short: "p" }, + host: { type: "string", short: "H" }, + timeout: { type: "string", short: "t" }, + workspace: { type: "string", short: "w" }, + config: { type: "string", short: "c" }, + verbose: { type: "boolean", short: "v" }, + help: { type: "boolean", short: "h" }, + }, + }); + if (values.help) + console.log( + "Usage: memmy serve [--host HOST] [--port PORT] [--config FILE] [--workspace DIR] [--timeout SECONDS] [--verbose]", + ); + else + try { + await prepareStartupMigrations(values); + await serve(values); + } catch (error) { + if (!(error instanceof ConfigError)) throw error; + console.error("memmy: " + error.message); + process.exitCode = 1; + } +} diff --git a/Memory/src/cli/scripts/build-runtime.mjs b/Memory/src/cli/scripts/build-runtime.mjs index cafdede24..0aa3f607d 100755 --- a/Memory/src/cli/scripts/build-runtime.mjs +++ b/Memory/src/cli/scripts/build-runtime.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import { npmInvocation } from "../../../../scripts/internal/shared/npm-invocation.mjs"; import { createHash } from "node:crypto"; import { createReadStream, existsSync } from "node:fs"; import { cp, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; @@ -7,6 +8,7 @@ import { basename, dirname, join, resolve, sep } from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; import { createRequire } from "node:module"; +import { bundleRuntime } from "../../../../scripts/internal/shared/bundle-runtime.mjs"; const require = createRequire(import.meta.url); const scriptDir = dirname(fileURLToPath(import.meta.url)); @@ -65,9 +67,15 @@ try { builtAt: new Date().toISOString() }); + const bundledRoot = join(temporaryRoot, "bundled"); + await bundleRuntime({ source: runtimeRoot, destination: bundledRoot, packageDirectory: ".", kind: "memory", target: { platform, arch } }); + // Workspace code is in the bundle now; native libraries and model files remain external. + await verifyRuntimeDependencies(bundledRoot, target, platform, arch, []); + if (process.env.MEMMY_MEMORY_SKIP_EMBEDDING_MODEL !== "1") await verifyEmbeddingModel(bundledRoot); + await mkdir(outputRoot, { recursive: true }); const assetPath = join(outputRoot, assetName); - run("tar", ["-czf", assetPath, "-C", runtimeRoot, "."], repositoryRoot); + run("tar", ["-czf", assetPath, "-C", bundledRoot, "."], repositoryRoot); const descriptor = { name: assetName, sha256: await sha256File(assetPath), size: (await stat(assetPath)).size }; await updateReleaseManifest(outputRoot, version, target, descriptor); process.stdout.write(`${assetPath}\n`); @@ -113,7 +121,8 @@ function npmPlatformFlags(platform, arch) { } function run(command, args, cwd) { - const result = spawnSync(command, args, { cwd, stdio: "inherit", env: process.env, shell: process.platform === "win32" }); + if (command === "npm") ({ command, args } = npmInvocation(args)); + const result = spawnSync(command, args, { cwd, stdio: "inherit", env: process.env }); if (result.status !== 0) throw new Error(`${command} ${args.join(" ")} failed`); } diff --git a/package-lock.json b/package-lock.json index b7319fd43..97cebb149 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@vitest/coverage-v8": "^4.1.7", "concurrently": "^9.2.1", "dotenv": "^16.6.1", + "esbuild": "0.28.2", "eslint": "^9.39.4", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.32.0", diff --git a/package.json b/package.json index 84acc6884..0075fd2ed 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "typecheck": "npm run memory:lint && npm run workspace:typecheck", "test": "npm run test:release-workflow && npm run test:packaging-guards && npm run test:contracts && npm run memory:test && npm run workspace:test && npm run agent:test:tui-cursor", "test:release-workflow": "vitest run tests/release-workflow.test.ts tests/oss-crc64.test.mjs tests/windows-memory-workflow.test.ts", - "test:packaging-guards": "vitest run tests/package-version-guard.test.mjs tests/packaged-runtime-config.test.mjs tests/open-computer-use-packaging.test.mjs tests/computer-history-packaging.test.mjs tests/prune-packaged-runtime.test.mjs tests/package-logging.test.mjs", + "test:packaging-guards": "vitest run tests/package-version-guard.test.mjs tests/packaged-runtime-config.test.mjs tests/open-computer-use-packaging.test.mjs tests/computer-history-packaging.test.mjs tests/prune-packaged-runtime.test.mjs tests/package-logging.test.mjs tests/runtime-bundle.test.ts", "test:linux-cli": "vitest run tests/linux-cli-packaging.test.mjs tests/linux-open-computer-use-bundle.test.mjs tests/linux-computer-use-deps.test.mjs && npm --prefix App/memmy-agent exec vitest run tests/entrypoints/cli/linux-systemd-gateway.test.ts tests/entrypoints/cli/gateway-lifecycle.test.ts tests/entrypoints/cli/root-terminal-options.test.ts tests/entrypoints/cli/terminal-target.test.ts tests/tools/computer-use/mac-permission-settings.test.ts tests/tools/computer-use/mac-permission-preflight.test.ts && npm --prefix Memory test -- tests/cli-setup.test.ts", "serve": "npm run memory:serve", "serve:local": "npm run memory:serve:local", @@ -57,6 +57,7 @@ "memory:runtime:package": "npm run runtime:package -w @memmy/memory", "agent:package": "npm --prefix App/memmy-agent run package:npm", "agent:pack": "npm --prefix App/memmy-agent run pack:npm", + "agent:runtime:package": "node App/memmy-agent/scripts/build-runtime.mjs", "memory:dev": "npm run dev -w @memmy/memory", "memory:serve": "npm run serve -w @memmy/memory", "memory:serve:local": "npm run serve:local -w @memmy/memory", @@ -89,6 +90,7 @@ "@vitest/coverage-v8": "^4.1.7", "concurrently": "^9.2.1", "dotenv": "^16.6.1", + "esbuild": "0.28.2", "eslint": "^9.39.4", "eslint-import-resolver-typescript": "^4.4.4", "eslint-plugin-import": "^2.32.0", diff --git a/scripts/internal/shared/bundle-runtime.mjs b/scripts/internal/shared/bundle-runtime.mjs new file mode 100644 index 000000000..ff63bac8a --- /dev/null +++ b/scripts/internal/shared/bundle-runtime.mjs @@ -0,0 +1,183 @@ +import { builtinModules } from "node:module"; +import { existsSync } from "node:fs"; +import { copyFile, cp, mkdir, readFile, readdir, realpath, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { build, transform } from "esbuild"; +import { includeRuntimePath } from "./runtime-payload.mjs"; + +// These packages load native binaries, workers, or assets relative to their own files. +const EXTERNAL_PACKAGES = new Set([ + "better-sqlite3", "tiktoken", "sqlite-vec", "@huggingface/transformers", + "playwright", "playwright-core", "@playwright/mcp", "open-computer-use", + "pdfjs-dist", "typescript", "html-validate", "@tencent-connect/openclaw-qqbot", + "ink", "@mistralai/mistralai", "isomorphic-git", "commander", "nunjucks", + "iconv-lite", "glob", "@discordjs/ws", "thread-stream", "pino", + "@larksuiteoapi/node-sdk", "@matrix-org/matrix-sdk-crypto-wasm", "open", "botbuilder", "jsonc-parser" +]); +const BUILTINS = new Set(builtinModules.flatMap((name) => [name, `node:${name}`])); +const packageName = (specifier) => specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : specifier.split("/")[0]; +const posix = (value) => value.split(path.sep).join("/"); + + +function inside(root, file) { + const relative = path.relative(root, file); + if (relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error(`Runtime file escapes its staging directory: ${file}`); + } + return relative; +} + +function findPackage(name, directory, root) { + for (let current = directory; ; current = path.dirname(current)) { + const candidate = path.join(current, "node_modules", ...name.split("/")); + if (existsSync(path.join(candidate, "package.json"))) return candidate; + if (current === root || path.dirname(current) === current) return undefined; + } +} + +// Keep entry paths for subprocesses and dynamic imports, but share bundled dependencies. +export async function bundleRuntime({ source, destination, packageDirectory, kind, target = {} }) { + source = await realpath(source); + destination = path.resolve(destination); + const separation = path.relative(source, destination); + if (!separation || (!separation.startsWith(`..${path.sep}`) && separation !== ".." && !path.isAbsolute(separation))) { + throw new Error("Runtime destination must be outside the staging directory."); + } + const packageRoot = path.join(source, packageDirectory); + inside(source, packageRoot); + const manifest = JSON.parse(await readFile(path.join(packageRoot, "package.json"), "utf8")); + const codeRoot = path.join(packageRoot, "dist"); + const outputRoot = path.join(destination, packageDirectory, "dist"); + const entryPoints = []; + const assets = []; + const externalRoots = new Set(); + const moduleUrls = new Map(); + const externalPaths = new Map(); + const copyOptions = { ...target, prune: true }; + + const collect = async (directory) => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const file = path.join(directory, entry.name); + const relative = posix(path.relative(packageRoot, file)); + if (!includeRuntimePath(relative, copyOptions) || entry.name === "node_modules" || entry.name === "vendor" || entry.name === "package-lock.json") continue; + if (entry.isDirectory()) await collect(file); + else if (entry.isFile()) { + const code = path.relative(codeRoot, file); + // Skills and integration templates contain programs emitted as data, not server modules. + if (file.endsWith(".js") && !code.startsWith("..") && !/(?:^|\/)(?:skills|viewer|templates)(?:\/|$)/u.test(posix(code))) { + const entry = posix(code); + if (kind === "agent" ? entry === "runtime-main.js" || /^tools\/(?:computer-history\/mac\/(?:record-human-history|summarize-history)|computer-use\/extract-workflow-candidate)\.js$/u.test(entry) + : ["src/server/index.js", "src/cli/index.js"].includes(entry)) entryPoints.push({ in: file, out: kind === "agent" && entry === "runtime-main.js" ? "main" : entry.slice(0, -3) }); + } + else if (!file.endsWith(".map") && !/\.d\.[cm]?ts$/u.test(file)) assets.push(file); + } + } + }; + // Runtime assets live here; src, vendor tarballs and build projects are not shipped. + for (const directory of ["dist", ...(kind === "memory" ? ["embedding-models", "adapters"] : [])]) { + const directoryPath = path.join(packageRoot, directory); + if (existsSync(directoryPath)) await collect(directoryPath); + } + if (!entryPoints.length) throw new Error(`No compiled ${kind} runtime entrypoints in ${codeRoot}.`); + + // These modules are resolved by strings passed to workers or child processes. + const dynamicPackages = kind === "agent" ? ["isomorphic-git", "@playwright/mcp", "open-computer-use"] : []; + for (const name of dynamicPackages) { + const directory = findPackage(name, packageRoot, source); + if (!directory) throw new Error(`Missing dynamic runtime dependency: ${name}`); + externalRoots.add(directory); + } + + const result = await build({ + entryPoints, outdir: outputRoot, outbase: codeRoot, entryNames: "[dir]/[name]", + chunkNames: "_chunks/[name]-[hash]", bundle: true, splitting: true, + platform: "node", target: "node22", format: "esm", keepNames: true, + write: false, metafile: true, logLevel: "silent", charset: "utf8", + banner: { js: 'import { createRequire as __memmyCreateRequire } from "node:module"; import { fileURLToPath as __memmyFileURLToPath } from "node:url"; const require = __memmyCreateRequire(import.meta.url);' }, + plugins: [{ name: "runtime-layout", setup(builder) { + builder.onResolve({ filter: /^[^./]|^@/ }, async (args) => { + if (args.pluginData?.runtimeResolved || BUILTINS.has(args.path) || !EXTERNAL_PACKAGES.has(packageName(args.path))) return; + const resolved = await builder.resolve(args.path, { resolveDir: args.resolveDir, kind: args.kind, pluginData: { runtimeResolved: true } }); + if (resolved.errors.length) return { errors: resolved.errors }; + const directory = findPackage(packageName(args.path), args.resolveDir, source); + if (!directory) throw new Error(`Cannot locate external runtime dependency: ${args.path}`); + inside(source, resolved.path); + externalPaths.set(resolved.path, { directory, relocated: path.join(destination, path.relative(source, resolved.path)) }); + return { path: resolved.path, external: true }; + }); + builder.onLoad({ filter: /\.[cm]?js$/ }, async (args) => { + if (!args.path.startsWith(`${packageRoot}${path.sep}`) || path.relative(packageRoot, args.path).split(path.sep).includes("node_modules")) return; + const contents = await readFile(args.path, "utf8"); + if (!contents.includes("import.meta.url")) return { contents, loader: "js", resolveDir: path.dirname(args.path) }; + const marker = `memmy-module:${posix(path.relative(source, args.path))}`; + moduleUrls.set(marker, path.join(destination, path.relative(source, args.path))); + const transformed = await transform(contents, { loader: "js", target: "node22", define: { "import.meta.url": JSON.stringify(marker) } }); + return { contents: transformed.code, loader: "js", resolveDir: path.dirname(args.path) }; + }); + } }] + }); + + for (const file of result.outputFiles) { + let contents = file.text; + for (const [marker, original] of moduleUrls) { + const relative = posix(path.relative(path.dirname(file.path), original)).split("/").map(encodeURIComponent).join("/"); + contents = contents.replaceAll(JSON.stringify(marker), `new URL(${JSON.stringify(relative)}, import.meta.url).href`); + } + for (const [original, { relocated }] of externalPaths) { + let relative = posix(path.relative(path.dirname(file.path), relocated)); + if (!relative.startsWith(".")) relative = `./${relative}`; + relative = relative.split("/").map(encodeURIComponent).join("/"); + // esbuild shares its CJS require helper between chunks; resolve from the caller. + contents = contents.replaceAll(`__require(${JSON.stringify(original)})`, `__require(__memmyFileURLToPath(new URL(${JSON.stringify(relative)}, import.meta.url)))`); + contents = contents.replaceAll(JSON.stringify(original), JSON.stringify(relative)); + } + await mkdir(path.dirname(file.path), { recursive: true }); + await writeFile(file.path, contents); + } + if (kind === "memory" && existsSync(path.join(packageRoot, "memory-runtime.json"))) assets.push(path.join(packageRoot, "memory-runtime.json")); + for (const file of assets) { + const output = path.join(destination, inside(source, file)); + await mkdir(path.dirname(output), { recursive: true }); + await copyFile(file, output); + } + // This is a launchable runtime, not the original SDK/development npm package. + const runtimeManifest = Object.fromEntries(["name", "version", "type", "bin", "engines", "license"].filter((key) => manifest[key] !== undefined).map((key) => [key, manifest[key]])); + await writeFile(path.join(destination, packageDirectory, "package.json"), `${JSON.stringify({ ...runtimeManifest, private: true }, null, 2)}\n`); + + const copied = new Set(); + const copyPackage = async (directory) => { + inside(source, await realpath(directory)); + if (copied.has(directory)) return; + copied.add(directory); + const manifest = JSON.parse(await readFile(path.join(directory, "package.json"), "utf8")); + await cp(directory, path.join(destination, inside(source, directory)), { + recursive: true, dereference: true, + filter: (file) => { + const relative = path.relative(directory, file); + return !relative.split(path.sep).includes("node_modules") && includeRuntimePath(path.relative(source, file), copyOptions); + } + }); + const optional = { ...manifest.optionalDependencies, ...manifest.peerDependencies }; + for (const name of new Set([...Object.keys(manifest.dependencies ?? {}), ...Object.keys(optional)])) { + const dependency = findPackage(name, directory, source); + if (dependency) await copyPackage(dependency); + else if (!Object.hasOwn(optional, name)) throw new Error(`Missing runtime dependency ${name} required by ${manifest.name}.`); + } + }; + for (const output of Object.values(result.metafile.outputs)) { + for (const imported of output.imports) { + const external = externalPaths.get(imported.path); + if (external) externalRoots.add(external.directory); + } + } + for (const directory of externalRoots) await copyPackage(directory); + // Keep installed optional modules referenced from bundled code (e.g. ws native accelerators). + for (const output of Object.values(result.metafile.outputs)) { + for (const imported of output.imports) { + if (!imported.external || BUILTINS.has(imported.path) || path.isAbsolute(imported.path)) continue; + const directory = findPackage(packageName(imported.path), packageRoot, source); + if (directory) await copyPackage(directory); + } + } + return { modules: Object.keys(result.metafile.inputs).length, bundles: result.outputFiles.length, externalPackages: copied.size }; +} diff --git a/scripts/internal/shared/npm-invocation.mjs b/scripts/internal/shared/npm-invocation.mjs new file mode 100644 index 000000000..cb38c0c88 --- /dev/null +++ b/scripts/internal/shared/npm-invocation.mjs @@ -0,0 +1,15 @@ +import { existsSync } from "node:fs"; +import { basename, dirname, join, resolve } from "node:path"; + +// Invoke npm through Node so Windows never passes package paths through cmd.exe. +export function npmInvocation(args, { execPath = process.execPath, env = process.env, platform = process.platform } = {}) { + const candidates = [ + env.npm_execpath && basename(env.npm_execpath) === "npm-cli.js" ? env.npm_execpath : undefined, + join(dirname(execPath), "node_modules", "npm", "bin", "npm-cli.js"), + resolve(dirname(execPath), "../lib/node_modules/npm/bin/npm-cli.js") + ]; + const cli = candidates.find((file) => file && existsSync(file)); + if (cli) return { command: execPath, args: [cli, ...args] }; + if (platform === "win32") throw new Error("Cannot locate npm-cli.js; run packaging with a Node installation that includes npm."); + return { command: "npm", args }; +} diff --git a/scripts/internal/shared/runtime-payload.mjs b/scripts/internal/shared/runtime-payload.mjs new file mode 100644 index 000000000..77bbe6161 --- /dev/null +++ b/scripts/internal/shared/runtime-payload.mjs @@ -0,0 +1,85 @@ +import path from "node:path"; + +// `doc` and `docs` are not skipped: the yaml package loads dist/doc/*.js at runtime. +const NON_RUNTIME_DIR = /^(?:test|tests|__tests__|__mocks__|example|examples|\.github|coverage|benchmark|benchmarks)$/i; +const NON_RUNTIME_DOC = /^(?:readme|changelog|history|contributing|code_of_conduct|security)(?:\.[a-z0-9]+)?$/i; +const NON_RUNTIME_FILE = /\.(?:map|d\.ts|d\.mts|d\.cts|ts|tsx|mts|cts|scss|sass|less|flow|tsbuildinfo|c|cc|cpp|h|hpp)$/i; +const NATIVE_PACKAGE = /(?:^|[-.])(darwin|linux|win32|windows|android|freebsd|openbsd)[-.](x64|arm64|ia32|arm|armv7l|universal|x86|mips64el|ppc64|s390x)(?:[-.](?:gnu|musl|msvc|eabi))?$/i; + +const packageTarget = (options = {}) => { + const raw = options.platform ?? process.platform; + const platform = raw === "mas" || raw === "darwin" ? "darwin" : raw === "windows" ? "win32" : raw; + return { platform, arch: options.arch ?? process.arch }; +}; + +const isForeignNativeName = (name, target) => { + const matched = NATIVE_PACKAGE.exec(name); + if (!matched) return false; + const platform = matched[1].toLowerCase() === "windows" ? "win32" : matched[1].toLowerCase(); + return platform !== target.platform || matched[2].toLowerCase() !== target.arch; +}; + +const shouldOmitPackagedDirectory = (relative, target) => { + if (omitPackagedPayload(relative)) return true; + const parts = String(relative).replaceAll("\\", "/").split("/").filter(Boolean); + if (parts.some((part) => NON_RUNTIME_DIR.test(part) || isForeignNativeName(part, target))) return true; + const napi = parts.indexOf("napi-v3"); + if (napi < 0 || !parts[napi + 1]) return false; + const platform = parts[napi + 1].toLowerCase(); + const arch = (parts[napi + 2] ?? "").toLowerCase(); + if (["darwin", "linux", "win32"].includes(platform) && platform !== target.platform) return true; + return ["x64", "arm64", "ia32"].includes(arch) && arch !== target.arch; +}; + +const omitNonRuntimePackagedRelative = (relative, options = {}) => { + const target = packageTarget(options); + const normalized = String(relative).replaceAll("\\", "/"); + if (shouldOmitPackagedDirectory(normalized, target)) return true; + const name = path.posix.basename(normalized); + if (NON_RUNTIME_DOC.test(name) || NON_RUNTIME_FILE.test(name)) return true; + if (shouldOmitPackagedDirectory(path.posix.dirname(normalized), target)) return true; + const extension = path.posix.extname(name).toLowerCase(); + if (target.platform === "win32" && (extension === ".dylib" || extension === ".so")) return true; + if (target.platform === "darwin" && (extension === ".dll" || extension === ".exe")) return true; + if (target.platform === "linux" && (extension === ".dylib" || extension === ".dll" || extension === ".exe")) return true; + return false; +}; + +// Installer payload that the Node runtime does not load. +function omitPackagedPayload(relative) { + const parts = String(relative ?? "").replaceAll("\\", "/").split("/").filter(Boolean); + if (parts.length === 0) return false; + const web = parts.indexOf("onnxruntime-web"); + if (web > 0 && parts[web - 1] === "node_modules") return true; + const openclaw = parts.indexOf("openclaw"); + // yaml loads dist/doc/*.js. Only the OpenClaw documentation tree is unused. + if (openclaw >= 0 && parts[openclaw + 1] === "docs") return true; + const sqlite = parts.lastIndexOf("better-sqlite3"); + if (sqlite > 0 && parts[sqlite - 1] === "node_modules") { + const rest = parts.slice(sqlite + 1).join("/"); + if (rest === "" || rest === "package.json" || rest === "lib" || rest.startsWith("lib/")) return false; + if (rest === "build" || rest === "build/Release" || rest === "build/Release/better_sqlite3.node") return false; + if (rest === "deps" || rest.startsWith("deps/") || rest === "src" || rest.startsWith("src/") || rest.startsWith("build/")) return true; + } + const tiktoken = parts.lastIndexOf("tiktoken"); + if (tiktoken > 0 && parts[tiktoken - 1] === "node_modules") { + const child = parts[tiktoken + 1]; + // get_encoding() reads ranks from tiktoken_bg.wasm, not encoders/ or lite/. + if (child === "encoders" || child === "lite") return true; + } + if (parts.at(-2) === "dist" && parts.at(-3) === "transformers" && parts.at(-4) === "@huggingface" && parts.at(-5) === "node_modules") { + const file = parts.at(-1); + if (file.endsWith(".map") || file.endsWith(".wasm")) return true; + // Node resolves transformers.node.*; the browser bundles and their wasm are unused. + if (/^transformers(?!\.node\.)/u.test(file)) return true; + } + return false; +} + +export function includeRuntimePath(relative, options = {}) { + const normalized = String(relative).replaceAll("\\", "/"); + const parts = normalized.split("/"); + if (parts.some((part) => part === ".git" || part === ".DS_Store" || part === "__MACOSX" || part.startsWith("._"))) return false; + if (["package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml"].includes(parts.at(-1))) return false; + return !omitNonRuntimePackagedRelative(normalized, options); +} diff --git a/tests/runtime-bundle.test.ts b/tests/runtime-bundle.test.ts new file mode 100644 index 000000000..fac9ddf4a --- /dev/null +++ b/tests/runtime-bundle.test.ts @@ -0,0 +1,124 @@ +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, expect, it } from "vitest"; +import { bundleRuntime } from "../scripts/internal/shared/bundle-runtime.mjs"; + +const roots: string[] = []; +afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); }); +async function fixture() { + const root = await mkdtemp(path.join(os.tmpdir(), "runtime bundle #")); + roots.push(root); + const source = path.join(root, "source"); + const put = async (file: string, contents: string) => { + const target = path.join(source, file); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, contents); + }; + return { root, source, put, destination: path.join(root, "output") }; +} + +it("runs relocated Memory entries without staging, retaining resources and exact external dependency versions", async () => { + const { root, source, destination, put } = await fixture(); + await put("package/package.json", JSON.stringify({ type: "module" })); + await put("package/dist/src/server/index.js", `import { readFileSync } from 'node:fs'; + import value from 'demo'; import sqlite from 'better-sqlite3'; + console.log(JSON.stringify([value, sqlite, readFileSync(new URL('./message%20%231.txt', import.meta.url), 'utf8')]));`); + await put("package/dist/src/server/message #1.txt", "asset"); + await put("package/dist/src/cli/index.js", `import value from 'demo'; console.log(value);`); + await put("package/node_modules/demo/package.json", JSON.stringify({ name: "demo", main: "index.cjs" })); + await put("package/node_modules/demo/index.cjs", `module.exports = require('glob');`); + await put("package/node_modules/demo/node_modules/glob/package.json", JSON.stringify({ name: "glob", main: "index.cjs" })); + await put("package/node_modules/demo/node_modules/glob/index.cjs", `module.exports = 'nested-version';`); + await put("package/node_modules/glob/package.json", JSON.stringify({ name: "glob", main: "index.cjs" })); + await put("package/node_modules/glob/index.cjs", `module.exports = 'wrong-version';`); + await put("package/node_modules/better-sqlite3/package.json", JSON.stringify({ name: "better-sqlite3", main: "lib/index.cjs", dependencies: { helper: "1" } })); + await put("package/node_modules/better-sqlite3/lib/index.cjs", `module.exports = require('helper');`); + await put("package/node_modules/helper/package.json", JSON.stringify({ name: "helper", main: "index.cjs" })); + await put("package/node_modules/helper/index.cjs", `module.exports = 'external-dependency';`); + await put("package/node_modules/better-sqlite3/build/Release/better_sqlite3.node", "native-fixture"); + await put("package/node_modules/unused/index.js", "unused"); + await put("package/src/server.ts", "source-only"); + await put("package/dist/src/server/index.js.map", "{}"); + await bundleRuntime({ source, destination, packageDirectory: "package", kind: "memory" }); + const relocated = path.join(root, "relocated # runtime"); + await rename(destination, relocated); + await rm(source, { recursive: true }); + const run = (entry: string) => execFileSync(process.execPath, [path.join(relocated, "package/dist/src", entry)], { encoding: "utf8" }).trim(); + expect(JSON.parse(run("server/index.js"))).toEqual(["nested-version", "external-dependency", "asset"]); + expect(run("cli/index.js")).toBe("nested-version"); + await expect(readFile(path.join(relocated, "package/node_modules/better-sqlite3/build/Release/better_sqlite3.node"), "utf8")).resolves.toBe("native-fixture"); + for (const file of ["src/server.ts", "node_modules/unused/index.js", "node_modules/demo/index.cjs", "dist/src/server/index.js.map"]) { + await expect(readFile(path.join(relocated, "package", file))).rejects.toThrow(); + } +}); + +it("packages the Agent serve entry and its dynamic tools without the standalone gateway", async () => { + const { source, destination, put } = await fixture(); + const pkg = "node_modules/@memtensor/memmy-agent"; + await put(`${pkg}/package.json`, JSON.stringify({ type: "module", bin: { memmy: "dist/main.js" } })); + await put(`${pkg}/dist/runtime-main.js`, await readFile(new URL("../App/memmy-agent/src/runtime-main.ts", import.meta.url), "utf8")); + await put(`${pkg}/dist/main.js`, `throw new Error('standalone CLI must not run');`); + await put(`${pkg}/dist/load-env.js`, "export {};"); + await put(`${pkg}/dist/config/loader.js`, "export class ConfigError extends Error {}"); + await put(`${pkg}/dist/version.js`, `export const VERSION = '1.2.1';`); + await put(`${pkg}/dist/entrypoints/cli/startup-migrations.js`, `export async function prepareStartupMigrations() { globalThis.migrationsReady = true; }`); + await put(`${pkg}/dist/entrypoints/cli/commands.js`, `export async function serve(options) { if (!globalThis.migrationsReady) throw new Error('Migrations skipped'); console.log(JSON.stringify(options)); } + export async function runInternalCommand(argv) { if (argv[2] === 'internal') { console.log('browser-prepare'); return true; } return false; } + export async function gateway() { return import('unused-gateway'); }`); + await put(`${pkg}/node_modules/unused-gateway/package.json`, JSON.stringify({ main: "index.js" })); + await put(`${pkg}/node_modules/unused-gateway/index.js`, `throw new Error('unused gateway');`); + for (const name of ["isomorphic-git", "@playwright/mcp", "open-computer-use"]) { + await put(`${pkg}/node_modules/${name}/package.json`, JSON.stringify({ name, main: "index.js" })); + await put(`${pkg}/node_modules/${name}/index.js`, `module.exports = ${JSON.stringify(name)};`); + } + await bundleRuntime({ source, destination, packageDirectory: pkg, kind: "agent" }); + await rm(source, { recursive: true }); + const output = execFileSync(process.execPath, [path.join(destination, pkg, "dist/main.js"), "serve", "--config", "config.json", "-t", "30"], { encoding: "utf8" }); + expect(JSON.parse(output)).toEqual({ config: "config.json", timeout: "30" }); + expect(execFileSync(process.execPath, [path.join(destination, pkg, "dist/main.js"), "internal", "browser-prepare"], { encoding: "utf8" }).trim()).toBe("browser-prepare"); + expect(execFileSync(process.execPath, [path.join(destination, pkg, "dist/main.js"), "--version"], { encoding: "utf8" }).trim()).toBe("1.2.1"); + await expect(readFile(path.join(destination, pkg, "node_modules/@playwright/mcp/index.js"), "utf8")).resolves.toContain("@playwright/mcp"); + await expect(readFile(path.join(destination, pkg, "node_modules/unused-gateway/index.js"))).rejects.toThrow(); +}); + +it("fails packaging when an external package's required dependency is missing", async () => { + const { source, destination, put } = await fixture(); + await put("package/package.json", JSON.stringify({ type: "module" })); + await put("package/dist/src/server/index.js", `import sqlite from 'better-sqlite3'; console.log(sqlite);`); + await put("package/node_modules/better-sqlite3/package.json", JSON.stringify({ main: "lib/index.js", dependencies: { missing: "1" } })); + await put("package/node_modules/better-sqlite3/lib/index.js", `module.exports = require('missing');`); + await expect(bundleRuntime({ source, destination, packageDirectory: "package", kind: "memory" })).rejects.toThrow("Missing runtime dependency missing"); +}); + +it("keeps native runtime assets for Windows while excluding build material", async () => { + const { includeRuntimePath } = await import("../scripts/internal/shared/runtime-payload.mjs"); + const target = { platform: "windows", arch: "x64" }; + for (const file of [ + "node_modules/yaml/dist/doc/Document.js", + "node_modules/better-sqlite3/build/Release/better_sqlite3.node", + "node_modules/onnxruntime-node/bin/napi-v3/win32/x64/onnxruntime.dll", + "node_modules/tiktoken/tiktoken_bg.wasm", + "dist/skills/computer-history/SKILL.md" + ]) expect(includeRuntimePath(file, target), file).toBe(true); + for (const file of [ + "node_modules/better-sqlite3/build/Release/obj/better_sqlite3.o", + "node_modules/onnxruntime-node/bin/napi-v3/darwin/arm64/onnxruntime_binding.node", + "node_modules/sqlite-vec-windows-arm64/vec0.dll", + "node_modules/demo/tests/test.js", + "dist/main.js.map", "dist/main.d.ts", "package-lock.json" + ]) expect(includeRuntimePath(file, target), file).toBe(false); +}); + + +it("invokes Windows npm with Node and preserves paths containing spaces", async () => { + const { root } = await fixture(); + const { npmInvocation } = await import("../scripts/internal/shared/npm-invocation.mjs"); + const executable = path.join(root, "Program Files", "node.exe"); + const cli = path.join(root, "Program Files", "node_modules/npm/bin/npm-cli.js"); + await mkdir(path.dirname(cli), { recursive: true }); + await writeFile(cli, ""); + const args = ["pack", "C:\\Users\\Name With Spaces\\package"]; + expect(npmInvocation(args, { execPath: executable, env: {}, platform: "win32" })).toEqual({ command: executable, args: [cli, ...args] }); +}); From 248bbafab8e147790e940903a1a158490e56c103 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Sat, 10 Oct 2026 10:52:11 +0800 Subject: [PATCH 36/85] fix: deduplicate native source captures across account changes --- .../service/session/session-turn-service.ts | 16 +++- Memory/src/storage/polardb.ts | 8 ++ Memory/src/storage/repositories.ts | 15 ++-- Memory/src/storage/schema.ts | 8 ++ .../tests/contract/source-turn-http.test.ts | 24 +++--- Memory/tests/native-scanner-lifecycle.test.ts | 8 +- .../repository/source-turn-schema.test.ts | 21 +++++- .../session/source-turn-capture.test.ts | 75 ++++++++++++++++++- 8 files changed, 147 insertions(+), 28 deletions(-) diff --git a/Memory/src/service/session/session-turn-service.ts b/Memory/src/service/session/session-turn-service.ts index 181d12d5d..811771711 100644 --- a/Memory/src/service/session/session-turn-service.ts +++ b/Memory/src/service/session/session-turn-service.ts @@ -1261,8 +1261,8 @@ export class SessionTurnService { toolCalls: normalizeCompleteTurnToolCalls(normalized), toolResults: normalizeCompleteTurnToolResults(normalized) }); return this.deps.repos.transaction(() => { - // A durable source identity is checked before touching an open Session. Deleted memories - // and closed Sessions therefore cannot turn a retry into a second capture. + // Native captures belong to the local source conversation, not the cloud account + // currently sending them. Check before opening a Session or scheduling any work. const existing = this.deps.repos.runtime.getSourceTurnCapture(scope, identity.turnId); if (existing) { if (existing.contentHash !== contentHash) { @@ -1310,6 +1310,12 @@ export class SessionTurnService { const adoptedImport = this.adoptLegacyImport(request, namespace, normalized, scope, contentHash); if (adoptedImport) return adoptedImport; const previous = this.deps.repos.runtime.latestSourceTurnCapture(scope); + if (previous) { + namespace.userId = previous.userId; + scope.userId = previous.userId; + } + normalized.namespace = namespace; + request = { ...request, namespace }; let gapEpisode: EpisodeRecord | undefined; let targetSessionId = previous?.sessionId; const legacyBackfill = allowsLegacyHistoryCapture(request); @@ -1347,6 +1353,10 @@ export class SessionTurnService { : resolved; } const session = resolved as SessionRecord; + // A scan may be the first completion of a Hook Session opened by another account. + // Keep the Session owner and all persisted capture references consistent. + namespace.userId = session.userId; + scope.userId = session.userId; const observed = this.deps.repos.runtime.getRawTurnBySessionTurn(session.id, identity.turnId); if (observed && this.deps.repos.runtime.getEpisode(observed.episodeId)?.status !== "open") { return detachLegacyHistory("source_episode_closed"); @@ -1581,7 +1591,7 @@ export class SessionTurnService { ): SessionRecord | { status: "pending"; reason: string } { const namespace = normalizeNamespace(request.namespace); const inScope = (candidate: SessionRecord): boolean => - candidate.userId === namespace.userId && candidate.source === identity.source && candidate.profileId === identity.profileId && + candidate.source === identity.source && candidate.profileId === identity.profileId && (!candidate.conversationId || candidate.conversationId === identity.conversationId) && (candidate.hostSessionKey === identity.conversationId || candidate.conversationId === identity.conversationId || candidate.hostSessionKey === `${identity.source}-memory-${identity.conversationId}`) && diff --git a/Memory/src/storage/polardb.ts b/Memory/src/storage/polardb.ts index be44cd7e0..123e23918 100644 --- a/Memory/src/storage/polardb.ts +++ b/Memory/src/storage/polardb.ts @@ -336,6 +336,14 @@ export function polardbMigrationSql(): string[] { )`, `CREATE INDEX IF NOT EXISTS idx_source_turn_captures_conversation ON source_turn_captures (user_id, source, profile_id, namespace_key, conversation_id, completed_at DESC)`, + `CREATE INDEX IF NOT EXISTS idx_source_turn_captures_native_turn + ON source_turn_captures (source, profile_id, namespace_key, conversation_id, turn_id)`, + `CREATE INDEX IF NOT EXISTS idx_source_turn_captures_native_conversation + ON source_turn_captures (source, profile_id, namespace_key, conversation_id, completed_at DESC)`, + `CREATE INDEX IF NOT EXISTS idx_source_sessions_native_conversation + ON sessions (source, profile_id, conversation_id)`, + `CREATE INDEX IF NOT EXISTS idx_source_sessions_native_host_key + ON sessions (source, profile_id, host_session_key)`, `CREATE TABLE IF NOT EXISTS runtime_kv ( key TEXT PRIMARY KEY, diff --git a/Memory/src/storage/repositories.ts b/Memory/src/storage/repositories.ts index dee1bb81f..d76fcd38b 100644 --- a/Memory/src/storage/repositories.ts +++ b/Memory/src/storage/repositories.ts @@ -1847,17 +1847,20 @@ export class RuntimeRepository { })); } + // userId is capture provenance. It must not partition the same local native turn + // when an installed Hook and the desktop scanner submit under different accounts. getSourceTurnCapture(scope: SourceTurnCaptureScope, turnId: string): SourceTurnCaptureRecord | undefined { const row = this.db.prepare(`SELECT * FROM source_turn_captures - WHERE user_id = @userId AND source = @source AND profile_id = @profileId - AND namespace_key = @namespaceKey AND conversation_id = @conversationId AND turn_id = @turnId` + WHERE source = @source AND profile_id = @profileId + AND namespace_key = @namespaceKey AND conversation_id = @conversationId AND turn_id = @turnId + ORDER BY created_at ASC, user_id ASC LIMIT 1` ).get({ ...scope, turnId }) as Record | undefined; return row ? sourceTurnCaptureFromSql(row) : undefined; } latestSourceTurnCapture(scope: SourceTurnCaptureScope): SourceTurnCaptureRecord | undefined { const row = this.db.prepare(`SELECT * FROM source_turn_captures - WHERE user_id = @userId AND source = @source AND profile_id = @profileId + WHERE source = @source AND profile_id = @profileId AND namespace_key = @namespaceKey AND conversation_id = @conversationId AND session_id IS NOT NULL ORDER BY completed_at DESC, source_sequence DESC LIMIT 1` ).get(scope) as Record | undefined; @@ -1869,7 +1872,7 @@ export class RuntimeRepository { after?: SourceTurnCaptureRecord; } { const prefix = `SELECT * FROM source_turn_captures - WHERE user_id = @userId AND source = @source AND profile_id = @profileId + WHERE source = @source AND profile_id = @profileId AND namespace_key = @namespaceKey AND conversation_id = @conversationId AND session_id IS NOT NULL`; const before = this.db.prepare(`${prefix} AND started_at < @startedAt ORDER BY started_at DESC LIMIT 1`) .get({ ...scope, startedAt }) as Record | undefined; @@ -1906,8 +1909,8 @@ export class RuntimeRepository { .run(toJson(rawTurnIds), toJson(l1MemoryIds), episodeId); } - sourceConversationSessions(input: { userId: string; source: string; profileId: string; conversationId: string }): SessionRecord[] { - return (this.db.prepare(`SELECT * FROM sessions WHERE user_id = @userId AND source = @source + sourceConversationSessions(input: { userId?: string; source: string; profileId: string; conversationId: string }): SessionRecord[] { + return (this.db.prepare(`SELECT * FROM sessions WHERE source = @source AND profile_id = @profileId AND (host_session_key = @conversationId OR conversation_id = @conversationId OR host_session_key = @source || '-memory-' || @conversationId) ORDER BY opened_at DESC`).all(input) as SqlSessionRow[]).map(sessionFromSql); diff --git a/Memory/src/storage/schema.ts b/Memory/src/storage/schema.ts index 9755c4b92..3cb76e0c4 100644 --- a/Memory/src/storage/schema.ts +++ b/Memory/src/storage/schema.ts @@ -529,6 +529,14 @@ const statements = [ )`, `CREATE INDEX IF NOT EXISTS idx_source_turn_captures_conversation ON source_turn_captures (user_id, source, profile_id, namespace_key, conversation_id, completed_at DESC)`, + `CREATE INDEX IF NOT EXISTS idx_source_turn_captures_native_turn + ON source_turn_captures (source, profile_id, namespace_key, conversation_id, turn_id)`, + `CREATE INDEX IF NOT EXISTS idx_source_turn_captures_native_conversation + ON source_turn_captures (source, profile_id, namespace_key, conversation_id, completed_at DESC)`, + `CREATE INDEX IF NOT EXISTS idx_source_sessions_native_conversation + ON sessions (source, profile_id, conversation_id)`, + `CREATE INDEX IF NOT EXISTS idx_source_sessions_native_host_key + ON sessions (source, profile_id, host_session_key)`, `CREATE TABLE IF NOT EXISTS memory_capture_claims ( user_id TEXT NOT NULL, diff --git a/Memory/tests/contract/source-turn-http.test.ts b/Memory/tests/contract/source-turn-http.test.ts index 34f070f86..1ab9d8088 100644 --- a/Memory/tests/contract/source-turn-http.test.ts +++ b/Memory/tests/contract/source-turn-http.test.ts @@ -40,10 +40,10 @@ describe("source turn HTTP contract", () => { } finally { await new Promise((resolve) => server.close(() => resolve())); } }); - it.each(["hook", "agent_source_scan"] as const)("uses the configured local owner and existing Hook Session when %s arrives first", async (channel) => { + it.each(["hook", "agent_source_scan", "concurrent"] as const)("reuses the installed Hook owner after account switch when %s arrives first", async (channel) => { const { service, db } = createTestService({ config: { ...DEFAULT_MEMMY_CONFIG, userId: "configured-owner" } }); const opened = service.openSession({ - source: "codex", namespace: { source: "codex", profileId: "default", userId: "configured-owner", sessionKey: "codex-memory-local-conversation" }, + source: "codex", namespace: { source: "codex", profileId: "default", userId: "installed-owner", sessionKey: "codex-memory-local-conversation" }, l3WorldModelProtocolVersion: 2, l3WorldModelTransition: "allow_legacy_rollover", workspaceUri: "file:///fixture-project", workspaceHostId: "a".repeat(64) }); @@ -55,26 +55,30 @@ describe("source turn HTTP contract", () => { startedAt: "2099-01-01T10:00:00.000Z", completedAt: "2099-01-01T10:01:00.000Z", sequence: 1, completionEvidence: "final_answer:local-turn", query: "Fix the source capture user and Session identity.", answer: "The source capture reuses the existing user and Session identity.", status: "succeeded", toolCalls: [], toolResults: [] - }, channel); + }, channel === "concurrent" ? "hook" : channel); const submit = async (source: "hook" | "agent_source_scan") => { const response = await fetch(`http://127.0.0.1:${address.port}/api/v1/source-turns/complete`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...payload, channel: source, - ...(source === "hook" ? { sessionId: opened.sessionId, namespace: { userId: "configured-owner" } } : {}) }) + ...(source === "hook" ? { sessionId: opened.sessionId, namespace: { userId: "installed-owner" } } : {}) }) }); expect(response.status).toBe(200); return response.json() as Promise<{ status: string; result: { sessionId: string; l1MemoryId: string } }>; }; try { - const first = await submit(channel); - const second = await submit(channel === "hook" ? "agent_source_scan" : "hook"); - expect(first.status).toBe("stored"); - expect(second.status).toBe("existing"); + const [first, second] = channel === "concurrent" + ? await Promise.all([submit("hook"), submit("agent_source_scan")]) + : [await submit(channel), await submit(channel === "hook" ? "agent_source_scan" : "hook")]; + expect([first.status, second.status].sort()).toEqual(["existing", "stored"]); expect(first.result.sessionId).toBe(opened.sessionId); expect(second.result.l1MemoryId).toBe(first.result.l1MemoryId); const repos = new Repositories(db.db); - expect(repos.memories.get(first.result.l1MemoryId)).toMatchObject({ userId: "configured-owner", sessionId: opened.sessionId, + expect(repos.memories.get(first.result.l1MemoryId)).toMatchObject({ userId: "installed-owner", sessionId: opened.sessionId, info: { project_id: repos.runtime.getSession(opened.sessionId)!.projectId } }); - expect(db.db.prepare("SELECT COUNT(*) AS n FROM sessions").get()).toEqual({ n: 1 }); + for (const table of ["sessions", "episodes", "raw_turns", "source_turn_captures"]) { + expect(db.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get()).toEqual({ n: 1 }); + } + expect(db.db.prepare("SELECT COUNT(*) AS n FROM memories WHERE memory_layer = 'L1'").get()).toEqual({ n: 1 }); + expect(db.db.prepare("SELECT COUNT(*) AS n FROM evolution_jobs WHERE job_type = 'trace_summary'").get()).toEqual({ n: 1 }); } finally { await new Promise(resolve => server.close(() => resolve())); } }); diff --git a/Memory/tests/native-scanner-lifecycle.test.ts b/Memory/tests/native-scanner-lifecycle.test.ts index e47fbaa18..b3a3a5a25 100644 --- a/Memory/tests/native-scanner-lifecycle.test.ts +++ b/Memory/tests/native-scanner-lifecycle.test.ts @@ -31,7 +31,7 @@ describe("Codex scan and Hook share the actual Memory lifecycle", () => { writeFileSync(path, records(true).map(record => JSON.stringify(record)).join("\n") + "\n"); const messages: RawCodexMessage[] = []; for await (const value of readCodexRollout(path)) messages.push(value); const turn = sourceTurnFromMessages(messages)!; - const hook = () => service.completeSourceTurn({ ...buildSourceTurnRequest(turn, "hook"), namespace: { source: "codex", profileId: "default", userId: "scan-owner" } }); + const hook = () => service.completeSourceTurn({ ...buildSourceTurnRequest(turn, "hook"), namespace: { source: "codex", profileId: "default", userId: "hook-owner" } }); const add = vi.spyOn(service, "addMemory"); const executor = createAgentSourceExecutor({ service, configPath: join(root, "config.yaml"), resolveAgentSkillRoot: () => null, @@ -43,12 +43,12 @@ describe("Codex scan and Hook share the actual Memory lifecycle", () => { expect(executor.scanStatus().error).toBeNull(); expect(hook().status).toBe("existing"); expect(add).not.toHaveBeenCalled(); - for (const table of ["raw_turns", "source_turn_captures"]) expect(db.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get()).toEqual({ n: 1 }); + for (const table of ["sessions", "episodes", "raw_turns", "source_turn_captures"]) expect(db.db.prepare(`SELECT COUNT(*) AS n FROM ${table}`).get()).toEqual({ n: 1 }); expect(db.db.prepare("SELECT COUNT(*) AS n FROM memories WHERE memory_layer = 'L1'").get()).toEqual({ n: 1 }); const captured = hook().result!; const repos = new Repositories(db.db); - expect(repos.runtime.getSession(captured.sessionId)?.userId).toBe("scan-owner"); - expect(repos.memories.get(captured.l1MemoryId)?.userId).toBe("scan-owner"); + expect(repos.runtime.getSession(captured.sessionId)?.userId).toBe(first === "hook" ? "hook-owner" : "scan-owner"); + expect(repos.memories.get(captured.l1MemoryId)?.userId).toBe(first === "hook" ? "hook-owner" : "scan-owner"); expect(repos.runtime.getEpisode(captured.episodeId)?.l1MemoryIds).toEqual([captured.l1MemoryId]); expect(repos.runtime.getRawTurn(captured.rawTurnId)?.toolCalls[0]).toMatchObject({ id: "read", input: { path: "src/main.ts" }, output: expect.stringContaining("source contents") }); expect(db.db.prepare("SELECT COUNT(*) AS n FROM evolution_jobs WHERE job_type = 'import_summary'").get()).toEqual({ n: 0 }); diff --git a/Memory/tests/repository/source-turn-schema.test.ts b/Memory/tests/repository/source-turn-schema.test.ts index febdf1edd..0e38bc630 100644 --- a/Memory/tests/repository/source-turn-schema.test.ts +++ b/Memory/tests/repository/source-turn-schema.test.ts @@ -33,6 +33,25 @@ describe("source turn schema upgrade", () => { finally { reopened.close(); } }); + it("adds account-independent lookup indexes on an existing database without rewriting captures", () => { + const { db, root } = createTestService(); + const runtime = new Repositories(db.db).runtime; + runtime.insertSourceTurnCapture({ userId: "installed-owner", source: "codex", profileId: "default", namespaceKey: "scope", + conversationId: "conversation", turnId: "turn", contentHash: "hash", startedAt: "2099-01-01T10:00:00Z", + completedAt: "2099-01-01T10:01:00Z", createdAt: "2099-01-01T10:01:00Z", response: { status: "rejected", reason: "capture_policy" } }); + const before = db.db.prepare("SELECT * FROM source_turn_captures").all(); + db.db.exec("DROP INDEX idx_source_turn_captures_native_turn; DROP INDEX idx_source_turn_captures_native_conversation"); + db.close(); + const reopened = new MemoryDb({ path: join(root, "memory.sqlite") }); + try { + expect(reopened.db.prepare("SELECT * FROM source_turn_captures").all()).toEqual(before); + const plan = reopened.db.prepare(`EXPLAIN QUERY PLAN SELECT * FROM source_turn_captures + WHERE source = ? AND profile_id = ? AND namespace_key = ? AND conversation_id = ? AND turn_id = ?`) + .all("codex", "default", "scope", "conversation", "turn") as Array<{ detail: string }>; + expect(plan.some(row => row.detail.includes("idx_source_turn_captures_native_turn"))).toBe(true); + } finally { reopened.close(); } + }); + it("enforces source uniqueness across separate database connections", () => { const { db, root } = createTestService(); const second = new MemoryDb({ path: join(root, "memory.sqlite") }); @@ -45,7 +64,7 @@ describe("source turn schema upgrade", () => { try { new Repositories(db.db).runtime.insertSourceTurnCapture(capture); expect(() => new Repositories(second.db).runtime.insertSourceTurnCapture(capture)).toThrow(/UNIQUE constraint/); - expect(new Repositories(second.db).runtime.getSourceTurnCapture(capture, capture.turnId)?.response).toEqual(capture.response); + expect(new Repositories(second.db).runtime.getSourceTurnCapture({ ...capture, userId: "switched-user" }, capture.turnId)?.response).toEqual(capture.response); } finally { second.close(); } }); }); diff --git a/Memory/tests/service/session/source-turn-capture.test.ts b/Memory/tests/service/session/source-turn-capture.test.ts index 88f279f80..71c474a4e 100644 --- a/Memory/tests/service/session/source-turn-capture.test.ts +++ b/Memory/tests/service/session/source-turn-capture.test.ts @@ -153,7 +153,8 @@ describe("native source turn submission", () => { const reopened = new MemoryDb({ path: join(root, "memory.sqlite") }); try { const restarted = createTestMemoryService({ db: reopened }); - const second = restarted.completeSourceTurn(request({ channel: channel === "hook" ? "agent_source_scan" : "hook", sessionId: "late-hook-session" })); + const second = restarted.completeSourceTurn(request({ channel: channel === "hook" ? "agent_source_scan" : "hook", sessionId: "late-hook-session", + namespace: { ...request().namespace!, userId: "switched-after-restart" } })); expect(second).toMatchObject({ status: "existing", result: { rawTurnId: result.rawTurnId, l1MemoryId: result.l1MemoryId, duplicate: true, jobs: [] } }); expect(counts(reopened)).toEqual(before); } finally { reopened.close(); } @@ -202,7 +203,6 @@ describe("native source turn submission", () => { }); it.each([ - { label: "another user with the same native conversation", namespace: { userId: "other-user" } }, { label: "another project", namespace: { projectId: "project-b" } }, { label: "another workspace", namespace: { workspaceId: "workspace-b" } }, { label: "an unproven tenant", namespace: { tenantId: "tenant-b" } }, @@ -310,12 +310,79 @@ describe("native source turn submission", () => { expect(counts(db)).toEqual(before); }); - it("does not merge equal QA belonging to different turns or scopes", () => { + it("keeps different native turns but reuses the same turn across accounts", () => { const { service } = createTestService(); const first = service.completeSourceTurn(request()); const second = service.completeSourceTurn(request({ sourceTurn: { ...request().sourceTurn, turnId: "native-turn-2", sequence: 2, startedAt: "2099-01-01T10:02:00.000Z", completedAt: "2099-01-01T10:03:00.000Z" } })); const otherUser = service.completeSourceTurn(request({ namespace: { ...request().namespace!, userId: "other-user" } })); - expect(new Set([first.result?.l1MemoryId, second.result?.l1MemoryId, otherUser.result?.l1MemoryId]).size).toBe(3); + expect(otherUser).toMatchObject({ status: "existing", result: { l1MemoryId: first.result!.l1MemoryId, jobs: [] } }); + expect(second.result?.l1MemoryId).not.toBe(first.result?.l1MemoryId); + }); + + it.each(["codex", "claude_code", "cursor", "opencode"])( + "keeps one %s conversation across accounts and capture channels", + async (source) => { + for (const firstChannel of ["hook", "agent_source_scan"] as const) { + const { db, service } = createTestService(); + const input = request({ source, channel: firstChannel, + namespace: { source, profileId: "default", userId: "installed-owner" }, + sourceTurn: { ...request().sourceTurn, source } }); + const opened = service.openSession({ namespace: { ...input.namespace!, + sessionKey: `${source}-memory-${input.sourceTurn.conversationId}` } }); + const first = service.completeSourceTurn(input); + expect(first).toMatchObject({ status: "stored", result: { sessionId: opened.sessionId } }); + const before = counts(db); + const switched = { ...input, channel: firstChannel === "hook" ? "agent_source_scan" as const : "hook" as const, + namespace: { ...input.namespace!, userId: "current-owner" } }; + expect(service.completeSourceTurn(switched)).toMatchObject({ status: "existing", result: { + sessionId: first.result!.sessionId, episodeId: first.result!.episodeId, + rawTurnId: first.result!.rawTurnId, l1MemoryIds: first.result!.l1MemoryIds, jobs: [] + } }); + expect(counts(db)).toEqual(before); + const secondInput = { ...switched, sourceTurn: { ...input.sourceTurn, turnId: "native-turn-2", sequence: 2, + startedAt: "2099-01-01T10:02:00.000Z", completedAt: "2099-01-01T10:03:00.000Z" } }; + const second = service.completeSourceTurn(secondInput); + expect(second).toMatchObject({ status: "stored", result: { + sessionId: opened.sessionId, episodeId: first.result!.episodeId + } }); + expect(second.result!.l1MemoryId).not.toBe(first.result!.l1MemoryId); + expect(db.db.prepare("SELECT DISTINCT user_id FROM source_turn_captures").all()).toEqual([{ user_id: "installed-owner" }]); + expect(counts(db)).toMatchObject({ sessions: 1, episodes: 1, raw_turns: 2, source_turn_captures: 2 }); + await runWorkerRounds(service, 4); + const recall = await service.search({ query: input.query, layers: ["L1"], limit: 10 }); + expect(recall.hits.filter(hit => hit.id === first.result!.l1MemoryId)).toHaveLength(1); + const afterWorker = counts(db); + service.completeSourceTurn(switched); + service.completeSourceTurn(secondInput); + expect(counts(db)).toEqual(afterWorker); + } + } + ); + + it("lets the first scanner completion reuse a Hook Session from the installed account", () => { + const { service, db } = createTestService(); + const input = request(); + const opened = service.openSession({ namespace: { ...input.namespace!, + sessionKey: `codex-memory-${input.sourceTurn.conversationId}` } }); + const scanned = service.completeSourceTurn({ ...input, channel: "agent_source_scan", + namespace: { ...input.namespace!, userId: "new-account" } }); + expect(scanned).toMatchObject({ status: "stored", result: { sessionId: opened.sessionId } }); + expect(service.completeSourceTurn(input).status).toBe("existing"); + expect(counts(db)).toMatchObject({ sessions: 1, episodes: 1, raw_turns: 1, source_turn_captures: 1 }); + expect(new Repositories(db.db).memories.get(scanned.result!.l1MemoryId)?.userId).toBe(input.namespace!.userId); + }); + + it("retains cross-account conflicts and tombstones after the Episode closes", () => { + const { service, db } = createTestService(); + const first = service.completeSourceTurn(request()); + service.closeSession(first.result!.sessionId); + const switched = request({ namespace: { ...request().namespace!, userId: "switched-owner" }, channel: "agent_source_scan" }); + const before = counts(db); + expect(service.completeSourceTurn(switched)).toMatchObject({ status: "existing", result: { jobs: [] } }); + expect(service.completeSourceTurn({ ...switched, answer: "A different answer" })).toMatchObject({ status: "conflict", reason: "source_turn_content_conflict" }); + db.db.prepare("UPDATE memories SET status = 'deleted', deleted_at = ? WHERE id = ?").run("2099-01-02", first.result!.l1MemoryId); + expect(service.completeSourceTurn(switched)).toMatchObject({ status: "rejected", reason: "capture_deleted" }); + expect(counts(db)).toEqual(before); }); it("reports conflicting content and keeps deleted capture identities", () => { From 4d38f0186fc1cab092c7001c300a834ca4174241 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Sat, 10 Oct 2026 11:05:04 +0800 Subject: [PATCH 37/85] test: clarify native conversation scope regression name --- Memory/tests/service/session/source-turn-capture.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Memory/tests/service/session/source-turn-capture.test.ts b/Memory/tests/service/session/source-turn-capture.test.ts index 71c474a4e..c0cacdb32 100644 --- a/Memory/tests/service/session/source-turn-capture.test.ts +++ b/Memory/tests/service/session/source-turn-capture.test.ts @@ -1076,7 +1076,7 @@ describe("native source turn submission", () => { expect(runtime.getEpisode(completed.result!.episodeId)?.openedAt).toBe("2099-01-01T10:02:00.000Z"); }); - it("rejects a supplied Session from another account or native conversation", () => { + it("rejects a supplied Session from another native conversation", () => { const { service } = createTestService(); const foreign = service.openSession({ namespace: { source: "codex", profileId: "default", userId: "other-user", sessionKey: "other-conversation" } }); expect(() => service.completeSourceTurn(request({ sessionId: foreign.sessionId }))).toThrow("source_session_scope_conflict"); From 0e48c982e0460f08b2f031a085f92992a20ced2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Wed, 14 Oct 2026 14:59:08 +0800 Subject: [PATCH 38/85] fix: collapse duplicate cross-account L1 recall candidates --- .../cross-account-candidate-dedup.ts | 151 ++++++++++++++++++ .../retrieval/indexed-candidate-pool.ts | 12 +- .../service/retrieval/retrieval-service.ts | 12 +- .../service/session/session-turn-service.ts | 8 +- 4 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 Memory/src/service/retrieval/cross-account-candidate-dedup.ts diff --git a/Memory/src/service/retrieval/cross-account-candidate-dedup.ts b/Memory/src/service/retrieval/cross-account-candidate-dedup.ts new file mode 100644 index 000000000..aab41318a --- /dev/null +++ b/Memory/src/service/retrieval/cross-account-candidate-dedup.ts @@ -0,0 +1,151 @@ +import type { SeededChannelScores } from "../../algorithm/plugin-algorithms.js"; +import type { Repositories } from "../../storage/repositories.js"; +import { attachMemoryVector, memoryVector, memoryVectorEntries } from "../../storage/memory-vector-state.js"; +import type { MemoryRow } from "../../types.js"; +import { stableHash } from "../../utils/id.js"; +import { isRecord } from "../../utils/json.js"; + +export interface CrossAccountCandidateDedupResult { + memories: MemoryRow[]; + channelScoresByMemory: ReadonlyMap; + memoryIdByMemoryId: ReadonlyMap; +} + +/** + * Collapse copies of the same locally sourced memory before ranking. Account IDs + * identify the write owner, but the same native source turn can have been written + * under more than one account before cross-account capture deduplication existed. + */ +export function deduplicateCrossAccountMemoryCandidates(input: { + memories: readonly MemoryRow[]; + repos: Repositories; + preferredUserId: string; + channelScoresByMemory?: ReadonlyMap; +}): CrossAccountCandidateDedupResult { + const groups = new Map(); + const nativeConversationBySessionId = new Map(); + input.memories.forEach((memory, index) => { + const key = duplicateKey(memory, input.repos, nativeConversationBySessionId); + if (!key) { + groups.set(`unique:${index}:${memory.id}`, { firstIndex: index, memories: [memory] }); + return; + } + const group = groups.get(key); + if (group) group.memories.push(memory); + else groups.set(key, { firstIndex: index, memories: [memory] }); + }); + + const selected = [...groups.values()] + .sort((left, right) => left.firstIndex - right.firstIndex) + .map((group) => { + const representative = [...group.memories].sort((left, right) => + Number(right.userId === input.preferredUserId) - Number(left.userId === input.preferredUserId) || + candidateScore(right, input.channelScoresByMemory) - candidateScore(left, input.channelScoresByMemory) || + left.createdAt.localeCompare(right.createdAt) || + left.id.localeCompare(right.id) + )[0]!; + return { representative, members: group.memories }; + }); + + const channelScoresByMemory = new Map(); + const memoryIdByMemoryId = new Map(); + for (const group of selected) { + if (group.members.length === 1) { + const memory = group.representative; + memoryIdByMemoryId.set(memory.id, memory.id); + const scores = input.channelScoresByMemory?.get(memory.id); + if (scores) channelScoresByMemory.set(memory.id, scores); + continue; + } + + const merged: SeededChannelScores = {}; + for (const memory of group.members) { + memoryIdByMemoryId.set(memory.id, group.representative.id); + for (const vector of memoryVectorEntries(memory)) { + if (!memoryVector(group.representative, vector.vectorField)) { + attachMemoryVector(group.representative, vector); + } + } + for (const [channel, score] of Object.entries(input.channelScoresByMemory?.get(memory.id) ?? {})) { + const key = channel as keyof SeededChannelScores; + const current = merged[key]; + if (typeof score === "number" && (current === undefined || score > current)) { + merged[key] = score; + } + } + } + if (Object.keys(merged).length > 0) channelScoresByMemory.set(group.representative.id, merged); + } + + return { + memories: selected.map((group) => group.representative), + channelScoresByMemory, + memoryIdByMemoryId + }; +} + +function duplicateKey( + memory: MemoryRow, + repos: Repositories, + nativeConversationBySessionId: Map +): string | undefined { + if (memory.memoryLayer !== "L1" || memory.properties.internal_info.memory_kind === "work_memory") { + return undefined; + } + const nativeConversation = nativeConversationKey(memory, repos, nativeConversationBySessionId); + if (!nativeConversation) return undefined; + const internal = memory.properties.internal_info; + const trace = isRecord(internal.trace) + ? internal.trace + : isRecord(memory.info.trace) + ? memory.info.trace + : undefined; + const turnId = text(memory.info.turn_id) ?? text(trace?.turn_id); + if (!turnId) return undefined; + const stepIndex = typeof trace?.step_index === "number" && Number.isFinite(trace.step_index) + ? trace.step_index + : memory.contentHash ?? stableHash(memory.memoryValue); + return `native-turn:${stableHash({ nativeConversation, turnId, stepIndex })}`; +} + +function nativeConversationKey( + memory: MemoryRow, + repos: Repositories, + cache: Map +): string | undefined { + if (!memory.sessionId) return undefined; + if (cache.has(memory.sessionId)) return cache.get(memory.sessionId); + const session = repos.runtime.getSession(memory.sessionId); + if (!session) { + cache.set(memory.sessionId, undefined); + return undefined; + } + const conversationId = session.conversationId?.trim() || memory.conversationId?.trim(); + const source = session.source.trim(); + const profileId = session.profileId.trim(); + if (!conversationId || !source || !profileId) { + cache.set(memory.sessionId, undefined); + return undefined; + } + + const storedNamespaceKey = text(session.meta.source_namespace_key); + const tenantId = text(session.meta.tenant_id) ?? text(session.meta.tenantId) ?? null; + const namespaceKey = storedNamespaceKey ?? stableHash({ + tenantId, + projectId: session.projectId ?? null, + workspaceId: session.workspaceId ?? null + }); + const key = stableHash({ source, profileId, namespaceKey, conversationId }); + cache.set(memory.sessionId, key); + return key; +} + +function candidateScore(memory: MemoryRow, scores?: ReadonlyMap): number { + return Math.max(0, ...Object.values(scores?.get(memory.id) ?? {}).filter((value): value is number => + typeof value === "number" && Number.isFinite(value) + )); +} + +function text(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} diff --git a/Memory/src/service/retrieval/indexed-candidate-pool.ts b/Memory/src/service/retrieval/indexed-candidate-pool.ts index f907e2d4d..e177b97ec 100644 --- a/Memory/src/service/retrieval/indexed-candidate-pool.ts +++ b/Memory/src/service/retrieval/indexed-candidate-pool.ts @@ -14,6 +14,7 @@ import type { MemoryLayer, MemoryRow } from "../../types.js"; +import { deduplicateCrossAccountMemoryCandidates } from "./cross-account-candidate-dedup.js"; export function dedupeStrings(values: readonly string[]): string[] { const out: string[] = []; @@ -147,13 +148,16 @@ export class IndexedCandidatePool { scores[hit.channel] = Math.max(scores[hit.channel] ?? -Infinity, hit.score); channelScoresByMemory.set(hit.id, scores); } - return { - memories: this.deps.repos.memories.getMany(candidateIds).filter((memory) => + const readyMemories = this.deps.repos.memories.getMany(candidateIds).filter((memory) => this.isMemoryReadyForRetrieval(memory) && this.isSkillVisibleToAgent(memory, input.currentAgentId) - ), + ); + return deduplicateCrossAccountMemoryCandidates({ + memories: readyMemories, + repos: this.deps.repos, + preferredUserId: input.userId, channelScoresByMemory - }; + }); } private isSkillVisibleToAgent(memory: MemoryRow, currentAgentId?: string): boolean { diff --git a/Memory/src/service/retrieval/retrieval-service.ts b/Memory/src/service/retrieval/retrieval-service.ts index 17e6ebf0d..ca7a6baed 100644 --- a/Memory/src/service/retrieval/retrieval-service.ts +++ b/Memory/src/service/retrieval/retrieval-service.ts @@ -69,6 +69,7 @@ import { normalizeRetrievalExtractKeywords } from "../turn/turn-normalization.js"; import { IndexedCandidatePool } from "./indexed-candidate-pool.js"; +import { deduplicateCrossAccountMemoryCandidates } from "./cross-account-candidate-dedup.js"; import { filterL1TraceSpanRecallHits } from "./l1-trace-span-filter.js"; type InternalMemorySearchRequest = MemorySearchRequest & { @@ -1941,7 +1942,8 @@ export class RetrievalService { ? this.retrieveTimeFilteredTraceMemories({ timeFilter, tags: request.tags, - limit: retrievalLimit + limit: retrievalLimit, + preferredUserId: context.userId }) : await this.retrieveSearchMemories({ userId: context.userId, @@ -2165,6 +2167,7 @@ export class RetrievalService { timeFilter: RetrievalTimeFilter; tags?: string[]; limit: number; + preferredUserId: string; }): { retrieval: RetrievalResult; memories: MemoryRow[] } { const filter: MemoryFilter = { memoryLayer: "L1", @@ -2174,10 +2177,15 @@ export class RetrievalService { ...(input.tags?.length ? { tags: input.tags } : {}) }; const candidateCount = this.deps.repos.memories.count(filter); - const candidates = this.deps.repos.memories + const loadedCandidates = this.deps.repos.memories .list(filter, candidateCount) .filter((memory) => this.isMemoryReadyForRetrieval(memory)) .filter((memory) => Boolean(traceMetaFromMemory(memory)?.summary.trim())); + const candidates = deduplicateCrossAccountMemoryCandidates({ + memories: loadedCandidates, + repos: this.deps.repos, + preferredUserId: input.preferredUserId + }).memories; const selected = [...candidates] .sort(compareTimeFilteredTraceRecency) .slice(0, Math.max(0, input.limit)) diff --git a/Memory/src/service/session/session-turn-service.ts b/Memory/src/service/session/session-turn-service.ts index 811771711..1132952c1 100644 --- a/Memory/src/service/session/session-turn-service.ts +++ b/Memory/src/service/session/session-turn-service.ts @@ -71,6 +71,7 @@ import type { SynthesizeDecisionRepairDraft } from "../feedback/feedback-experience.js"; import { recordApiLog } from "../model-audit/model-call-audit.js"; +import { deduplicateCrossAccountMemoryCandidates } from "../retrieval/cross-account-candidate-dedup.js"; import { namespaceForRawTurn, namespaceForSession, @@ -2937,13 +2938,18 @@ export class SessionTurnService { : undefined; const query = buildRepairSuggestionQuery(input); const repairLayers = retrievalLayersForMode("decision_repair"); - const candidates = this.deps.repos.memories.list( + const loadedCandidates = this.deps.repos.memories.list( { memoryLayer: repairLayers, status: ["activated", "resolving"] }, 500 ).filter((memory) => this.deps.isMemoryReadyForRetrieval(memory)); + const candidates = deduplicateCrossAccountMemoryCandidates({ + memories: loadedCandidates, + repos: this.deps.repos, + preferredUserId: session.userId + }).memories; const retrieval = retrievePluginMemories({ query, queryVector: await this.deps.queryVector(query), From 15164f98c4da4d2dca1823d85185dda50db719c1 Mon Sep 17 00:00:00 2001 From: dolnna <99724206+dolnna@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:41:59 +0800 Subject: [PATCH 39/85] style(desktop): place social sign-in below email login --- .../src/components/social-login-buttons.tsx | 11 ++- App/frontend/desktop/src/i18n/messages.ts | 2 + App/frontend/desktop/src/pages/login-page.tsx | 3 +- .../desktop/src/pages/welcome-page.tsx | 17 ++-- App/frontend/desktop/src/styles.css | 82 +++++++++++++++++++ 5 files changed, 103 insertions(+), 12 deletions(-) diff --git a/App/frontend/desktop/src/components/social-login-buttons.tsx b/App/frontend/desktop/src/components/social-login-buttons.tsx index 078f7074f..45bdd7e46 100644 --- a/App/frontend/desktop/src/components/social-login-buttons.tsx +++ b/App/frontend/desktop/src/components/social-login-buttons.tsx @@ -10,6 +10,7 @@ const SOCIAL_LOGIN_BUTTONS_ENABLED = false; export interface SocialLoginButtonsProps { pendingProvider: SocialLoginProvider | null; disabled?: boolean; + showDivider?: boolean; feedback?: { text: string; tone: "error" | "success" } | null; onLogin: (provider: SocialLoginProvider) => void; } @@ -19,15 +20,18 @@ export function SocialLoginButtons(props: SocialLoginButtonsProps) { if (!SOCIAL_LOGIN_BUTTONS_ENABLED) return null; return ( -
-
+
+ {props.showDivider ?
{t("login.social.orSocial")}
: null} +
} disabled={props.disabled || Boolean(props.pendingProvider)} onClick={() => props.onLogin("google")} /> } disabled={props.disabled || Boolean(props.pendingProvider)} @@ -48,6 +52,7 @@ export function SocialLoginButtons(props: SocialLoginButtonsProps) { } function SocialLoginButton(props: { + provider: SocialLoginProvider; label: string; icon: ReactNode; disabled?: boolean; @@ -58,7 +63,7 @@ function SocialLoginButton(props: { type="button" disabled={props.disabled} onClick={props.onClick} - className="flex min-w-0 items-center justify-center gap-2 rounded-btn border border-border-stone/80 bg-white px-3 py-3 text-sm font-semibold text-text-ink shadow-sm transition-colors hover:bg-canvas-oat/60 disabled:cursor-not-allowed disabled:opacity-45" + className={`social-login-button social-login-button--${props.provider}`} > {props.icon} {props.label} diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index 9e79ffc53..a7ddec3d2 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -215,6 +215,7 @@ export const zhCNMessages = { "login.continue": "登录 / 注册", "login.social.google": "使用 Google 登录", "login.social.github": "使用 GitHub 登录", + "login.social.orSocial": "或", "login.social.opening": "正在打开…", "login.social.waiting": "请在浏览器中完成登录…", "login.social.expired": "登录已超时,请重试", @@ -1955,6 +1956,7 @@ export const enUSMessages: Record = { "login.continue": "Log in / Sign up", "login.social.google": "Continue with Google", "login.social.github": "Continue with GitHub", + "login.social.orSocial": "or", "login.social.opening": "Opening…", "login.social.waiting": "Complete sign-in in your browser…", "login.social.expired": "Sign-in timed out. Please try again.", diff --git a/App/frontend/desktop/src/pages/login-page.tsx b/App/frontend/desktop/src/pages/login-page.tsx index c20db5726..2b37c8213 100644 --- a/App/frontend/desktop/src/pages/login-page.tsx +++ b/App/frontend/desktop/src/pages/login-page.tsx @@ -171,7 +171,7 @@ export function LoginPage() {

{t("nav.login")}

{t("login.title")}

-
+
void startSocialLogin(provider)} /> diff --git a/App/frontend/desktop/src/pages/welcome-page.tsx b/App/frontend/desktop/src/pages/welcome-page.tsx index 666092d42..324f84e91 100644 --- a/App/frontend/desktop/src/pages/welcome-page.tsx +++ b/App/frontend/desktop/src/pages/welcome-page.tsx @@ -252,6 +252,15 @@ export function WelcomePage() { onOpenTerms={() => void openExternalUrl(getLegalLinkUrl("terms", language, state.bootstrap?.legal))} onOpenDataAgreement={() => void openExternalUrl(getLegalLinkUrl("data", language, state.bootstrap?.legal))} /> + {channel === "email" ? ( + void startSocialLogin(provider)} + /> + ) : null}
@@ -270,14 +279,6 @@ export function WelcomePage() { {t("welcome.byok.quickAction")} - {channel === "email" ? ( - void startSocialLogin(provider)} - /> - ) : null}
diff --git a/App/frontend/desktop/src/styles.css b/App/frontend/desktop/src/styles.css index 375ca9c0f..911eeef22 100644 --- a/App/frontend/desktop/src/styles.css +++ b/App/frontend/desktop/src/styles.css @@ -11070,6 +11070,88 @@ body.memmy-platform-windows:not(.memmy-window-fullscreen) .memmy-notification-st color: color-mix(in srgb, var(--color-text-ink) 24%, transparent); } +.login-page__body { + padding-bottom: 24px; +} + +.login-page__body .auth-code-form-input.flex-1, +.welcome-login-card__body .auth-code-form-input.flex-1 { + min-width: 0; +} + +.social-login-actions { + display: grid; + gap: 18px; +} + +.social-login-actions__buttons { + display: grid; + gap: 10px; +} + +.social-login-button { + display: flex; + width: 100%; + min-height: 48px; + align-items: center; + justify-content: center; + gap: 10px; + padding: 10px 16px; + border: 1px solid color-mix(in srgb, var(--color-border-stone) 85%, var(--color-text-ink)); + border-radius: 11px; + background: var(--color-background-paper); + color: var(--color-text-ink); + font-size: 14px; + font-weight: 700; + line-height: 1.25; + cursor: pointer; + transition: border-color 150ms ease, background 150ms ease, box-shadow 150ms ease; +} + +.social-login-button--google { + border-color: color-mix(in srgb, var(--color-action-sky) 53%, var(--color-border-stone)); + background: color-mix(in srgb, var(--color-canvas-oat) 42%, white); + box-shadow: 0 2px 9px rgb(29 93 76 / 7%); +} + +.social-login-button:hover:not(:disabled) { + border-color: var(--color-action-sky-hover); + background: color-mix(in srgb, var(--color-canvas-oat) 68%, white); +} + +.social-login-button:focus-visible { + outline: 3px solid color-mix(in srgb, var(--color-action-sky) 65%, transparent); + outline-offset: 2px; +} + +.social-login-button:disabled { + cursor: not-allowed; + opacity: .55; +} + +.social-login-divider { + display: flex; + align-items: center; + gap: 13px; + color: color-mix(in srgb, var(--color-text-ink) 55%, transparent); + font-size: 12px; + line-height: 1; + white-space: nowrap; +} + +.social-login-divider::before, +.social-login-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--color-border-stone); +} + +.login-page__body .social-login-actions, +.welcome-login-card__body .social-login-actions { + margin-top: 22px; +} + /* Optional invite field: muted so it reads as secondary / non-required. */ .auth-code-form-input--optional { border-color: color-mix(in srgb, var(--color-border-stone) 42%, white); From b041201417fd3791151d8c9e74e55ea2e53fa8ad Mon Sep 17 00:00:00 2001 From: dolnna <99724206+dolnna@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:52:28 +0800 Subject: [PATCH 40/85] feat(desktop): refine model thinking controls and custom capabilities --- .../desktop/src/components/Select.tsx | 91 +++-- .../src/components/agent-model-selector.tsx | 16 +- .../src/components/agent-thinking-control.tsx | 114 ++---- .../agent-thinking-menu.interaction.test.tsx | 103 ++++++ App/frontend/desktop/src/i18n/messages.ts | 26 +- .../src/pages/model-workspace-section.tsx | 324 ++++++++++++------ .../src/pages/tests/home-page.test.tsx | 5 +- ...del-workspace-section.interaction.test.tsx | 127 +++++++ .../src/pages/tests/settings-page.test.tsx | 39 +-- App/frontend/desktop/src/styles.css | 254 +++++++++----- 10 files changed, 756 insertions(+), 343 deletions(-) create mode 100644 App/frontend/desktop/src/components/tests/agent-thinking-menu.interaction.test.tsx diff --git a/App/frontend/desktop/src/components/Select.tsx b/App/frontend/desktop/src/components/Select.tsx index 89cbc0dcf..92995123d 100644 --- a/App/frontend/desktop/src/components/Select.tsx +++ b/App/frontend/desktop/src/components/Select.tsx @@ -31,6 +31,8 @@ export interface SelectProps { placement?: "top" | "bottom"; /** Optional footer rendered below options; receives a close helper. */ menuFooter?: (api: { close: () => void }) => ReactNode; + /** Optional panel shown beside the menu while an option is hovered or focused. */ + optionAside?: (option: SelectOption) => ReactNode; } /** Handles select. */ @@ -40,15 +42,23 @@ export function Select(props: SelectProps) { const listboxId = `${controlId}-listbox`; const labelId = `${controlId}-label`; const rootRef = useRef(null); + const menuRef = useRef(null); const [open, setOpen] = useState(false); const [activeValue, setActiveValue] = useState(props.value); + const [hoveredValue, setHoveredValue] = useState(null); + const [hoveredTop, setHoveredTop] = useState(0); const enabledOptions = useMemo(() => props.options.filter((option) => !option.disabled), [props.options]); const selectedOption = props.options.find((option) => option.value === props.value); const activeOption = props.options.find((option) => option.value === activeValue && !option.disabled); + const hoveredOption = props.options.find((option) => option.value === hoveredValue && !option.disabled); + const aside = hoveredOption ? props.optionAside?.(hoveredOption) : null; useEffect(() => { - if (!open) return; + if (!open) { + setHoveredValue(null); + return; + } setActiveValue(props.value || enabledOptions[0]?.value || ""); @@ -62,6 +72,12 @@ export function Select(props: SelectProps) { return () => document.removeEventListener("mousedown", closeOnOutsideClick); }, [enabledOptions, open, props.value]); + function showOptionAside(option: SelectOption, element: HTMLButtonElement) { + const menuTop = menuRef.current?.getBoundingClientRect().top ?? 0; + setHoveredTop(element.getBoundingClientRect().top - menuTop); + setHoveredValue(option.value); + } + function moveActive(step: 1 | -1) { if (!enabledOptions.length) return; @@ -158,38 +174,51 @@ export function Select(props: SelectProps) {
{props.menuFooter?.({ close: () => setOpen(false) })} + {aside && ( +
+ {aside} +
+ )}
)}
diff --git a/App/frontend/desktop/src/components/agent-model-selector.tsx b/App/frontend/desktop/src/components/agent-model-selector.tsx index f81d63ffa..7158bcbb1 100644 --- a/App/frontend/desktop/src/components/agent-model-selector.tsx +++ b/App/frontend/desktop/src/components/agent-model-selector.tsx @@ -102,12 +102,6 @@ export function AgentModelSelector(props: AgentModelSelectorProps) { const thinkingConfig = resolveThinkingConfigForModel(workspace, resolved.candidate); return ( -
-
setEditor((prev) => prev ? { ...prev, customProtocol: value as CustomProtocol } : null)} + onValueChange={(value) => { + setEditor((prev) => prev ? { ...prev, customProtocol: value as CustomProtocol } : null); + setEditorTest({ status: "idle", message: null }); + }} className="select-control--subtle" options={CUSTOM_PROTOCOL_OPTIONS.map((option) => ({ value: option.value, @@ -1168,57 +1205,13 @@ export function ModelWorkspaceSection(props: ModelWorkspaceSectionProps) { setEditor((prev) => prev ? { ...prev, region } : prev)} + onChange={(region) => { + setEditor((prev) => prev ? { ...prev, region } : prev); + setEditorTest({ status: "idle", message: null }); + }} placeholder={t("settings.modelWorkspace.regionPlaceholder")} /> )} -
- setEditor((prev) => prev ? { ...prev, customThinkingEnabled } : null)} - /> - {editor.customThinkingEnabled && ( - <> - setEditor((prev) => prev ? { ...prev, customThinkingSwitchable } : null)} - /> - {editor.customThinkingSwitchable && ( - setEditor((prev) => prev ? { ...prev, customThinkingDefaultEnabled } : null)} - /> - )} -
- - setEditor((prev) => prev ? { ...prev, customThinkingLevels: event.target.value } : null)} - className="custom-thinking-levels__input" - /> -

- {t("settings.modelWorkspace.customThinkingLevelsHint")} -

-
- - )} - setEditor((prev) => prev ? { ...prev, customImageInput } : null)} - /> -
)} { setEditor((current) => current ? { ...current, apiKey: value } : current); @@ -1242,9 +1233,7 @@ export function ModelWorkspaceSection(props: ModelWorkspaceSectionProps) { maskedValue={editor.connectionId && !editorProviderChanged ? editorExistingConnection?.apiKeyMasked : undefined} - placeholder={editor.connectionId && !editorProviderChanged - ? t("settings.modelWorkspace.replaceKeyPlaceholder") - : "sk-..."} + placeholder={t("settings.modelWorkspace.apiKeyPlaceholder")} showPassword={showEditorApiKey} onTogglePassword={() => setShowEditorApiKey((show) => !show)} /> @@ -1262,6 +1251,7 @@ export function ModelWorkspaceSection(props: ModelWorkspaceSectionProps) { capabilityDrafts: editorExistingConnection ? editorCapabilitiesForProtocol(editorExistingConnection.protocol) : [...DEFAULT_TEXT_CAPABILITIES], + modelDraftOptions: defaultCustomModelOptions(), addingModel: true, editingModelIndex: null })} @@ -1309,9 +1299,18 @@ export function ModelWorkspaceSection(props: ModelWorkspaceSectionProps) { /> setEditor({ ...editor, capabilityDrafts })} + onChange={(capabilityDrafts) => { + setEditor({ ...editor, capabilityDrafts }); + setEditorTest({ status: "idle", message: null }); + }} />
+ {editor.provider === "custom" && editor.capabilityDrafts.includes("chat") && ( + + )}