From 30713c8de9a3755c8e9ca65ffc5e2d48b9a875b6 Mon Sep 17 00:00:00 2001 From: xuzhiqin1990 Date: Sat, 19 Sep 2026 19:52:54 +0800 Subject: [PATCH 01/56] 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 4e4c5fc0d199f714676c08d0e274758b8759ae3e Mon Sep 17 00:00:00 2001 From: ZongYue Date: Fri, 9 Oct 2026 12:21:19 +0800 Subject: [PATCH 02/56] 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 03/56] 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 04/56] 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 05/56] 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 06/56] 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 07/56] 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 08/56] =?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 09/56] 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 10/56] 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 11/56] 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 12/56] 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 13/56] 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 14/56] 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 15/56] 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 16/56] =?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 17/56] =?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 18/56] =?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 19/56] =?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 20/56] =?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 21/56] 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 22/56] 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 23/56] 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 24/56] 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 25/56] 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 26/56] 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 f2e4f07c48b192193a4e6bbe9dbac55606838419 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Wed, 14 Oct 2026 16:02:55 +0800 Subject: [PATCH 27/56] Let external plugins participate in the agent lifecycle Memmy needs a host-owned integration point so third-party plugins can contribute temporary model context, inspect buffered responses, and block an unverified final answer without controlling agent startup. Load configured hook factories once per runtime, compose them with native hooks, keep iteration and finalization feedback request-only, and isolate plugin failures so the original agent flow continues. Constraint: External plugins must not own or interrupt Memmy startup and injected context must not enter user session history Rejected: Launch Memmy through a plugin wrapper | reverses host/plugin ownership and couples execution to one plugin Confidence: high Scope-risk: moderate Directive: Keep external hook loading host-owned, fail-open, and restart-bound; preserve request-only injection isolation Tested: npm run build; 5 focused Vitest files with 58 passing tests; git diff --check Not-tested: Full typecheck is blocked by missing optional dependency typings; full suite has 48 unrelated environment/baseline failures with 5606 tests passing --- App/memmy-agent/src/config/schema.ts | 3 + .../agent-runtime/external-hook-loader.ts | 50 +++++ .../src/core/agent-runtime/hook.ts | 195 +++++++++++++++- .../src/core/agent-runtime/loop.ts | 50 ++++- .../src/core/agent-runtime/runner.ts | 128 ++++++++--- .../tests/config/agent-hooks-config.test.ts | 21 ++ .../external-hook-loader.test.ts | 123 +++++++++++ .../core/agent-runtime/hook-composite.test.ts | 114 ++++++++++ .../core/agent-runtime/runner-hooks.test.ts | 208 +++++++++++++++++- .../runner-progress-deltas.test.ts | 35 +++ 10 files changed, 879 insertions(+), 48 deletions(-) create mode 100644 App/memmy-agent/src/core/agent-runtime/external-hook-loader.ts create mode 100644 App/memmy-agent/tests/config/agent-hooks-config.test.ts create mode 100644 App/memmy-agent/tests/core/agent-runtime/external-hook-loader.test.ts diff --git a/App/memmy-agent/src/config/schema.ts b/App/memmy-agent/src/config/schema.ts index 9820534b8..66bed95bc 100644 --- a/App/memmy-agent/src/config/schema.ts +++ b/App/memmy-agent/src/config/schema.ts @@ -1168,6 +1168,7 @@ export class FileMemoryConfig extends Base { export class Config extends Base { app: Dict; + agentHooks: string[]; agents: AgentsConfig; providers: ProvidersConfig; channels: ChannelsConfig; @@ -1208,6 +1209,7 @@ export class Config extends Base { if (appUserId) { this.app.userId = appUserId; } + this.agentHooks = assertStringArray("agentHooks", pick(init, ["agentHooks"], [])); this.agents = init.agents instanceof AgentsConfig ? init.agents : new AgentsConfig(init.agents ?? {}); this.providers = @@ -1456,6 +1458,7 @@ export class Config extends Base { delete this.app.user_id; this.memmyMemory.userId = appUserId ?? this.memmyMemory.userId ?? "local-user"; const data: Dict = { + agentHooks: this.agentHooks, agents: this.agents, providers: this.providers, channels: this.channels, diff --git a/App/memmy-agent/src/core/agent-runtime/external-hook-loader.ts b/App/memmy-agent/src/core/agent-runtime/external-hook-loader.ts new file mode 100644 index 000000000..0c24704ac --- /dev/null +++ b/App/memmy-agent/src/core/agent-runtime/external-hook-loader.ts @@ -0,0 +1,50 @@ +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { + AGENT_HOOK_TIMEOUT_MS, + withAgentHookTimeout, + type AgentHook, +} from "./hook.js"; + +type HookFactory = () => AgentHook | Promise; + +export async function loadExternalAgentHooks(specifiers: string[]): Promise { + const hooks: AgentHook[] = []; + for (const specifier of specifiers) { + try { + const hook = await withAgentHookTimeout(async () => { + const module = await import(moduleTarget(specifier)); + if (typeof module.default !== "function") { + throw new Error(`agent hook module '${specifier}' must default-export a hook factory`); + } + return (module.default as HookFactory)(); + }, AGENT_HOOK_TIMEOUT_MS, `external agent hook '${specifier}' initialization`); + if (!hook || typeof hook !== "object") { + throw new Error(`agent hook module '${specifier}' did not return a hook`); + } + hook.reraise = false; + Object.defineProperty(hook, "externalHookSpecifier", { + configurable: false, + enumerable: false, + value: specifier, + writable: false, + }); + hooks.push(hook); + } catch (error) { + console.error(`Unable to load external agent hook '${specifier}':`, error); + } + } + return hooks; +} + +function moduleTarget(specifier: string): string { + if (specifier.startsWith("file:")) return new URL(specifier).href; + if (path.isAbsolute(specifier)) return pathToFileURL(specifier).href; + if (specifier === "~" || specifier.startsWith("~/") || specifier.startsWith("./") || specifier.startsWith("../")) { + throw new Error(`agent hook specifier '${specifier}' must be a package, absolute path, or file URL`); + } + if (/^[a-z][a-z\d+.-]*:/iu.test(specifier)) { + throw new Error(`agent hook specifier '${specifier}' must use the file URL protocol`); + } + return specifier; +} diff --git a/App/memmy-agent/src/core/agent-runtime/hook.ts b/App/memmy-agent/src/core/agent-runtime/hook.ts index 375e3f5a4..ee2d82298 100644 --- a/App/memmy-agent/src/core/agent-runtime/hook.ts +++ b/App/memmy-agent/src/core/agent-runtime/hook.ts @@ -79,8 +79,51 @@ export type AgentToolRegistrationContext = { metadata: Record; }; +export type AgentHookMessage = { + role: "user"; + content: string; +}; + +export type LlmContentRewrite = + | string + | null + | { + content: string | null; + continue: true; + }; + +export const AGENT_HOOK_TIMEOUT_MS = 600_000; +export const AGENT_HOOK_SHUTDOWN_TIMEOUT_MS = 3_000; + +export async function withAgentHookTimeout( + run: () => T | Promise, + timeoutMs: number, + label: string, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + Promise.resolve().then(run), + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), + timeoutMs, + ); + timer.unref?.(); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function hookLabel(hook: AgentHook): string { + return String((hook as any).externalHookSpecifier ?? hook.constructor.name); +} + export class AgentHookContext { spec?: any; + workspace: string | null; sessionKey?: string | null; session?: any; reason?: string | null; @@ -101,6 +144,7 @@ export class AgentHookContext { metadata: Record; constructor(init: Partial = {}) { Object.assign(this, init); + this.workspace = init.workspace ?? init.spec?.workspace ?? null; this.sessionKey = init.sessionKey ?? null; this.session = init.session ?? null; this.reason = init.reason ?? null; @@ -133,7 +177,11 @@ export class AgentHook { return false; } - async beforeIteration(ctx: AgentHookContext): Promise { void ctx; } + requiresBufferedLlmContent(): boolean { + return false; + } + + async beforeIteration(ctx: AgentHookContext): Promise { void ctx; } async onStream(ctx: AgentHookContext, delta: string): Promise { void ctx; void delta; } async onStreamEnd(ctx: AgentHookContext, opts: { resuming?: boolean } = {}): Promise { void ctx; void opts; } async beforeExecuteTools(ctx: AgentHookContext): Promise { void ctx; } @@ -142,6 +190,17 @@ export class AgentHook { finalizeContent(ctx: AgentHookContext, content: string | null): string | null { return content; } + async beforeFinalize( + ctx: AgentHookContext, + candidate: string, + ): Promise { + void ctx; + void candidate; + } + async rewriteLlmContent(ctx: AgentHookContext, content: string | null): Promise { + void ctx; + return content; + } onRegisterTools(ctx: AgentToolRegistrationContext): void { void ctx; } onBuildSystemPrompt(ctx: SystemPromptBuildContext): void { void ctx; } async beforeBuildSystemPrompt(ctx: AgentHookContext): Promise { void ctx; } @@ -156,6 +215,7 @@ export class AgentHook { async afterCompaction(ctx: AgentHookContext): Promise { void ctx; } async subagentStart(ctx: AgentHookContext): Promise { void ctx; } async subagentStop(ctx: AgentHookContext): Promise { void ctx; } + async shutdown(): Promise {} } export class CompositeHook extends AgentHook { @@ -167,18 +227,49 @@ export class CompositeHook extends AgentHook { } override wantsStreaming(): boolean { - return this.hooks.some((hook) => hook.wantsStreaming()); + for (const hook of this.hooks) { + try { + if (hook.wantsStreaming?.()) return true; + } catch (error) { + if (hook.reraise) throw error; + console.error(`AgentHook.wantsStreaming error in ${hookLabel(hook)}:`, error); + } + } + return false; + } + + override requiresBufferedLlmContent(): boolean { + for (const hook of this.hooks) { + try { + if (hook.requiresBufferedLlmContent?.()) return true; + } catch (error) { + if (hook.reraise) throw error; + console.error(`AgentHook.requiresBufferedLlmContent error in ${hookLabel(hook)}:`, error); + } + } + return false; + } + + private async invokeHook(hook: AgentHook, methodName: string, ...args: any[]): Promise { + const fn = (hook as any)[methodName]; + if (typeof fn !== "function") return undefined as T; + const timeoutMs = methodName === "shutdown" + ? AGENT_HOOK_SHUTDOWN_TIMEOUT_MS + : AGENT_HOOK_TIMEOUT_MS; + return withAgentHookTimeout( + () => fn.apply(hook, args), + timeoutMs, + `AgentHook.${methodName}`, + ); } async forEachHookSafe(methodName: string, ...args: any[]): Promise { for (const hook of this.hooks) { try { - const fn = (hook as any)[methodName]; - if (typeof fn !== "function") continue; - await fn.apply(hook, args); + await this.invokeHook(hook, methodName, ...args); } catch (error) { if (hook.reraise) throw error; - console.error(`AgentHook.${methodName} error in ${hook.constructor.name}:`, error); + console.error(`AgentHook.${methodName} error in ${hookLabel(hook)}:`, error); } } } @@ -191,7 +282,7 @@ export class CompositeHook extends AgentHook { fn.apply(hook, args); } catch (error) { if (hook.reraise) throw error; - console.error(`AgentHook.${methodName} error in ${hook.constructor.name}:`, error); + console.error(`AgentHook.${methodName} error in ${hookLabel(hook)}:`, error); } } } @@ -199,8 +290,24 @@ export class CompositeHook extends AgentHook { override async beforeRun(ctx: AgentHookContext): Promise { await this.forEachHookSafe("beforeRun", ctx); } - override async beforeIteration(ctx: AgentHookContext): Promise { - await this.forEachHookSafe("beforeIteration", ctx); + override async beforeIteration(ctx: AgentHookContext): Promise { + const messages: AgentHookMessage[] = []; + for (const hook of this.hooks) { + try { + const result = await this.invokeHook(hook, "beforeIteration", ctx); + if (result == null) continue; + if (!Array.isArray(result) || result.some((item) => ( + !item || item.role !== "user" || typeof item.content !== "string" + ))) { + throw new TypeError("beforeIteration must return user messages"); + } + messages.push(...result); + } catch (error) { + if (hook.reraise) throw error; + console.error(`AgentHook.beforeIteration error in ${hookLabel(hook)}:`, error); + } + } + return messages; } override async onStream(ctx: AgentHookContext, delta: string): Promise { await this.forEachHookSafe("onStream", ctx, delta); @@ -219,9 +326,74 @@ export class CompositeHook extends AgentHook { } override finalizeContent(ctx: AgentHookContext, content: string | null): string | null { let next = content; - for (const hook of this.hooks) next = hook.finalizeContent(ctx, next); + for (const hook of this.hooks) { + try { + if (typeof hook.finalizeContent === "function") { + next = hook.finalizeContent(ctx, next); + } + } catch (error) { + if (hook.reraise) throw error; + console.error(`AgentHook.finalizeContent error in ${hookLabel(hook)}:`, error); + } + } return next; } + override async beforeFinalize( + ctx: AgentHookContext, + candidate: string, + ): Promise { + const messages: AgentHookMessage[] = []; + for (const hook of this.hooks) { + try { + const result = await this.invokeHook( + hook, + "beforeFinalize", + ctx, + candidate, + ); + if (result == null) continue; + if (!Array.isArray(result) || result.some((item) => ( + !item || item.role !== "user" || typeof item.content !== "string" + ))) { + throw new TypeError("beforeFinalize must return user messages"); + } + messages.push(...result.filter((item) => item.content.trim())); + } catch (error) { + console.error(`AgentHook.beforeFinalize error in ${hookLabel(hook)}:`, error); + } + } + return messages; + } + override async rewriteLlmContent( + ctx: AgentHookContext, + content: string | null, + ): Promise { + let next = content; + let shouldContinue = false; + for (const hook of this.hooks) { + try { + const result = await this.invokeHook(hook, "rewriteLlmContent", ctx, next); + if (result === undefined) continue; + if ( + result !== null + && typeof result !== "string" + && (!result || result.continue !== true || (result.content !== null && typeof result.content !== "string")) + ) { + throw new TypeError("rewriteLlmContent returned an invalid result"); + } + if (typeof result === "object" && result !== null) { + next = result.content; + shouldContinue = true; + } else { + next = result; + } + } catch (error) { + if (hook.reraise) throw error; + console.error(`AgentHook.rewriteLlmContent error in ${hookLabel(hook)}:`, error); + } + } + return shouldContinue ? { content: next, continue: true } : next; + } override onRegisterTools(ctx: AgentToolRegistrationContext): void { this.forEachHookSyncSafe("onRegisterTools", ctx); } @@ -261,6 +433,9 @@ export class CompositeHook extends AgentHook { override async subagentStop(ctx: AgentHookContext): Promise { await this.forEachHookSafe("subagentStop", ctx); } + override async shutdown(): Promise { + await this.forEachHookSafe("shutdown"); + } } export class CompositeAgentHook extends CompositeHook {} diff --git a/App/memmy-agent/src/core/agent-runtime/loop.ts b/App/memmy-agent/src/core/agent-runtime/loop.ts index 750a149c8..b46c226b2 100644 --- a/App/memmy-agent/src/core/agent-runtime/loop.ts +++ b/App/memmy-agent/src/core/agent-runtime/loop.ts @@ -119,6 +119,7 @@ import { type TokenCompactionStatus, } from "./memory.js"; import { AgentHook, AgentHookContext, CompositeAgentHook } from "./hook.js"; +import { loadExternalAgentHooks } from "./external-hook-loader.js"; import { SubagentManager } from "./subagent.js"; import { AutoCompact } from "./autocompact.js"; import { configuredModelPresets, defaultSelectionSignature, makePresetSnapshotLoader, normalizePresetName } from "./model-presets.js"; @@ -734,6 +735,9 @@ export class AgentLoop { unifiedSession: boolean; backgroundTasks: Array> = []; extraHooks: AgentHook[] = []; + private externalHooks: AgentHook[] = []; + private externalHooksLoadPromise: Promise | null = null; + private externalHooksShutdownPromise: Promise | null = null; startTime: number; lastUsageBySession: Map>; goalRuntime: GoalRuntime; @@ -1133,6 +1137,7 @@ export class AgentLoop { } async initializeRuntimeTools(): Promise { + await this.ensureExternalHooksLoaded(); await initializeDesktopScreenCapture(); await this.connectMcp(); await this.browserSessionManager.initialize(); @@ -1145,9 +1150,34 @@ export class AgentLoop { } async closeRuntimeTools(): Promise { - await this.browserSessionManager.close(); - await this.closeMcp(); - await this.memmyMemoryIntegration.dispose?.(); + try { + await this.browserSessionManager.close(); + await this.closeMcp(); + await this.memmyMemoryIntegration.dispose?.(); + } finally { + await this.shutdownExternalHooks(); + } + } + + private ensureExternalHooksLoaded(): Promise { + this.externalHooksLoadPromise ??= loadExternalAgentHooks(this.config.agentHooks) + .then((hooks) => { + this.externalHooks = hooks; + this.extraHooks.push(...hooks); + }) + .catch((error) => { + console.error("Unable to initialize external agent hooks:", error); + }); + return this.externalHooksLoadPromise; + } + + private shutdownExternalHooks(): Promise { + this.externalHooksShutdownPromise ??= new CompositeAgentHook(this.externalHooks) + .shutdown() + .catch((error) => { + console.error("Unable to shut down external agent hooks:", error); + }); + return this.externalHooksShutdownPromise; } async closeBrowserSession( @@ -1311,11 +1341,15 @@ export class AgentLoop { }); } - registerHookTools(toolCtx: ToolContext, phase: string, registry: ToolRegistryInstance = this.tools): void { + registerHookTools( + toolCtx: ToolContext, + phase: string, + registry: ToolRegistryInstance = this.tools, + ): void { this.lifecycleHook().onRegisterTools({ registry, toolContext: toolCtx, - workspace: this.workspace, + workspace: toolCtx.workspace ?? this.workspace, metadata: { phase }, }); } @@ -1326,6 +1360,9 @@ export class AgentLoop { session, sessionKey, reason, + workspace: typeof session.metadata?.[WEBUI_WORKSPACE_CWD_METADATA_KEY] === "string" + ? session.metadata[WEBUI_WORKSPACE_CWD_METADATA_KEY] + : this.workspace, metadata: { lifecycle: "session" }, }), ); @@ -1337,6 +1374,9 @@ export class AgentLoop { session, sessionKey, reason, + workspace: typeof session?.metadata?.[WEBUI_WORKSPACE_CWD_METADATA_KEY] === "string" + ? session.metadata[WEBUI_WORKSPACE_CWD_METADATA_KEY] + : this.workspace, metadata: { lifecycle: "session" }, }), ); diff --git a/App/memmy-agent/src/core/agent-runtime/runner.ts b/App/memmy-agent/src/core/agent-runtime/runner.ts index bfe6ef224..bb7eef3ae 100644 --- a/App/memmy-agent/src/core/agent-runtime/runner.ts +++ b/App/memmy-agent/src/core/agent-runtime/runner.ts @@ -11,7 +11,7 @@ import type { ActualModelContext } from "@memmy/local-api-contracts"; import { CONTEXT_SAFETY_BUFFER_TOKENS } from "../../token-budget.js"; import { ToolRegistry } from "./tools/registry.js"; import type { FileMutationOutcome, ToolExecutionContext } from "./tools/base.js"; -import { AgentHook, AgentHookContext } from "./hook.js"; +import { AgentHook, AgentHookContext, type AgentHookMessage } from "./hook.js"; import { buildFinalizationRetryMessage, buildLengthRecoveryMessage, @@ -808,10 +808,11 @@ export class AgentRunner { const tools = options.toolsOverride === undefined ? spec.tools?.getDefinitions?.() ?? [] : options.toolsOverride; - const wantsStreaming = options.forceNonStreaming ? false : hook.wantsStreaming(); + const forceNonStreaming = options.forceNonStreaming || hook.requiresBufferedLlmContent(); + const wantsStreaming = forceNonStreaming ? false : hook.wantsStreaming(); const providerRuntime = provider as any; const wantsProgressStreaming = - !options.forceNonStreaming && + !forceNonStreaming && !wantsStreaming && spec.streamProgressDeltas && spec.progressCallback && @@ -820,7 +821,7 @@ export class AgentRunner { (provider.constructor as any)?.supportsProgressDeltas === true ); const args = this.buildRequestArgs(spec, messages, tools); - const liveFileEdits = !options.forceNonStreaming && spec.progressCallback && onProgressAcceptsFileEditEvents(spec.progressCallback) + const liveFileEdits = !forceNonStreaming && spec.progressCallback && onProgressAcceptsFileEditEvents(spec.progressCallback) ? new StreamingFileEditTracker({ workspace: spec.workspace ?? null, tools: spec.tools, @@ -1495,6 +1496,8 @@ export class AgentRunner { spec.provider ??= this.provider; if (!spec.provider) throw new Error("AgentRunSpec.provider is required"); const hook = spec.hook ?? new AgentHook(); + const requiresBufferedLlmContent = hook.requiresBufferedLlmContent(); + const streamsHookContent = hook.wantsStreaming() && !requiresBufferedLlmContent; const messages = [...spec.initialMessages]; const toolCalls: any[] = []; const toolEvents: Record[] = []; @@ -1511,6 +1514,8 @@ export class AgentRunner { let lengthRecoveries = 0; let injectionCycles = 0; let hadInjections = false; + let pendingHookInjections: AgentHookMessage[] = []; + let hookContinuationExhausted = false; const imageTextState: TurnImageTextState = { nextImageNumber: 1, nextBatchId: 1, @@ -1549,21 +1554,30 @@ export class AgentRunner { break; } } - let messagesForModel = this.modelContextMessages(messages, modelContextProjection); + const context = new AgentHookContext({ spec, messages, iteration, usage }); + const beforeIterationInjections = await hook.beforeIteration(context) ?? []; + const requestOnlyInjections = [...pendingHookInjections, ...beforeIterationInjections]; + pendingHookInjections = []; + let requestMessages = messages; + if (requestOnlyInjections.length) { + requestMessages = messages.map((message) => ({ ...message })); + AgentRunner.appendInjectedMessages(requestMessages, requestOnlyInjections); + console.info("AgentHook.beforeIteration injected request-only content:", requestOnlyInjections); + } + let messagesForModel = this.modelContextMessages(requestMessages, modelContextProjection); messagesForModel = this.prepareMessagesForModel(spec, messagesForModel, { toolsForRequest, reservedPromptTokens: 0, }); messagesForModel = this.prepareImageMessages(messagesForModel, imageTextState); - const context = new AgentHookContext({ spec, messages, iteration, usage }); - await hook.beforeIteration(context); response = await this.requestModelWithImagePolicy( spec, messagesForModel, hook, context, imageTextState, + { forceNonStreaming: requiresBufferedLlmContent }, ); const iterationUsage = this.usageDict(response.usage); this.accumulateUsage(usage, iterationUsage); @@ -1600,8 +1614,25 @@ export class AgentRunner { context.streamedReasoning = true; } + const rewritten = await hook.rewriteLlmContent(context, response.content); + const shouldContinueFromHook = rewritten !== null && typeof rewritten === "object"; + const rewrittenContent = shouldContinueFromHook ? (rewritten as { content: string | null }).content : rewritten; + response.content = rewrittenContent; + if (shouldContinueFromHook) { + if (typeof rewrittenContent === "string" && !isBlankText(rewrittenContent)) { + pendingHookInjections = [{ role: "user", content: rewrittenContent }]; + } + if (streamsHookContent) { + await hook.onStreamEnd(context, { resuming: iteration + 1 < spec.maxIterations }); + } + await hook.afterIteration(context); + if (iteration + 1 < spec.maxIterations) continue; + hookContinuationExhausted = true; + break; + } + if (response.shouldExecuteTools && spec.tools && response.toolCalls.length) { - if (hook.wantsStreaming()) { + if (streamsHookContent) { await hook.onStreamEnd(context, { resuming: true }); } const assistant = this.assistantMessage(response); @@ -1702,13 +1733,13 @@ export class AgentRunner { if (response.finishReason !== "error" && isBlankText(clean)) { emptyContentRetries += 1; if (emptyContentRetries < MAX_EMPTY_RETRIES) { - if (hook.wantsStreaming()) { + if (streamsHookContent) { await hook.onStreamEnd(context, { resuming: false }); } await hook.afterIteration(context); continue; } - if (hook.wantsStreaming()) { + if (streamsHookContent) { await hook.onStreamEnd(context, { resuming: false }); } response = await this.requestFinalizationRetry( @@ -1756,15 +1787,18 @@ export class AgentRunner { injectionCycles = cycles; if (shouldContinue) hadInjections = true; } - if (hook.wantsStreaming()) { - await hook.onStreamEnd(context, { resuming: shouldContinue }); - } if (shouldContinue) { + if (streamsHookContent) { + await hook.onStreamEnd(context, { resuming: true }); + } await hook.afterIteration(context); continue; } if (response.finishReason === "error") { + if (streamsHookContent) { + await hook.onStreamEnd(context, { resuming: false }); + } finalContent = clean || spec.errorMessage || DEFAULT_ERROR_MESSAGE; stopReason = "error"; error = finalContent; @@ -1784,6 +1818,9 @@ export class AgentRunner { } if (isBlankText(clean)) { + if (streamsHookContent) { + await hook.onStreamEnd(context, { resuming: false }); + } finalContent = EMPTY_FINAL_RESPONSE_MESSAGE; stopReason = "emptyFinalResponse"; error = finalContent; @@ -1801,6 +1838,24 @@ export class AgentRunner { break; } + const finalizeInjections = await hook.beforeFinalize?.(context, clean as string); + if (finalizeInjections?.length) { + const canContinue = iteration + 1 < spec.maxIterations; + if (streamsHookContent) { + await hook.onStreamEnd(context, { resuming: canContinue }); + } + await hook.afterIteration(context); + if (canContinue) { + pendingHookInjections = finalizeInjections; + continue; + } + hookContinuationExhausted = true; + break; + } + if (streamsHookContent) { + await hook.onStreamEnd(context, { resuming: false }); + } + messages.push(assistant ?? buildAssistantMessage(clean)); await this.emitCheckpoint(spec, { phase: "finalResponse", @@ -1826,7 +1881,7 @@ export class AgentRunner { strip: true, maxIterations: spec.maxIterations, }); - if (spec.maxIterationsFinalPrompt) { + if (spec.maxIterationsFinalPrompt && !hookContinuationExhausted) { spec.onMaxFinalizationStarting?.(); const reservedPromptTokens = estimateMessageTokens({ role: "user", @@ -1884,19 +1939,6 @@ export class AgentRunner { finalContent = "Error: task cancelled"; stopReason = "cancelled"; error = finalContent; - } else if (response.finishReason === "error" && AgentRunner.terminalImageError(response)) { - const [, cleanedContent] = extractReasoning( - response.reasoningContent, - response.thinkingBlocks, - response.content, - ); - response.content = cleanedContent; - finalContent = hook.finalizeContent(context, response.content) - || spec.errorMessage - || DEFAULT_ERROR_MESSAGE; - stopReason = "error"; - error = finalContent; - AgentRunner.appendModelErrorPlaceholder(messages); } else { const [, cleanedContent] = extractReasoning( response.reasoningContent, @@ -1904,11 +1946,33 @@ export class AgentRunner { response.content, ); response.content = cleanedContent; - const clean = hook.finalizeContent(context, response.content); - finalContent = response.finishReason !== "error" && !isBlankText(clean) - ? clean - : fallback; - AgentRunner.appendFinalMessage(messages, finalContent); + context.response = response; + context.toolCalls = [...response.toolCalls]; + const finalRewrite = await hook.rewriteLlmContent(context, response.content); + const continueFromFinalization = finalRewrite !== null && typeof finalRewrite === "object"; + response.content = continueFromFinalization + ? (finalRewrite as { content: string | null }).content + : finalRewrite; + if (continueFromFinalization) { + finalContent = fallback; + AgentRunner.appendFinalMessage(messages, finalContent); + } else if (response.finishReason === "error" && AgentRunner.terminalImageError(response)) { + finalContent = hook.finalizeContent(context, response.content) + || spec.errorMessage + || DEFAULT_ERROR_MESSAGE; + stopReason = "error"; + error = finalContent; + AgentRunner.appendModelErrorPlaceholder(messages); + } else { + const clean = hook.finalizeContent(context, response.content); + const finalizeInjections = response.finishReason !== "error" && !isBlankText(clean) + ? await hook.beforeFinalize?.(context, clean as string) + : []; + finalContent = finalizeInjections?.length || response.finishReason === "error" || isBlankText(clean) + ? fallback + : clean; + AgentRunner.appendFinalMessage(messages, finalContent); + } } } catch (requestError) { if (isAbortError(requestError) || spec.abortSignal?.aborted) { diff --git a/App/memmy-agent/tests/config/agent-hooks-config.test.ts b/App/memmy-agent/tests/config/agent-hooks-config.test.ts new file mode 100644 index 000000000..2b77b1f57 --- /dev/null +++ b/App/memmy-agent/tests/config/agent-hooks-config.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { Config, ValueError } from "../../src/config/schema.js"; + +describe("agentHooks config", () => { + it("defaults to an empty list and round-trips configured specifiers", () => { + expect(new Config().agentHooks).toEqual([]); + + const config = new Config({ + agentHooks: ["@memmy/example-hook", "file:///opt/memmy/example-hook.js"], + }); + + expect(config.toObject().agentHooks).toEqual([ + "@memmy/example-hook", + "file:///opt/memmy/example-hook.js", + ]); + }); + + it("rejects non-string entries", () => { + expect(() => new Config({ agentHooks: ["valid", 42] })).toThrow(ValueError); + }); +}); diff --git a/App/memmy-agent/tests/core/agent-runtime/external-hook-loader.test.ts b/App/memmy-agent/tests/core/agent-runtime/external-hook-loader.test.ts new file mode 100644 index 000000000..fc655fd5d --- /dev/null +++ b/App/memmy-agent/tests/core/agent-runtime/external-hook-loader.test.ts @@ -0,0 +1,123 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { Config } from "../../../src/config/schema.js"; +import { AgentLoop } from "../../../src/core/agent-runtime/loop.js"; +import { loadExternalAgentHooks } from "../../../src/core/agent-runtime/external-hook-loader.js"; + +const roots: string[] = []; + +afterEach(() => { + vi.useRealTimers(); + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe("external agent hook loader", () => { + it("loads configured hook factories without host arguments", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "memmy-agent-hook-")); + roots.push(root); + const pluginPath = path.join(root, "plugin.mjs"); + fs.writeFileSync(pluginPath, [ + "export default async (...args) => ({", + " argsLength: args.length,", + " reraise: true,", + " async beforeIteration() {},", + "});", + ].join("\n")); + + const hooks = await loadExternalAgentHooks([pathToFileURL(pluginPath).href]); + + expect(hooks).toHaveLength(1); + expect((hooks[0] as unknown as { argsLength: number }).argsLength).toBe(0); + expect(hooks[0]?.reraise).toBe(false); + }); + + it("skips modules without a default hook factory", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "memmy-agent-hook-")); + roots.push(root); + const pluginPath = path.join(root, "plugin.mjs"); + fs.writeFileSync(pluginPath, "export const value = 1;\n"); + await expect(loadExternalAgentHooks([pathToFileURL(pluginPath).href])).resolves.toEqual([]); + }); + + it("continues loading after a module fails", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "memmy-agent-hook-")); + roots.push(root); + const firstPluginPath = path.join(root, "first-plugin.mjs"); + const invalidPluginPath = path.join(root, "invalid-plugin.mjs"); + const lastPluginPath = path.join(root, "last-plugin.mjs"); + fs.writeFileSync(firstPluginPath, [ + "export default () => ({", + ' id: "first",', + "});", + ].join("\n")); + fs.writeFileSync(invalidPluginPath, "export const value = 1;\n"); + fs.writeFileSync(lastPluginPath, 'export default () => ({ id: "last" });\n'); + + const hooks = await loadExternalAgentHooks([ + pathToFileURL(firstPluginPath).href, + pathToFileURL(invalidPluginPath).href, + lastPluginPath, + ]); + + expect(hooks.map((hook) => (hook as unknown as { id: string }).id)).toEqual(["first", "last"]); + }); + + it("rejects relative and home-relative specifiers without failing the batch", async () => { + await expect(loadExternalAgentHooks(["./plugin.mjs", "../plugin.mjs", "~/plugin.mjs"])) + .resolves.toEqual([]); + }); + + it("rejects URL protocols other than file", async () => { + await expect(loadExternalAgentHooks([ + "data:text/javascript,export default () => ({})", + "node:fs", + ])).resolves.toEqual([]); + }); + + it("times out hook initialization without blocking the batch", async () => { + vi.useFakeTimers(); + const root = fs.mkdtempSync(path.join(os.tmpdir(), "memmy-agent-hook-")); + roots.push(root); + const pluginPath = path.join(root, "plugin.mjs"); + fs.writeFileSync(pluginPath, "export default () => new Promise(() => {});\n"); + + const loading = loadExternalAgentHooks([pathToFileURL(pluginPath).href]); + await vi.advanceTimersByTimeAsync(600_000); + + await expect(loading).resolves.toEqual([]); + }); + + it("loads once and shuts down once through AgentLoop runtime lifecycle", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "memmy-agent-hook-")); + roots.push(root); + const pluginPath = path.join(root, "plugin.mjs"); + const counterKey = `__memmyHookShutdown_${Date.now()}_${Math.random()}`; + (globalThis as any)[counterKey] = 0; + fs.writeFileSync(pluginPath, [ + "export default () => ({", + ' id: "external",', + ` async shutdown() { globalThis[${JSON.stringify(counterKey)}] += 1; },`, + "});", + ].join("\n")); + const loop = new AgentLoop({ + config: new Config({ + agentHooks: [pathToFileURL(pluginPath).href], + agents: { defaults: { workspace: root } }, + }), + workspace: root, + provider: { getDefaultModel: () => "test", generation: { maxTokens: 1024 } } as any, + }); + + await (loop as any).ensureExternalHooksLoaded(); + await (loop as any).ensureExternalHooksLoaded(); + expect(loop.extraHooks.filter((hook: any) => hook.id === "external")).toHaveLength(1); + + await (loop as any).shutdownExternalHooks(); + await (loop as any).shutdownExternalHooks(); + expect((globalThis as any)[counterKey]).toBe(1); + delete (globalThis as any)[counterKey]; + }); +}); diff --git a/App/memmy-agent/tests/core/agent-runtime/hook-composite.test.ts b/App/memmy-agent/tests/core/agent-runtime/hook-composite.test.ts index a6891727e..d5709e749 100644 --- a/App/memmy-agent/tests/core/agent-runtime/hook-composite.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/hook-composite.test.ts @@ -45,6 +45,7 @@ class StaticTool extends Tool { } afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); }); @@ -72,6 +73,109 @@ describe("CompositeHook", () => { expect(calls).toEqual(["A:0", "B:0"]); }); + it("collects beforeIteration messages in hook order", async () => { + class H extends AgentHook { + override async beforeIteration() { + return [{ role: "user" as const, content: "first" }]; + } + } + class H2 extends AgentHook { + override async beforeIteration() { + return [{ role: "user" as const, content: "second" }]; + } + } + + await expect(new CompositeHook([new H(), new H2()]).beforeIteration(ctx())).resolves.toEqual([ + { role: "user", content: "first" }, + { role: "user", content: "second" }, + ]); + }); + + it("collects beforeFinalize messages in hook order", async () => { + class H extends AgentHook { + override async beforeFinalize() { + return [{ role: "user" as const, content: "first review" }]; + } + } + class H2 extends AgentHook { + override async beforeFinalize() { + return [{ role: "user" as const, content: "second review" }]; + } + } + + await expect(new CompositeHook([new H(), new H2()]).beforeFinalize(ctx(), "draft")) + .resolves.toEqual([ + { role: "user", content: "first review" }, + { role: "user", content: "second review" }, + ]); + }); + + it("isolates beforeFinalize failures and keeps later feedback", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + class BadHook extends AgentHook { + override async beforeFinalize(): Promise { + throw new Error("review failed"); + } + } + class GoodHook extends AgentHook { + override async beforeFinalize() { + return [{ role: "user" as const, content: "retry safely" }]; + } + } + + await expect(new CompositeHook([new BadHook(), new GoodHook()]).beforeFinalize(ctx(), "draft")) + .resolves.toEqual([{ role: "user", content: "retry safely" }]); + }); + + it("pipes rewritten content and keeps continuation sticky", async () => { + class ContinueHook extends AgentHook { + override async rewriteLlmContent() { + return { content: "review", continue: true as const }; + } + } + class SuffixHook extends AgentHook { + override async rewriteLlmContent(context: AgentHookContext, content: string | null) { + return `${content}!`; + } + } + + await expect(new CompositeHook([new ContinueHook(), new SuffixHook()]) + .rewriteLlmContent(ctx(), "draft")) + .resolves.toEqual({ content: "review!", continue: true }); + }); + + it("isolates synchronous failures from fail-open hooks", () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + class BadHook extends AgentHook { + override wantsStreaming(): boolean { + throw new Error("broken streaming check"); + } + override finalizeContent(): string | null { + throw new Error("broken finalizer"); + } + } + + const hook = new CompositeHook([new BadHook(), new AgentHook()]); + + expect(hook.wantsStreaming()).toBe(false); + expect(hook.finalizeContent(ctx(), "answer")).toBe("answer"); + }); + + it("stops waiting for a fail-open asynchronous hook after the fixed timeout", async () => { + vi.useFakeTimers(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + class HangingHook extends AgentHook { + override async beforeRun(): Promise { + await new Promise(() => undefined); + } + } + const call = new CompositeHook([new HangingHook()]).beforeRun(ctx()); + + await vi.advanceTimersByTimeAsync(600_000); + + await expect(call).resolves.toBeUndefined(); + }); + it("fans out all async methods to every hook", async () => { const events: string[] = []; class RecordingHook extends AgentHook { @@ -308,6 +412,16 @@ describe("CompositeHook", () => { expect(new CompositeHook([new AgentHook()]).finalizeContent(ctx(), null)).toBeNull(); }); + it("preserves null returned by finalizeContent", () => { + class RemoveContent extends AgentHook { + override finalizeContent(): null { + return null; + } + } + + expect(new CompositeHook([new RemoveContent()]).finalizeContent(ctx(), "answer")).toBeNull(); + }); + it("orders finalizeContent as a pipeline", () => { const steps: string[] = []; class H1 extends AgentHook { diff --git a/App/memmy-agent/tests/core/agent-runtime/runner-hooks.test.ts b/App/memmy-agent/tests/core/agent-runtime/runner-hooks.test.ts index 8a96a8ca3..2b59555db 100644 --- a/App/memmy-agent/tests/core/agent-runtime/runner-hooks.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/runner-hooks.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { AgentHook, AgentHookContext } from "../../../src/core/agent-runtime/hook.js"; import { AgentRunner, AgentRunSpec } from "../../../src/core/agent-runtime/runner.js"; import { LLMResponse, ToolCallRequest } from "../../../src/providers/base.js"; @@ -10,6 +10,208 @@ class FinalHook extends AgentHook { } describe("AgentRunner hooks", () => { + it("adds beforeIteration messages only to the current model request", async () => { + const requests: any[][] = []; + const provider = { + chatWithRetry: async ({ messages }: any) => { + requests.push(messages); + return new LLMResponse({ content: "done" }); + }, + }; + class InjectionHook extends AgentHook { + override async beforeIteration() { + return [{ role: "user" as const, content: "request-only memory" }]; + } + } + const initial = [{ role: "user", content: "question" }]; + + const result = await new AgentRunner(provider as any).run(new AgentRunSpec({ + messages: initial, + hook: new InjectionHook(), + maxIterations: 1, + })); + + expect(requests[0]?.at(-1)?.content).toContain("request-only memory"); + expect(initial).toEqual([{ role: "user", content: "question" }]); + expect(result.messages.some((message) => String(message.content).includes("request-only memory"))).toBe(false); + expect(result.hadInjections).toBe(false); + }); + + it("continues before executing rejected tool calls", async () => { + const requests: any[][] = []; + let call = 0; + const provider = { + chatWithRetry: async ({ messages }: any) => { + requests.push(messages); + call += 1; + if (call === 1) { + return new LLMResponse({ + content: "draft", + toolCalls: [new ToolCallRequest({ id: "call_1", name: "dangerous", arguments: {} })], + }); + } + return new LLMResponse({ content: "verified" }); + }, + }; + const execute = vi.fn(async () => "should not run"); + const tools = { + getDefinitions: () => [], + get: () => null, + execute, + }; + class VerificationHook extends AgentHook { + rewrites = 0; + override async rewriteLlmContent(context: AgentHookContext, content: string | null) { + this.rewrites += 1; + return this.rewrites === 1 + ? { content: "revise this answer", continue: true as const } + : content; + } + } + + const result = await new AgentRunner(provider as any).run(new AgentRunSpec({ + messages: [{ role: "user", content: "question" }], + tools, + hook: new VerificationHook(), + maxIterations: 2, + })); + + expect(result.finalContent).toBe("verified"); + expect(execute).not.toHaveBeenCalled(); + expect(requests[1]?.at(-1)?.content).toContain("revise this answer"); + expect(result.messages.some((message) => String(message.content).includes("revise this answer"))).toBe(false); + }); + + it("does not run an unverified finalization request after continuation exhausts iterations", async () => { + const provider = { + chatWithRetry: vi.fn(async () => new LLMResponse({ content: "unverified draft" })), + }; + class RejectingHook extends AgentHook { + override async rewriteLlmContent() { + return { content: "try again", continue: true as const }; + } + } + + const result = await new AgentRunner(provider as any).run(new AgentRunSpec({ + messages: [{ role: "user", content: "question" }], + hook: new RejectingHook(), + maxIterations: 1, + maxIterationsFinalPrompt: "Summarize completed work.", + })); + + expect(provider.chatWithRetry).toHaveBeenCalledTimes(1); + expect(result.stopReason).toBe("maxIterations"); + expect(result.messages.some((message) => message.content === "unverified draft")).toBe(false); + }); + + it("continues from beforeFinalize with request-only feedback", async () => { + const requests: any[][] = []; + const checkpoints: string[] = []; + let calls = 0; + let afterRuns = 0; + const provider = { + chatWithRetry: async ({ messages }: any) => { + requests.push(structuredClone(messages)); + calls += 1; + return new LLMResponse({ content: calls === 1 ? "unverified draft" : "verified answer" }); + }, + }; + class StopHook extends AgentHook { + override requiresBufferedLlmContent(): boolean { + return true; + } + override async beforeFinalize(_context: AgentHookContext, candidate: string) { + return candidate === "unverified draft" + ? [{ role: "user" as const, content: "verify the page count before answering" }] + : []; + } + override async afterRun(): Promise { + afterRuns += 1; + } + } + + const result = await new AgentRunner(provider as any).run(new AgentRunSpec({ + messages: [{ role: "user", content: "question" }], + hook: new StopHook(), + maxIterations: 2, + checkpointCallback: ({ phase }) => { + checkpoints.push(phase); + }, + })); + + expect(result.finalContent).toBe("verified answer"); + expect(requests).toHaveLength(2); + expect(requests[1]?.at(-1)?.content).toContain("verify the page count before answering"); + expect(result.messages.some((message) => String(message.content).includes("unverified draft"))).toBe(false); + expect(result.messages.some((message) => String(message.content).includes("verify the page count"))).toBe(false); + expect(checkpoints).toEqual(["finalResponse"]); + expect(afterRuns).toBe(1); + }); + + it("does not submit a candidate rejected by beforeFinalize when iterations are exhausted", async () => { + const provider = { + chatWithRetry: vi.fn(async () => new LLMResponse({ content: "unverified draft" })), + }; + class StopHook extends AgentHook { + override async beforeFinalize() { + return [{ role: "user" as const, content: "verification failed" }]; + } + } + + const result = await new AgentRunner(provider as any).run(new AgentRunSpec({ + messages: [{ role: "user", content: "question" }], + hook: new StopHook(), + maxIterations: 1, + maxIterationsFinalPrompt: "Summarize completed work.", + })); + + expect(provider.chatWithRetry).toHaveBeenCalledTimes(1); + expect(result.stopReason).toBe("maxIterations"); + expect(result.messages.some((message) => message.content === "unverified draft")).toBe(false); + }); + + it("checks a max-iteration finalization candidate before submitting it", async () => { + let calls = 0; + const candidates: string[] = []; + const provider = { + chatWithRetry: vi.fn(async () => { + calls += 1; + if (calls === 1) { + return new LLMResponse({ + content: "working", + toolCalls: [new ToolCallRequest({ id: "call_1", name: "read_file", arguments: {} })], + }); + } + return new LLMResponse({ content: "unverified summary" }); + }), + }; + const tools = { + getDefinitions: () => [], + get: () => null, + execute: async () => "tool result", + }; + class StopHook extends AgentHook { + override async beforeFinalize(_context: AgentHookContext, candidate: string) { + candidates.push(candidate); + return [{ role: "user" as const, content: "verification failed" }]; + } + } + + const result = await new AgentRunner(provider as any).run(new AgentRunSpec({ + messages: [{ role: "user", content: "question" }], + tools, + hook: new StopHook(), + maxIterations: 1, + maxIterationsMessage: "iteration limit reached", + maxIterationsFinalPrompt: "Summarize completed work.", + })); + + expect(provider.chatWithRetry).toHaveBeenCalledTimes(2); + expect(candidates).toEqual(["unverified summary"]); + expect(result.finalContent).toBe("iteration limit reached"); + expect(result.messages.some((message) => message.content === "unverified summary")).toBe(false); + }); + it("lets hooks finalize assistant content", async () => { const provider = { chatWithRetry: async () => new LLMResponse({ content: "done" }) }; const result = await new AgentRunner(provider as any).run(new AgentRunSpec({ messages: [], hook: new FinalHook() })); @@ -58,6 +260,9 @@ describe("AgentRunner hooks", () => { events.push(["finalizeContent", context.iteration, content]); return content?.toUpperCase() ?? content; } + override async beforeFinalize(context: AgentHookContext, candidate: string) { + events.push(["beforeFinalize", context.iteration, candidate]); + } } const result = await new AgentRunner(provider as any).run(new AgentRunSpec({ @@ -75,6 +280,7 @@ describe("AgentRunner hooks", () => { ["afterIteration", 0, null, ["tool result"], [{ name: "list_dir", status: "ok", detail: "tool result" }], null], ["beforeIteration", 1], ["finalizeContent", 1, "done"], + ["beforeFinalize", 1, "DONE"], ["afterIteration", 1, "DONE", [], [], "completed"], ]); }); diff --git a/App/memmy-agent/tests/core/agent-runtime/runner-progress-deltas.test.ts b/App/memmy-agent/tests/core/agent-runtime/runner-progress-deltas.test.ts index a8e3abe0e..5e8311209 100644 --- a/App/memmy-agent/tests/core/agent-runtime/runner-progress-deltas.test.ts +++ b/App/memmy-agent/tests/core/agent-runtime/runner-progress-deltas.test.ts @@ -42,6 +42,41 @@ afterEach(() => { }); describe("AgentRunner progress deltas", () => { + it("buffers provider output when a hook requires complete content", async () => { + const provider = { + supportsProgressDeltas: true, + chatStreamWithRetry: vi.fn(), + chatWithRetry: vi.fn(async () => new LLMResponse({ content: "done", toolCalls: [], usage: {} })), + }; + const progress = withProgressCapabilities(vi.fn(), { reasoning: true }); + const hook = { + requiresBufferedLlmContent: vi.fn(() => true), + wantsStreaming: () => true, + beforeRun: vi.fn(), + beforeIteration: vi.fn(), + rewriteLlmContent: vi.fn(async (_context: AgentHookContext, content: string | null) => content), + finalizeContent: (_context: AgentHookContext, content: string | null) => content, + afterIteration: vi.fn(), + afterRun: vi.fn(), + } as any; + + const result = await new AgentRunner(provider as any).run(new AgentRunSpec({ + messages: [{ role: "user", content: "question" }], + provider: provider as any, + tools: toolRegistry([], async () => "ok"), + model: "test-model", + maxIterations: 1, + progressCallback: progress, + hook, + })); + + expect(result.finalContent).toBe("done"); + expect(provider.chatWithRetry).toHaveBeenCalledTimes(1); + expect(provider.chatStreamWithRetry).not.toHaveBeenCalled(); + expect(progress).not.toHaveBeenCalled(); + expect(hook.requiresBufferedLlmContent).toHaveBeenCalledOnce(); + }); + it("streams only visible incremental content after stripping thinking blocks", async () => { const deltas: string[] = []; const hook = new AgentProgressHook(null, (delta) => { From 4753a6bb8032f7318247e7ac4480c586f702fdbf Mon Sep 17 00:00:00 2001 From: ZongYue Date: Wed, 14 Oct 2026 16:29:26 +0800 Subject: [PATCH 28/56] feat: build and verify runtime artifacts for all platforms --- .github/workflows/runtime-artifacts.yml | 93 +++++++++++++++++++ .../shared/collect-runtime-artifacts.mjs | 39 ++++++++ .../shared/verify-runtime-artifacts.mjs | 78 ++++++++++++++++ tests/runtime-artifacts.test.mjs | 43 +++++++++ 4 files changed, 253 insertions(+) create mode 100644 .github/workflows/runtime-artifacts.yml create mode 100644 scripts/internal/shared/collect-runtime-artifacts.mjs create mode 100644 scripts/internal/shared/verify-runtime-artifacts.mjs create mode 100644 tests/runtime-artifacts.test.mjs diff --git a/.github/workflows/runtime-artifacts.yml b/.github/workflows/runtime-artifacts.yml new file mode 100644 index 000000000..8129087cb --- /dev/null +++ b/.github/workflows/runtime-artifacts.yml @@ -0,0 +1,93 @@ +name: Agent and Memory runtime artifacts + +on: + workflow_dispatch: + push: + branches: [zy_runtime_artifacts] + +permissions: + contents: read + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - target: darwin-arm64 + runner: macos-14 + arch: arm64 + - target: darwin-x64 + runner: macos-15-intel + arch: x64 + - target: linux-arm64 + runner: ubuntu-24.04-arm + arch: arm64 + - target: linux-x64 + runner: ubuntu-24.04 + arch: x64 + - target: windows-arm64 + runner: windows-11-arm + arch: arm64 + - target: windows-x64 + runner: windows-2025 + arch: x64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + defaults: + run: + shell: bash + env: + COPYFILE_DISABLE: '1' + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' + ELECTRON_SKIP_BINARY_DOWNLOAD: '1' + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 26.0.0 + architecture: ${{ matrix.arch }} + cache: npm + - name: Install build dependencies + run: | + npm ci --no-audit --no-fund + npm --prefix App/memmy-agent install --ignore-scripts --no-audit --no-fund + - name: Build Agent runtime + run: npm run agent:runtime:package -- --target "${{ matrix.target }}" --output "$RUNNER_TEMP/runtime-artifacts" + - name: Build Memory runtime + run: npm run runtime:package -w @memmy/memory -- --target "${{ matrix.target }}" --output "$RUNNER_TEMP/runtime-artifacts" + - name: Verify unpacked runtimes and native dependencies + run: node scripts/internal/shared/verify-runtime-artifacts.mjs "$RUNNER_TEMP/runtime-artifacts" "${{ matrix.target }}" + - uses: actions/upload-artifact@v4 + with: + name: runtimes-${{ matrix.target }} + path: ${{ runner.temp }}/runtime-artifacts/* + if-no-files-found: error + compression-level: 0 + retention-days: 14 + + collect: + needs: build + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-node@v4 + with: + node-version: 26.0.0 + - uses: actions/download-artifact@v4 + with: + pattern: runtimes-* + path: runtime-input + - name: Collect all twelve archives with checksums + run: node scripts/internal/shared/collect-runtime-artifacts.mjs runtime-input runtime-output + - uses: actions/upload-artifact@v4 + with: + name: all-platform-runtimes + path: runtime-output/* + if-no-files-found: error + compression-level: 0 + retention-days: 14 diff --git a/scripts/internal/shared/collect-runtime-artifacts.mjs b/scripts/internal/shared/collect-runtime-artifacts.mjs new file mode 100644 index 000000000..6bf5e513f --- /dev/null +++ b/scripts/internal/shared/collect-runtime-artifacts.mjs @@ -0,0 +1,39 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { cp, mkdir, readFile, stat, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repo = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const [input, output] = process.argv.slice(2).map((value) => resolve(value)); +if (!input || !output || input === output) throw new Error("Usage: collect-runtime-artifacts.mjs "); +const targets = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "windows-arm64", "windows-x64"]; +const manifest = { sourceCommit: process.env.GITHUB_SHA ?? null, nodeVersion: process.versions.node, nodeAbi: process.versions.modules, runtimes: {} }; +const checksums = []; +await mkdir(output, { recursive: true }); +for (const kind of ["agent", "memory"]) { + const packageFile = kind === "agent" ? "App/memmy-agent/package.json" : "Memory/package.json"; + const version = JSON.parse(await readFile(join(repo, packageFile))).version; + const assets = {}; + for (const target of targets) { + const root = join(input, `runtimes-${target}`); + const metadataFile = kind === "agent" ? `agent-release-${target}.json` : "memory-release.json"; + const metadata = JSON.parse(await readFile(join(root, metadataFile))); + const asset = metadata.assets?.[target]; + const name = `memmy-${kind}-runtime-${version}-${target}.${kind === "agent" ? "tgz" : "tar.gz"}`; + if (metadata.version !== version || asset?.name !== name) throw new Error(`Invalid ${kind} release metadata for ${target}`); + const file = join(root, name); + const hash = createHash("sha256"); + for await (const chunk of createReadStream(file)) hash.update(chunk); + const sha256 = hash.digest("hex"); + const size = (await stat(file)).size; + if (asset.sha256 !== sha256 || asset.size !== size) throw new Error(`Checksum or size mismatch: ${name}`); + await cp(file, join(output, name)); + assets[target] = { name, sha256, size }; + checksums.push(`${sha256} ${name}`); + } + manifest.runtimes[kind] = { version, assets }; +} +await writeFile(join(output, "runtime-artifacts.json"), `${JSON.stringify(manifest, null, 2)}\n`); +await writeFile(join(output, "SHA256SUMS"), `${checksums.sort().join("\n")}\n`); +console.log(`Collected ${checksums.length} verified runtime archives at ${output}`); diff --git a/scripts/internal/shared/verify-runtime-artifacts.mjs b/scripts/internal/shared/verify-runtime-artifacts.mjs new file mode 100644 index 000000000..80cedf5e3 --- /dev/null +++ b/scripts/internal/shared/verify-runtime-artifacts.mjs @@ -0,0 +1,78 @@ +import { spawn, spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; + +const repo = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const directory = resolve(process.argv[2]); +const target = process.argv[3]; +const hostTarget = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`; +if (target !== hostTarget) throw new Error(`Expected ${target}, running on ${hostTarget}`); +const agentVersion = JSON.parse(await readFile(join(repo, "App/memmy-agent/package.json"))).version; +const memoryVersion = JSON.parse(await readFile(join(repo, "Memory/package.json"))).version; +const root = await mkdtemp(join(tmpdir(), "runtime-artifact-check-")); +let service; +let serviceExit; +let logs = ""; +try { + for (const [kind, version, extension] of [["agent", agentVersion, "tgz"], ["memory", memoryVersion, "tar.gz"]]) { + await mkdir(join(root, kind)); + run("tar", ["-xzf", join(directory, `memmy-${kind}-runtime-${version}-${target}.${extension}`), "-C", join(root, kind)]); + } + const agentRoot = join(root, "agent/package"); + const agent = JSON.parse(await readFile(join(agentRoot, "agent-runtime.json"))); + if (agent.target !== target || agent.version !== agentVersion || agent.nodeAbi !== process.versions.modules) { + throw new Error("Agent runtime metadata does not match the runner"); + } + if (run(process.execPath, [join(agentRoot, "dist/main.js"), "--version"]).trim() !== agentVersion) { + throw new Error("Agent runtime version check failed"); + } + for (const packageRoot of [agentRoot, join(root, "memory")]) { + const require = createRequire(join(packageRoot, "package.json")); + const Database = require("better-sqlite3"); + const database = new Database(":memory:"); + if (packageRoot.endsWith("memory")) require("sqlite-vec").load(database); + database.prepare("SELECT 1").get(); + database.close(); + } + const socket = createServer(); + await new Promise((resolvePort, reject) => { socket.once("error", reject); socket.listen(0, "127.0.0.1", resolvePort); }); + const port = socket.address().port; + await new Promise((done) => socket.close(done)); + const config = join(root, "config.json"); + await writeFile(config, JSON.stringify({ storage: { mode: "local", backend: "sqlite", sqlitePath: join(root, "memory.sqlite") }, agentAccess: { autoScanKnownAgents: false, watchFileChanges: false, autoInjectSkill: false } })); + service = spawn(process.execPath, [join(root, "memory/dist/src/server/index.js"), "--config", config, "--port", String(port)], { + cwd: root, env: { ...process.env, MEMMY_MEMORY_HOME: join(root, "home"), MEMORY_SERVICE_HOME: join(root, "home") }, stdio: ["ignore", "pipe", "pipe"] + }); + serviceExit = new Promise((done) => service.once("close", done)); + service.on("error", (error) => { logs += String(error); }); + for (const stream of [service.stdout, service.stderr]) stream.on("data", (chunk) => { logs = (logs + chunk).slice(-8000); }); + let healthy = false; + for (let attempt = 0; attempt < 100; attempt++) { + if (service.exitCode !== null) break; + try { + const response = await fetch(`http://127.0.0.1:${port}/api/v1/health`, { signal: AbortSignal.timeout(1000) }); + if (response.ok) { healthy = true; break; } + } catch {} + await delay(200); + } + if (!healthy) throw new Error(`Memory runtime failed health check: ${logs}`); + console.log(`Verified Agent ${agentVersion}, Memory ${memoryVersion}, target ${target}, Node ABI ${process.versions.modules}`); +} finally { + if (service) { + service.kill(); + await Promise.race([serviceExit, delay(3000)]); + if (service.exitCode === null) { service.kill("SIGKILL"); await serviceExit; } + } + await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }); +} + +function run(command, args) { + const result = spawnSync(command, args, { encoding: "utf8", timeout: 60_000, env: { ...process.env, COPYFILE_DISABLE: "1" } }); + if (result.status !== 0) throw new Error(`${command} failed: ${result.error ?? result.stderr}`); + return result.stdout; +} diff --git a/tests/runtime-artifacts.test.mjs b/tests/runtime-artifacts.test.mjs new file mode 100644 index 000000000..634b9d219 --- /dev/null +++ b/tests/runtime-artifacts.test.mjs @@ -0,0 +1,43 @@ +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, expect, it } from "vitest"; +import YAML from "yaml"; + +const root = resolve(import.meta.dirname, ".."); +const collector = join(root, "scripts/internal/shared/collect-runtime-artifacts.mjs"); +const workflow = YAML.parse(await readFile(join(root, ".github/workflows/runtime-artifacts.yml"), "utf8")); +const roots = []; +afterEach(async () => { for (const directory of roots.splice(0)) await rm(directory, { recursive: true, force: true }); }); + +it("collects all target artifacts and rejects corrupted downloads", async () => { + const directory = await mkdtemp(join(tmpdir(), "runtime-artifact-test-")); + roots.push(directory); + const targets = workflow.jobs.build.strategy.matrix.include.map((entry) => entry.target); + expect(new Set(targets)).toEqual(new Set(["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "windows-arm64", "windows-x64"])); + let corrupted; + for (const target of targets) { + const input = join(directory, "input", `runtimes-${target}`); + await mkdir(input, { recursive: true }); + for (const kind of ["agent", "memory"]) { + const manifestPath = kind === "agent" ? "App/memmy-agent/package.json" : "Memory/package.json"; + const version = JSON.parse(await readFile(join(root, manifestPath))).version; + const name = `memmy-${kind}-runtime-${version}-${target}.${kind === "agent" ? "tgz" : "tar.gz"}`; + const bytes = Buffer.from(`${kind}:${target}`); + corrupted = join(input, name); + await writeFile(corrupted, bytes); + const metadata = { version, assets: { [target]: { name, size: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") } } }; + await writeFile(join(input, kind === "agent" ? `agent-release-${target}.json` : "memory-release.json"), JSON.stringify(metadata)); + } + } + const args = [collector, join(directory, "input"), join(directory, "output")]; + execFileSync(process.execPath, args); + expect((await readdir(join(directory, "output"))).length).toBe(14); + const manifest = JSON.parse(await readFile(join(directory, "output/runtime-artifacts.json"))); + expect(Object.keys(manifest.runtimes.agent.assets)).toHaveLength(6); + expect(Object.keys(manifest.runtimes.memory.assets)).toHaveLength(6); + await writeFile(corrupted, "corrupted"); + expect(() => execFileSync(process.execPath, args, { stdio: "pipe" })).toThrow(); +}); From 7ec2e5ec56ea590ea47028c4f50cdd614466fd13 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Wed, 14 Oct 2026 17:37:32 +0800 Subject: [PATCH 29/56] Keep installed Memmy CLI directly executable The npm package assembly copies the compiled distribution into a staging directory where the entrypoint mode is not guaranteed to remain executable. Explicitly restore mode 0755 after the copy so global npm bin links can invoke Memmy without a wrapper. Constraint: The npm bin target must be executable after package staging on Linux Rejected: Rely on source file mode preservation | recursive copy does not make the packaging invariant explicit Confidence: high Scope-risk: narrow Directive: Keep the executable-bit assertion in packaging verification when changing the CLI entrypoint Tested: npm run package:npm; packaged dist/main.js mode is 755; packaged CLI --version succeeds; git diff --check Not-tested: Cross-platform npm installation outside Linux --- App/memmy-agent/scripts/build-npm-package.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/App/memmy-agent/scripts/build-npm-package.mjs b/App/memmy-agent/scripts/build-npm-package.mjs index ef6432e0d..1436f6659 100644 --- a/App/memmy-agent/scripts/build-npm-package.mjs +++ b/App/memmy-agent/scripts/build-npm-package.mjs @@ -1,4 +1,4 @@ -import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { chmod, 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"; @@ -54,6 +54,7 @@ const packageManifest = { await writeFile(join(outputRoot, "package.json"), `${JSON.stringify(packageManifest, null, 2)}\n`); await cp(join(agentRoot, "dist"), join(outputRoot, "dist"), { recursive: true }); +await chmod(join(outputRoot, "dist", "main.js"), 0o755); await cp(join(agentRoot, ".env.example"), join(outputRoot, ".env.example")); await cp(join(agentRoot, "README.md"), join(outputRoot, "README.md")); From f030dbabdad5be0121d9db7d68af7707a057898a Mon Sep 17 00:00:00 2001 From: antalike <> Date: Wed, 14 Oct 2026 17:42:29 +0800 Subject: [PATCH 30/56] feat(auth): enable Google and GitHub social login Render the social sign-in choices again and give the cloud oauth start/status calls a 20s floor so a slow provider redirect does not trip the 5s default timeout. --- .../cloud-client/http-cloud-client.ts | 9 +++-- .../cloud-client/tests/cloud-client.test.ts | 33 +++++++++++++++++++ .../src/components/social-login-buttons.tsx | 4 +-- .../tests/social-login-buttons.test.tsx | 7 ++-- 4 files changed, 44 insertions(+), 9 deletions(-) diff --git a/App/backend/src/adapters/outbound/cloud-client/http-cloud-client.ts b/App/backend/src/adapters/outbound/cloud-client/http-cloud-client.ts index 6eb1c4e66..f9e5d0aed 100644 --- a/App/backend/src/adapters/outbound/cloud-client/http-cloud-client.ts +++ b/App/backend/src/adapters/outbound/cloud-client/http-cloud-client.ts @@ -68,6 +68,7 @@ import type { } from "./types.js"; const DEFAULT_TIMEOUT_MS = 5_000; +const CLOUD_SOCIAL_LOGIN_TIMEOUT_MS = 20_000; const CLOUD_COMPOSIO_ROUTER_TIMEOUT_MS = 60_000; const CLOUD_COMPOSIO_UNAVAILABLE_MESSAGE = "工具连接服务暂时不可用"; const CLOUD_COMPOSIO_SERVICE_UNAVAILABLE_CODE = 60020; @@ -147,7 +148,7 @@ export function createHttpCloudClient(options: CreateHttpCloudClientOptions = {} const data = await requestCloudData>( fetchImpl, baseUrl, - timeoutMs, + socialLoginTimeoutMs(timeoutMs), "/api/agentUser/oauth/start", { body: { @@ -169,7 +170,7 @@ export function createHttpCloudClient(options: CreateHttpCloudClientOptions = {} const data = await requestCloudData>( fetchImpl, baseUrl, - timeoutMs, + socialLoginTimeoutMs(timeoutMs), "/api/agentUser/oauth/status", { body: { flowId: input.flowId, pollToken: input.pollToken }, @@ -532,6 +533,10 @@ async function requestCloudData( return envelope.data as T; } +function socialLoginTimeoutMs(timeoutMs: number): number { + return Math.max(timeoutMs, CLOUD_SOCIAL_LOGIN_TIMEOUT_MS); +} + function normalizeAgentRegion(value: string | undefined): "cn" | "intl" { return value?.trim().toLowerCase() === "intl" ? "intl" : "cn"; } diff --git a/App/backend/src/adapters/outbound/cloud-client/tests/cloud-client.test.ts b/App/backend/src/adapters/outbound/cloud-client/tests/cloud-client.test.ts index 498655008..ffea27e3f 100644 --- a/App/backend/src/adapters/outbound/cloud-client/tests/cloud-client.test.ts +++ b/App/backend/src/adapters/outbound/cloud-client/tests/cloud-client.test.ts @@ -222,6 +222,39 @@ describe("cloud client", () => { ]); }); + it("keeps waiting for Google login after the default cloud timeout", async () => { + server = createServer((_request, response) => { + setTimeout(() => { + sendJson(response, { + code: 0, + message: "ok", + data: { + flowId: "social-flow-id-0001", + pollToken: "social-poll-token-0000000000000001", + authorizationUrl: "https://accounts.google.com/o/oauth2/v2/auth?state=opaque-state", + expiresInSec: 600, + pollIntervalSec: 2 + } + }); + }, 1_500); + }); + await listen(server); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Mock cloud server did not bind"); + const client = createHttpCloudClient({ + baseUrl: `http://127.0.0.1:${address.port}`, + timeoutMs: 200 + }); + + const start = await client.startSocialLogin({ + provider: "google", + locale: "en", + loginSource: "Memmy" + }); + + expect(start.authorizationUrl).toContain("https://accounts.google.com/"); + }); + it("rejects a social-login authorization URL outside the provider allowlist", async () => { server = createServer((_request, response) => { sendJson(response, { diff --git a/App/frontend/desktop/src/components/social-login-buttons.tsx b/App/frontend/desktop/src/components/social-login-buttons.tsx index 45bdd7e46..5b3ce1acc 100644 --- a/App/frontend/desktop/src/components/social-login-buttons.tsx +++ b/App/frontend/desktop/src/components/social-login-buttons.tsx @@ -3,9 +3,7 @@ import type { SocialLoginProvider } from "@memmy/local-api-contracts"; import type { ReactNode } from "react"; import { useTranslation } from "../i18n/use-translation.js"; -// Keep the integration code available, but do not render social login until the -// production providers are ready to be exposed again. -const SOCIAL_LOGIN_BUTTONS_ENABLED = false; +const SOCIAL_LOGIN_BUTTONS_ENABLED = true; export interface SocialLoginButtonsProps { pendingProvider: SocialLoginProvider | null; diff --git a/App/frontend/desktop/src/components/tests/social-login-buttons.test.tsx b/App/frontend/desktop/src/components/tests/social-login-buttons.test.tsx index 909a562a0..6cd8aef10 100644 --- a/App/frontend/desktop/src/components/tests/social-login-buttons.test.tsx +++ b/App/frontend/desktop/src/components/tests/social-login-buttons.test.tsx @@ -4,7 +4,7 @@ import { I18nProvider } from "../../i18n/i18n-provider.js"; import { SocialLoginButtons } from "../social-login-buttons.js"; describe("SocialLoginButtons", () => { - it("does not render social login choices while the feature is disabled", () => { + it("renders Google and GitHub sign-in choices", () => { const onLogin = vi.fn(); const html = renderToString( @@ -12,9 +12,8 @@ describe("SocialLoginButtons", () => { ); - expect(html).toBe(""); - expect(html).not.toContain("Continue with Google"); - expect(html).not.toContain("Continue with GitHub"); + expect(html).toContain("Continue with Google"); + expect(html).toContain("Continue with GitHub"); expect(onLogin).not.toHaveBeenCalled(); }); }); From 374a4e5c9f119559a88674a3892563405791364c Mon Sep 17 00:00:00 2001 From: ZongYue Date: Wed, 14 Oct 2026 17:55:40 +0800 Subject: [PATCH 31/56] feat: build all Memory runtime platforms with one command --- .github/workflows/runtime-artifacts.yml | 24 ++++--- Memory/package.json | 1 + package.json | 1 + scripts/internal/shared/build-memory-all.mjs | 69 +++++++++++++++++++ .../shared/collect-runtime-artifacts.mjs | 8 ++- .../shared/verify-runtime-artifacts.mjs | 24 ++++--- tests/runtime-artifacts.test.mjs | 62 ++++++++++++++++- 7 files changed, 169 insertions(+), 20 deletions(-) create mode 100644 scripts/internal/shared/build-memory-all.mjs diff --git a/.github/workflows/runtime-artifacts.yml b/.github/workflows/runtime-artifacts.yml index 8129087cb..7854d5629 100644 --- a/.github/workflows/runtime-artifacts.yml +++ b/.github/workflows/runtime-artifacts.yml @@ -1,10 +1,16 @@ name: Agent and Memory runtime artifacts +run-name: runtime-artifacts-${{ github.ref_name }} on: workflow_dispatch: + inputs: + runtime: + description: Runtime packages to build + type: choice + options: [both, memory] + default: both push: - branches: [zy_runtime_artifacts] - + branches: [zy_memory_runtime_*] permissions: contents: read @@ -51,15 +57,17 @@ jobs: architecture: ${{ matrix.arch }} cache: npm - name: Install build dependencies - run: | - npm ci --no-audit --no-fund - npm --prefix App/memmy-agent install --ignore-scripts --no-audit --no-fund + run: npm ci --no-audit --no-fund + - name: Install Agent build dependencies + if: github.event_name == 'workflow_dispatch' && inputs.runtime != 'memory' + run: npm --prefix App/memmy-agent install --ignore-scripts --no-audit --no-fund - name: Build Agent runtime + if: github.event_name == 'workflow_dispatch' && inputs.runtime != 'memory' run: npm run agent:runtime:package -- --target "${{ matrix.target }}" --output "$RUNNER_TEMP/runtime-artifacts" - name: Build Memory runtime run: npm run runtime:package -w @memmy/memory -- --target "${{ matrix.target }}" --output "$RUNNER_TEMP/runtime-artifacts" - name: Verify unpacked runtimes and native dependencies - run: node scripts/internal/shared/verify-runtime-artifacts.mjs "$RUNNER_TEMP/runtime-artifacts" "${{ matrix.target }}" + run: node scripts/internal/shared/verify-runtime-artifacts.mjs "$RUNNER_TEMP/runtime-artifacts" "${{ matrix.target }}" "${{ inputs.runtime || 'memory' }}" - uses: actions/upload-artifact@v4 with: name: runtimes-${{ matrix.target }} @@ -82,8 +90,8 @@ jobs: with: pattern: runtimes-* path: runtime-input - - name: Collect all twelve archives with checksums - run: node scripts/internal/shared/collect-runtime-artifacts.mjs runtime-input runtime-output + - name: Collect runtime archives with checksums + run: node scripts/internal/shared/collect-runtime-artifacts.mjs runtime-input runtime-output "${{ inputs.runtime || 'memory' }}" - uses: actions/upload-artifact@v4 with: name: all-platform-runtimes diff --git a/Memory/package.json b/Memory/package.json index d6d7845ad..a9760f538 100644 --- a/Memory/package.json +++ b/Memory/package.json @@ -29,6 +29,7 @@ "pack:npm": "npm run package:npm && npm pack ./dist/memmy-memory-npm", "binary": "bash src/cli/scripts/build-binary.sh", "runtime:package": "node src/cli/scripts/build-runtime.mjs", + "runtime:package:all": "node ../scripts/internal/shared/build-memory-all.mjs", "release:assemble": "node src/cli/scripts/assemble-release.mjs", "standalone:package": "node src/cli/scripts/build-standalone.mjs" }, diff --git a/package.json b/package.json index 9b0cfb045..383e21434 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "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", + "memory:runtime:package:all": "node scripts/internal/shared/build-memory-all.mjs", "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", diff --git a/scripts/internal/shared/build-memory-all.mjs b/scripts/internal/shared/build-memory-all.mjs new file mode 100644 index 000000000..a7a7af4be --- /dev/null +++ b/scripts/internal/shared/build-memory-all.mjs @@ -0,0 +1,69 @@ +import { createHash, randomUUID } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { mkdir, readFile, stat } from "node:fs/promises"; +import { spawnSync } from "node:child_process"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { setTimeout as delay } from "node:timers/promises"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); +const workflow = "runtime-artifacts.yml"; +const targets = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "windows-arm64", "windows-x64"]; + +function command(executable, args, live = false) { + const result = spawnSync(executable, args, { cwd: repoRoot, encoding: "utf8", stdio: live ? "inherit" : "pipe" }); + if (result.status !== 0) throw new Error(`${executable} ${args.join(" ")} failed: ${result.error?.message ?? result.stderr ?? "see output above"}`); + return result.stdout?.trim() ?? ""; +} + +export async function buildMemoryAll({ run = command, wait = delay } = {}) { + if (run("git", ["status", "--porcelain"])) throw new Error("Commit your local changes before building all platforms."); + const sha = run("git", ["rev-parse", "HEAD"]); + const remote = run("git", ["remote", "get-url", "origin"]); + const repository = run("gh", ["repo", "view", remote, "--json", "nameWithOwner", "--jq", ".nameWithOwner"]); + const branch = `zy_memory_runtime_${randomUUID()}`; + console.log(`Uploading committed source ${sha} to ${repository} (${branch})`); + run("git", ["push", "origin", `${sha}:refs/heads/${branch}`], true); + let build; + for (let attempt = 0; attempt < 24; attempt++) { + const runs = JSON.parse(run("gh", ["run", "list", "--repo", repository, "--workflow", workflow, "--branch", branch, "--event", "push", "--limit", "50", "--json", "databaseId,displayTitle,headSha,url"])); + build = runs.find((entry) => entry.headSha === sha); + if (build) break; + await wait(5000); + } + if (!build) throw new Error(`Build was pushed but was not found yet. Check Actions in ${repository}, branch ${branch}.`); + console.log(`Building six Memory runtimes: ${build.url}`); + run("gh", ["run", "watch", String(build.databaseId), "--repo", repository, "--exit-status", "--interval", "15"], true); + const output = join(repoRoot, "Memory/dist/releases", `all-${build.databaseId}`); + await mkdir(output, { recursive: true }); + run("gh", ["run", "download", String(build.databaseId), "--repo", repository, "--name", "all-platform-runtimes", "--dir", output], true); + await verifyMemoryDownload(output, sha); + try { run("git", ["push", "origin", "--delete", branch], true); } + catch (error) { console.warn(`Artifacts are complete; temporary branch cleanup failed: ${error.message}`); } + console.log(`Six Memory runtime archives verified: ${output}`); + return output; +} + +export async function verifyMemoryDownload(output, sha) { + const manifest = JSON.parse(await readFile(join(output, "runtime-artifacts.json"), "utf8")); + const memory = manifest.runtimes?.memory; + if (manifest.sourceCommit !== sha || Object.keys(manifest.runtimes ?? {}).join() !== "memory" || + Object.keys(memory?.assets ?? {}).sort().join() !== [...targets].sort().join()) { + throw new Error("Downloaded manifest does not match the requested commit and six Memory targets."); + } + for (const target of targets) { + const asset = memory.assets[target]; + if (asset.name !== `memmy-memory-runtime-${memory.version}-${target}.tar.gz` || !/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(memory.version)) { + throw new Error(`Invalid Memory artifact name: ${target}`); + } + const file = join(output, asset.name); + const hash = createHash("sha256"); + for await (const chunk of createReadStream(file)) hash.update(chunk); + if (hash.digest("hex") !== asset.sha256 || (await stat(file)).size !== asset.size) throw new Error(`Checksum or size mismatch: ${asset.name}`); + } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { await buildMemoryAll(); } + catch (error) { console.error(error.message); process.exitCode = 1; } +} diff --git a/scripts/internal/shared/collect-runtime-artifacts.mjs b/scripts/internal/shared/collect-runtime-artifacts.mjs index 6bf5e513f..390ca422d 100644 --- a/scripts/internal/shared/collect-runtime-artifacts.mjs +++ b/scripts/internal/shared/collect-runtime-artifacts.mjs @@ -5,13 +5,17 @@ import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const repo = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); -const [input, output] = process.argv.slice(2).map((value) => resolve(value)); +const [inputPath, outputPath, selection = "both"] = process.argv.slice(2); +if (!inputPath || !outputPath || !["both", "memory"].includes(selection)) { + throw new Error("Usage: collect-runtime-artifacts.mjs [both|memory]"); +} +const [input, output] = [inputPath, outputPath].map((value) => resolve(value)); if (!input || !output || input === output) throw new Error("Usage: collect-runtime-artifacts.mjs "); const targets = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "windows-arm64", "windows-x64"]; const manifest = { sourceCommit: process.env.GITHUB_SHA ?? null, nodeVersion: process.versions.node, nodeAbi: process.versions.modules, runtimes: {} }; const checksums = []; await mkdir(output, { recursive: true }); -for (const kind of ["agent", "memory"]) { +for (const kind of selection === "memory" ? ["memory"] : ["agent", "memory"]) { const packageFile = kind === "agent" ? "App/memmy-agent/package.json" : "Memory/package.json"; const version = JSON.parse(await readFile(join(repo, packageFile))).version; const assets = {}; diff --git a/scripts/internal/shared/verify-runtime-artifacts.mjs b/scripts/internal/shared/verify-runtime-artifacts.mjs index 80cedf5e3..a5bbd37b7 100644 --- a/scripts/internal/shared/verify-runtime-artifacts.mjs +++ b/scripts/internal/shared/verify-runtime-artifacts.mjs @@ -10,6 +10,8 @@ import { setTimeout as delay } from "node:timers/promises"; const repo = resolve(dirname(fileURLToPath(import.meta.url)), "../../.."); const directory = resolve(process.argv[2]); const target = process.argv[3]; +const selection = process.argv[4] ?? "both"; +if (!["both", "memory"].includes(selection)) throw new Error("Expected runtime selection: both or memory"); const hostTarget = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`; if (target !== hostTarget) throw new Error(`Expected ${target}, running on ${hostTarget}`); const agentVersion = JSON.parse(await readFile(join(repo, "App/memmy-agent/package.json"))).version; @@ -19,19 +21,23 @@ let service; let serviceExit; let logs = ""; try { - for (const [kind, version, extension] of [["agent", agentVersion, "tgz"], ["memory", memoryVersion, "tar.gz"]]) { + const runtimes = [["memory", memoryVersion, "tar.gz"]]; + if (selection === "both") runtimes.unshift(["agent", agentVersion, "tgz"]); + for (const [kind, version, extension] of runtimes) { await mkdir(join(root, kind)); run("tar", ["-xzf", join(directory, `memmy-${kind}-runtime-${version}-${target}.${extension}`), "-C", join(root, kind)]); } const agentRoot = join(root, "agent/package"); - const agent = JSON.parse(await readFile(join(agentRoot, "agent-runtime.json"))); - if (agent.target !== target || agent.version !== agentVersion || agent.nodeAbi !== process.versions.modules) { - throw new Error("Agent runtime metadata does not match the runner"); + if (selection === "both") { + const agent = JSON.parse(await readFile(join(agentRoot, "agent-runtime.json"))); + if (agent.target !== target || agent.version !== agentVersion || agent.nodeAbi !== process.versions.modules) { + throw new Error("Agent runtime metadata does not match the runner"); + } + if (run(process.execPath, [join(agentRoot, "dist/main.js"), "--version"]).trim() !== agentVersion) { + throw new Error("Agent runtime version check failed"); + } } - if (run(process.execPath, [join(agentRoot, "dist/main.js"), "--version"]).trim() !== agentVersion) { - throw new Error("Agent runtime version check failed"); - } - for (const packageRoot of [agentRoot, join(root, "memory")]) { + for (const packageRoot of selection === "memory" ? [join(root, "memory")] : [agentRoot, join(root, "memory")]) { const require = createRequire(join(packageRoot, "package.json")); const Database = require("better-sqlite3"); const database = new Database(":memory:"); @@ -61,7 +67,7 @@ try { await delay(200); } if (!healthy) throw new Error(`Memory runtime failed health check: ${logs}`); - console.log(`Verified Agent ${agentVersion}, Memory ${memoryVersion}, target ${target}, Node ABI ${process.versions.modules}`); + console.log(`Verified ${selection === "both" ? `Agent ${agentVersion}, ` : ""}Memory ${memoryVersion}, target ${target}, Node ABI ${process.versions.modules}`); } finally { if (service) { service.kill(); diff --git a/tests/runtime-artifacts.test.mjs b/tests/runtime-artifacts.test.mjs index 634b9d219..6e8379d76 100644 --- a/tests/runtime-artifacts.test.mjs +++ b/tests/runtime-artifacts.test.mjs @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { afterEach, expect, it } from "vitest"; import YAML from "yaml"; +import { buildMemoryAll, verifyMemoryDownload } from "../scripts/internal/shared/build-memory-all.mjs"; const root = resolve(import.meta.dirname, ".."); const collector = join(root, "scripts/internal/shared/collect-runtime-artifacts.mjs"); @@ -38,6 +39,65 @@ it("collects all target artifacts and rejects corrupted downloads", async () => const manifest = JSON.parse(await readFile(join(directory, "output/runtime-artifacts.json"))); expect(Object.keys(manifest.runtimes.agent.assets)).toHaveLength(6); expect(Object.keys(manifest.runtimes.memory.assets)).toHaveLength(6); + for (const target of targets) { + const input = join(directory, "input", `runtimes-${target}`); + for (const name of await readdir(input)) if (name.includes("agent")) await rm(join(input, name)); + } + const memoryOutput = join(directory, "memory-output"); + const memoryArgs = [collector, join(directory, "input"), memoryOutput, "memory"]; + execFileSync(process.execPath, memoryArgs, { env: { ...process.env, GITHUB_SHA: "test-commit" } }); + expect(await readdir(memoryOutput)).toHaveLength(8); + await verifyMemoryDownload(memoryOutput, "test-commit"); + await expect(verifyMemoryDownload(memoryOutput, "other-commit")).rejects.toThrow("requested commit"); + await writeFile(join(memoryOutput, (await readdir(memoryOutput)).find((name) => name.endsWith("tar.gz"))), "corrupt download"); + await expect(verifyMemoryDownload(memoryOutput, "test-commit")).rejects.toThrow("Checksum"); await writeFile(corrupted, "corrupted"); - expect(() => execFileSync(process.execPath, args, { stdio: "pipe" })).toThrow(); + expect(() => execFileSync(process.execPath, memoryArgs, { stdio: "pipe" })).toThrow(); +}); + +it("rejects local changes before uploading source", async () => { + await expect(buildMemoryAll({ run: () => " M package.json" })).rejects.toThrow("Commit your local changes"); +}); + +it("tracks the pushed build and propagates failure before downloading", async () => { + let branch; + let watched = false; + const calls = []; + await expect(buildMemoryAll({ + wait: async () => {}, + run: (command, args) => { + calls.push([command, ...args]); + if (command === "git") { + if (args[0] === "status") return ""; + if (args[0] === "rev-parse") return "sha"; + if (args[0] === "remote") return "https://github.com/owner/repo.git"; + if (args[0] === "push") { + expect(args[1]).toBe("origin"); + expect(args[2]).toMatch(/^sha:refs\/heads\/zy_memory_runtime_/); + branch = args[2].split("/").at(-1); + return ""; + } + } + if (args[0] === "repo") { + expect(args[2]).toBe("https://github.com/owner/repo.git"); + return "owner/repo"; + } + if (args[1] === "list") { + expect(args).toContain(branch); + expect(args).toContain("push"); + return JSON.stringify([ + { databaseId: 10, headSha: "other-sha" }, + { databaseId: 11, headSha: "sha", url: "https://example.test/run/11" }, + ]); + } + if (args[1] === "watch") { + expect(args[2]).toBe("11"); + watched = true; + throw new Error("Windows build failed"); + } + throw new Error("Unexpected command"); + }, + })).rejects.toThrow("Windows build failed"); + expect(watched).toBe(true); + expect(calls.some((args) => args.includes("download"))).toBe(false); }); From 7db6ad6c4a8f259b93d223b73378fd2bcf652aec Mon Sep 17 00:00:00 2001 From: lee Date: Wed, 14 Oct 2026 18:28:29 +0800 Subject: [PATCH 32/56] fix(runtime): skip third-party install scripts when packaging the agent runtime @tencent-connect/openclaw-qqbot's postinstall (node scripts/postinstall-link-sdk.js 2>/dev/null || true) is POSIX-only. Under Windows cmd.exe it fails unless a 'true' binary is on PATH, which aborts 'npm run agent:runtime:package -- --target windows-x64'. Install the staged package with --ignore-scripts and rebuild only better-sqlite3, the native module the runtime loads. Also retry removal of the temporary staging directory so transient Windows file locks do not mask the real error. Co-authored-by: Cursor --- App/memmy-agent/scripts/build-runtime.mjs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/App/memmy-agent/scripts/build-runtime.mjs b/App/memmy-agent/scripts/build-runtime.mjs index 4ca5bd08e..3b430807a 100644 --- a/App/memmy-agent/scripts/build-runtime.mjs +++ b/App/memmy-agent/scripts/build-runtime.mjs @@ -31,7 +31,12 @@ try { 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); + // Third-party lifecycle scripts are not needed to ship the runtime and some are POSIX-only + // (e.g. @tencent-connect/openclaw-qqbot runs `... 2>/dev/null || true`, which fails under + // Windows cmd.exe unless a `true` binary happens to be on PATH). Install without scripts, + // then run only the install script of the native module the runtime actually loads. + run("npm", ["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund"], packageRoot); + run("npm", ["rebuild", "better-sqlite3", "--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")); @@ -50,7 +55,8 @@ try { }); console.log(archive); } finally { - await rm(temporaryRoot, { recursive: true, force: true }); + // Windows may briefly lock freshly installed files (antivirus/indexer), so retry on EBUSY/EPERM. + await rm(temporaryRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 300 }); } function run(command, args, cwd) { From 195a8fad1a7e3f247ce426ef9ebf7fd32931e5ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=98=89?= Date: Thu, 15 Oct 2026 10:41:38 +0800 Subject: [PATCH 33/56] =?UTF-8?q?fix:=20=E4=BF=9D=E7=95=99=E8=BF=81?= =?UTF-8?q?=E7=A7=BB=E5=90=8E=E7=9A=84=E6=A8=A1=E5=9E=8B=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E5=B9=B6=E5=88=B7=E6=96=B0=E8=AE=B0=E5=BF=86=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/infrastructure/memmy-config/index.ts | 6 +- .../tests/account-model-projection.test.ts | 24 ++++++ .../src/services/app-config-service.ts | 8 ++ .../services/tests/app-config-service.test.ts | 75 ++++++++++++++++++- .../tests/runtime-config-sync-service.test.ts | 30 ++++++++ 5 files changed, 140 insertions(+), 3 deletions(-) diff --git a/App/backend/src/infrastructure/memmy-config/index.ts b/App/backend/src/infrastructure/memmy-config/index.ts index d04c1f30d..7abfc6407 100644 --- a/App/backend/src/infrastructure/memmy-config/index.ts +++ b/App/backend/src/infrastructure/memmy-config/index.ts @@ -585,7 +585,11 @@ export async function writeAccountModelProjectionToMemmyConfig( appConfig, LEGACY_ACCOUNT_BYOK_LOCAL_SELECTION_BASELINE ); - appConfig.userMode = "account"; + // Projection repair also runs during migration; preserve an explicit BYOK choice. + const userMode = existingString(appConfig.userMode); + if (!userMode || userMode === "unset") { + 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 61031302e..ac3ae2914 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 @@ -86,6 +86,30 @@ function currentByokCatalog(): Record { } describe("account model projection current catalog", () => { + it.each([ + [undefined, "account"], + ["", "account"], + ["unset", "account"], + ["account", "account"], + ["byok", "byok"] + ])("preserves an explicit user mode when repairing the projection (%s -> %s)", async (userMode, expectedMode) => { + const initial = currentByokCatalog(); + const file = await configFile({ ...initial, app: { userMode } }); + await writeAccountModelProjectionToMemmyConfig({ cloudUuid: "cloud-token", userId: "owner-a" }, file); + + const saved = await readConfig(file); + expect(saved.app.userMode).toBe(expectedMode); + expect(saved.providers.openai).toEqual((initial.providers as any).openai); + expect(saved.modelAssignments.byok).toEqual((initial.modelAssignments as any).byok); + expect(saved.providers.memmy_account.ownerAccountId).toBe("owner-a"); + expect(saved.modelAssignments.account.agent.candidates).toContain(accountId("owner-a", "agent")); + const snapshot = await readFile(file, "utf8"); + await expect(writeAccountModelProjectionToMemmyConfig({ + cloudUuid: "cloud-token", userId: "owner-a" + }, file)).resolves.toMatchObject({ changed: false }); + expect(await readFile(file, "utf8")).toBe(snapshot); + }); + it("creates an owner-scoped Provider, endpoint, six presets, and isolated assignment", async () => { const file = await configFile(currentByokCatalog()); const beforeByok = (await readConfig(file)).modelAssignments.byok; diff --git a/App/backend/src/services/app-config-service.ts b/App/backend/src/services/app-config-service.ts index c69a03335..ec2330727 100644 --- a/App/backend/src/services/app-config-service.ts +++ b/App/backend/src/services/app-config-service.ts @@ -115,6 +115,14 @@ export function createAppConfigService(options: CreateAppConfigServiceOptions): }); await options.memoryClient?.reloadConfig({ reason: "memory_token_budget_saved" }); } + if (input.userMode) { + try { + await options.memoryClient?.reloadConfig({ reason: "user_mode_saved" }); + } catch { + // Settings are saved; saving the mode again or restarting Memory can retry the reload. + console.warn("Memory config reload failed after user mode was saved"); + } + } preserveCompletedGuideWhenSwitchingToByok(previousOnboarding, options); return settings; }, diff --git a/App/backend/src/services/tests/app-config-service.test.ts b/App/backend/src/services/tests/app-config-service.test.ts index caf963eff..6b811adbf 100644 --- a/App/backend/src/services/tests/app-config-service.test.ts +++ b/App/backend/src/services/tests/app-config-service.test.ts @@ -1,9 +1,9 @@ /** App config service tests. */ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import YAML from "yaml"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createAppConfigService } from "../app-config-service.js"; import { createAppStateStore } from "../../infrastructure/app-state-store/index.js"; import { createMemmyConfigWriter } from "../../infrastructure/memmy-config/index.js"; @@ -16,6 +16,77 @@ import type { } from "@memmy/local-api-contracts"; describe("AppConfigService", () => { + it.each(["byok", "account"] as const)("reloads Memory after persisting %s mode to YAML and SQLite", async (userMode) => { + const root = mkdtempSync(join(tmpdir(), "memmy-mode-reload-")); + const configPath = join(root, "config.yaml"); + const store = createAppStateStore({ databasePath: join(root, "app.sqlite") }); + try { + const observations: unknown[] = []; + const reloadConfig = vi.fn(async (input) => { + observations.push({ + input, + yamlMode: YAML.parse(readFileSync(configPath, "utf8")).app.userMode, + databaseMode: store.repositories.bootstrap.getAppSettings().userMode + }); + }); + const service = createAppConfigService({ + bootstrapRepository: store.repositories.bootstrap, + memmyConfigWriter: createMemmyConfigWriter({ configPath }), + memoryClient: { reloadConfig } + }); + await expect(service.updateSettings({ userMode })).resolves.toMatchObject({ userMode }); + expect(observations).toEqual([{ + input: { reason: "user_mode_saved" }, yamlMode: userMode, databaseMode: userMode + }]); + + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + reloadConfig.mockRejectedValueOnce(new Error("Memory unavailable")); + await expect(service.updateSettings({ userMode })).resolves.toMatchObject({ userMode }); + expect(warning).toHaveBeenCalledWith("Memory config reload failed after user mode was saved"); + expect(YAML.parse(readFileSync(configPath, "utf8")).app.userMode).toBe(userMode); + expect(store.repositories.bootstrap.getAppSettings().userMode).toBe(userMode); + // Saving the same mode again must retry the failed reload. + await service.updateSettings({ userMode }); + expect(reloadConfig).toHaveBeenCalledTimes(3); + expect(observations).toHaveLength(2); + } finally { + warning.mockRestore(); + } + } finally { + store.close(); + rmSync(root, { recursive: true, force: true }); + } + }); + + it("does not reload Memory when only appearance settings change", async () => { + const reloadConfig = vi.fn(); + const service = createAppConfigService({ + bootstrapRepository: { + ...createBootstrapRepositoryStub(), + updateAppSettings: (patch) => appSettings(patch) + }, + memoryClient: { reloadConfig } + }); + await service.updateSettings({ theme: "dark" }); + expect(reloadConfig).not.toHaveBeenCalled(); + }); + + it("does not update the database or reload Memory if saving the runtime mode fails", async () => { + const updateAppSettings = vi.fn(); + const reloadConfig = vi.fn(); + const writer = createMemmyConfigWriter(); + vi.spyOn(writer, "writeUserMode").mockRejectedValueOnce(new Error("Config not writable")); + const service = createAppConfigService({ + bootstrapRepository: { ...createBootstrapRepositoryStub(), updateAppSettings }, + memmyConfigWriter: writer, + memoryClient: { reloadConfig } + }); + await expect(service.updateSettings({ userMode: "account" })).rejects.toThrow("Config not writable"); + expect(updateAppSettings).not.toHaveBeenCalled(); + expect(reloadConfig).not.toHaveBeenCalled(); + }); + it("updates app settings through the bootstrap repository", async () => { const calls: unknown[] = []; const service = createAppConfigService({ 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 1c941259b..f9d52f602 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 @@ -612,6 +612,36 @@ describe("syncRuntimeConfigWithAppState", () => { expect(saved.modelAssignments.account.ownerAccountId).toBe("owner-a"); }); + it.each([false, true])("preserves explicit BYOK while repairing a migrated account projection (runtime migrated: %s)", async (runtimeSourceWasMigrated) => { + const context = createContext(); + seedAccountSession(context); + const byok = currentByokCatalog() as any; + context.writeConfig({ ...byok, app: { ...byok.app, userMode: "byok" } }); + + const options = { + ...context, + accountChannel: "email" as const, + migrationConsistency: { + accountSourceIsAuthoritative: true, + runtimeSourceWasMigrated, + categorySourcesShareGeneration: false + } + }; + await expect(syncRuntimeConfigWithAppState(options)).resolves.toMatchObject({ + mode: "byok", provider: "openai", model: "gpt-5", hydratedAppState: true + }); + const saved = YAML.parse(readFileSync(context.memmyConfigPath, "utf8")); + expect(saved.app.userMode).toBe("byok"); + expect(saved.providers.openai).toEqual(byok.providers.openai); + expect(saved.modelAssignments.byok).toEqual(byok.modelAssignments.byok); + expect(saved.providers.memmy_account).toMatchObject({ ownerAccountId: "owner-a", apiKey: "cloud-token-a" }); + expect(context.store.repositories.bootstrap.getAppSettings().userMode).toBe("byok"); + expect(context.store.repositories.accountSession.get()).toMatchObject({ authenticated: true }); + const snapshot = readFileSync(context.memmyConfigPath, "utf8"); + await expect(syncRuntimeConfigWithAppState(options)).resolves.toMatchObject({ mode: "byok", wroteConfig: false }); + expect(readFileSync(context.memmyConfigPath, "utf8")).toBe(snapshot); + }); + it("restores the active account after an upgrade left active_uuid empty", async () => { const context = createContext(); seedAccountSession(context); From 0192ab8ceda2389a21fb4ae626e09e7d068152b4 Mon Sep 17 00:00:00 2001 From: whqf1123 <333792770+whqf1123@users.noreply.github.com> Date: Sat, 10 Oct 2026 14:29:28 +0800 Subject: [PATCH 34/56] fix: guard embedding inputs and isolate rejected batches (MEMMY-488) --- Memory/readme.md | 24 ++++- Memory/src/cli/legacy-migration.ts | 1 + Memory/src/config/index.ts | 3 + Memory/src/model/embedder.ts | 2 +- Memory/src/model/openai-embedding-inputs.ts | 67 ++++++++++--- Memory/src/service/worker/job-handlers.ts | 4 + Memory/src/service/worker/worker-runner.ts | 16 ++- Memory/tests/config.test.ts | 6 +- Memory/tests/embedding-input-guard.test.ts | 50 ++++++++++ Memory/tests/memmy-488-reproduction.test.ts | 98 +++++++++++++++++++ .../embedding/embedding-processing.test.ts | 44 +++++++-- 11 files changed, 282 insertions(+), 33 deletions(-) create mode 100644 Memory/tests/embedding-input-guard.test.ts create mode 100644 Memory/tests/memmy-488-reproduction.test.ts diff --git a/Memory/readme.md b/Memory/readme.md index 624f19346..8a5279407 100644 --- a/Memory/readme.md +++ b/Memory/readme.md @@ -72,11 +72,25 @@ The `MEMMY_MEMORY_HOST`, `MEMMY_MEMORY_PORT`, and `MEMMY_MEMORY_DB` environment variables override the corresponding server settings. The `MEMORY_SERVICE_*` aliases are also accepted. -OpenAI-compatible embedding inputs are tokenized and split automatically with -a conservative 7,500-token per-input budget. Set -`memmyMemory.embedding.maxInputTokens` or -`MEMMY_EMBEDDING_MAX_INPUT_TOKENS` to use a smaller budget for a provider with -a shorter context window. +OpenAI-compatible embedding inputs are split without truncating the source text. +Known OpenAI embedding models use a 7,500-token per-input budget. Opaque aliases +(such as BGE deployments) use a 4,000-token estimate plus an independent 12,000-byte +UTF-8 cap, because the provider may use a different tokenizer. Splits preserve +Unicode characters; chunk vectors are length-weighted and combined into one +vector per original input. Set `memmyMemory.embedding.maxInputTokens` / +`MEMMY_EMBEDDING_MAX_INPUT_TOKENS`, or for opaque aliases +`memmyMemory.embedding.maxInputBytes` / `MEMMY_EMBEDDING_MAX_INPUT_BYTES`, to lower +these limits. Defaults are conservative guards, not guarantees of an unknown +provider's context window. Budgets too small for a complete character fail +locally without retrying or dropping text. + +Embedding workers isolate a rejected batch by trying each original item once +for input-related HTTP 400/413/422 failures, including generic parameter errors. +Successful siblings are saved; a still-invalid item stops automatic retries. +Authentication/configuration, quota, rate-limit and server errors do not trigger +this fan-out. This applies to both new jobs and the embedding retry queue. +Previously failed records require an explicit retry/reindex; updating the code +does not automatically reset their failed state. All remote summary-model calls (capture, reflection, long-turn splitting, reward scoring, retrieval filtering, and turn routing) are clipped before diff --git a/Memory/src/cli/legacy-migration.ts b/Memory/src/cli/legacy-migration.ts index 42b936bde..f38c93d0e 100644 --- a/Memory/src/cli/legacy-migration.ts +++ b/Memory/src/cli/legacy-migration.ts @@ -214,6 +214,7 @@ function mapEmbedding(value: Row): Row { model: string(value.model), apiKey: string(value.apiKey), maxInputTokens: finite(value.maxInputTokens) ?? undefined, + maxInputBytes: finite(value.maxInputBytes) ?? undefined, batchSize: finite(value.batchSize) ?? undefined, cache: typeof cache.enabled === "boolean" ? cache.enabled : undefined }); diff --git a/Memory/src/config/index.ts b/Memory/src/config/index.ts index a1e0af3f9..8809b20de 100644 --- a/Memory/src/config/index.ts +++ b/Memory/src/config/index.ts @@ -92,6 +92,7 @@ export interface EmbeddingConfig { actualModelContext?: ActualModelContext; selectionError?: "model_selection_unavailable"; maxInputTokens?: number; + maxInputBytes?: number; batchSize: number; timeoutMs: number; maxRetries: number; @@ -626,6 +627,7 @@ function configFromEnv(): Record { model: process.env.MEMMY_EMBEDDING_MODEL, apiKey: process.env.MEMMY_EMBEDDING_API_KEY, maxInputTokens: numberEnv("MEMMY_EMBEDDING_MAX_INPUT_TOKENS"), + maxInputBytes: numberEnv("MEMMY_EMBEDDING_MAX_INPUT_BYTES"), batchSize: numberEnv("MEMMY_EMBEDDING_BATCH_SIZE"), timeoutMs: numberEnv("MEMMY_EMBEDDING_TIMEOUT_MS"), maxRetries: numberEnv("MEMMY_EMBEDDING_MAX_RETRIES") @@ -797,6 +799,7 @@ function normalizeEmbedding(input: Record): EmbeddingConfig { ? input.selectionError : undefined, maxInputTokens: positiveInteger(input.maxInputTokens), + maxInputBytes: positiveInteger(input.maxInputBytes), batchSize: numberValue(input.batchSize, DEFAULT_MEMMY_CONFIG.embedding.batchSize), timeoutMs: numberValue(input.timeoutMs, DEFAULT_MEMMY_CONFIG.embedding.timeoutMs), maxRetries: numberValue(input.maxRetries, DEFAULT_MEMMY_CONFIG.embedding.maxRetries), diff --git a/Memory/src/model/embedder.ts b/Memory/src/model/embedder.ts index 70abd20dd..b07468d43 100644 --- a/Memory/src/model/embedder.ts +++ b/Memory/src/model/embedder.ts @@ -237,7 +237,7 @@ class HttpEmbedder implements Embedder { throw new Error(`${provider} embedding provider requires apiKey or endpoint`); } const plan = provider === "openai_compatible" - ? planOpenAiEmbeddingInputs(texts, this.config.model, this.config.maxInputTokens) + ? planOpenAiEmbeddingInputs(texts, this.config.model, this.config.maxInputTokens, this.config.maxInputBytes) : null; if (!plan) return this.requestOpenAiShape(texts, provider, url, role); diff --git a/Memory/src/model/openai-embedding-inputs.ts b/Memory/src/model/openai-embedding-inputs.ts index 1954b66a7..1aac54d74 100644 --- a/Memory/src/model/openai-embedding-inputs.ts +++ b/Memory/src/model/openai-embedding-inputs.ts @@ -2,6 +2,13 @@ import { get_encoding } from "tiktoken"; const OPENAI_EMBEDDING_INPUT_TOKEN_BUDGET = 7_500; const OPAQUE_EMBEDDING_INPUT_TOKEN_BUDGET = 4_000; +// An alias may use a different tokenizer. Bound bytes independently of our +// token estimate; this is a conservative default, not a provider context limit. +const OPAQUE_EMBEDDING_INPUT_BYTE_BUDGET = 12_000; + +export class EmbeddingInputLimitError extends Error { + override readonly name = "EmbeddingInputLimitError"; +} const OPENAI_EMBEDDING_BATCH_TOKEN_BUDGET = 290_000; export interface OpenAiEmbeddingChunk { @@ -22,7 +29,8 @@ let opaqueModelEncoder: ReturnType | undefined; export function planOpenAiEmbeddingInputs( texts: string[], model?: string, - configuredMaxInputTokens?: number + configuredMaxInputTokens?: number, + configuredMaxInputBytes?: number ): OpenAiEmbeddingPlan | null { const knownOpenAiModel = isKnownOpenAiEmbeddingModel(model); const inputTokenBudget = resolveInputTokenBudget(model, configuredMaxInputTokens); @@ -30,15 +38,21 @@ export function planOpenAiEmbeddingInputs( // use a different tokenizer (for example BGE-M3), so keep their chunks as // text and let the provider apply its native tokenizer. const useTokenIds = knownOpenAiModel; + const inputByteBudget = knownOpenAiModel ? Infinity : + Math.min(positiveBudget(configuredMaxInputBytes) ?? OPAQUE_EMBEDDING_INPUT_BYTE_BUDGET, + OPAQUE_EMBEDDING_INPUT_BYTE_BUDGET); const encoder = knownOpenAiModel ? (openAiEncoder ??= get_encoding("cl100k_base")) : (opaqueModelEncoder ??= get_encoding("o200k_base")); - const encoded = texts.map((text) => Array.from(encoder.encode(text, [], []))); - const totalTokens = encoded.reduce((sum, tokens) => sum + tokens.length, 0); - if (totalTokens <= OPENAI_EMBEDDING_BATCH_TOKEN_BUDGET && - encoded.every((tokens) => tokens.length <= inputTokenBudget)) return null; + const inputs = texts.flatMap((text, originalIndex) => + (useTokenIds ? [text] : splitUtf8Input(text, inputByteBudget)) + .map((part) => ({ originalIndex, tokens: Array.from(encoder.encode(part, [], [])) })) + ); + const totalTokens = inputs.reduce((sum, item) => sum + item.tokens.length, 0); + if (inputs.length === texts.length && totalTokens <= OPENAI_EMBEDDING_BATCH_TOKEN_BUDGET && + inputs.every((item) => item.tokens.length <= inputTokenBudget)) return null; - const chunks = encoded.flatMap((tokens, originalIndex) => { + const chunks = inputs.flatMap(({ tokens, originalIndex }) => { if (tokens.length === 0) return [{ originalIndex, tokens, input: useTokenIds ? tokens : "" }]; const tokenBytes = useTokenIds ? undefined @@ -46,11 +60,13 @@ export function planOpenAiEmbeddingInputs( const items: OpenAiEmbeddingChunk[] = []; for (let offset = 0; offset < tokens.length;) { let end = Math.min(tokens.length, offset + inputTokenBudget); - if (!useTokenIds && end < tokens.length) { - while (end > offset && startsWithContinuationByte(tokenBytes?.[end])) { - end -= 1; + if (!useTokenIds) { + // Never cut through a UTF-8 character, including when one character + // occupies several tokens. If it cannot fit, fail without sending it. + while (end > offset && end < tokens.length && startsWithContinuationByte(tokenBytes![end])) end -= 1; + if (end === offset) { + throw new EmbeddingInputLimitError("Embedding token budget is too small for one complete character; increase maxInputTokens."); } - if (end === offset) end = Math.min(tokens.length, offset + inputTokenBudget); } const chunkTokens = tokens.slice(offset, end); items.push({ @@ -96,10 +112,12 @@ function isKnownOpenAiEmbeddingModel(model?: string): boolean { return /(?:^|[/.:])text-embedding-(?:3-(?:small|large)|ada-002)(?:$|[/.:])/i.test(model?.trim() ?? ""); } +function positiveBudget(value?: number): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 1 ? Math.floor(value) : undefined; +} + function resolveInputTokenBudget(model?: string, configured?: number): number { - const explicit = typeof configured === "number" && Number.isFinite(configured) && configured > 0 - ? Math.floor(configured) - : undefined; + const explicit = positiveBudget(configured); const defaultBudget = isKnownOpenAiEmbeddingModel(model) ? OPENAI_EMBEDDING_INPUT_TOKEN_BUDGET : OPAQUE_EMBEDDING_INPUT_TOKEN_BUDGET; @@ -132,3 +150,26 @@ function batchChunks(chunks: OpenAiEmbeddingChunk[]): OpenAiEmbeddingChunk[][] { if (current.length > 0) batches.push(current); return batches; } + +function splitUtf8Input(text: string, budget: number): string[] { + if (Buffer.byteLength(text, "utf8") <= budget) return [text]; + const parts: string[] = []; + let offset = 0; + let start = 0; + let bytes = 0; + for (const character of text) { + const size = Buffer.byteLength(character, "utf8"); + if (size > budget) { + throw new EmbeddingInputLimitError("Embedding byte budget is too small for one complete character; increase maxInputBytes."); + } + if (bytes + size > budget) { + parts.push(text.slice(start, offset)); + start = offset; + bytes = 0; + } + offset += character.length; + bytes += size; + } + parts.push(text.slice(start)); + return parts; +} diff --git a/Memory/src/service/worker/job-handlers.ts b/Memory/src/service/worker/job-handlers.ts index 8eb2d8bc2..121dac284 100644 --- a/Memory/src/service/worker/job-handlers.ts +++ b/Memory/src/service/worker/job-handlers.ts @@ -1,3 +1,4 @@ +import { EmbeddingInputLimitError } from "../../model/openai-embedding-inputs.js"; /** * Worker job routing and durable write-back helpers. * @@ -583,6 +584,9 @@ export function classifyProcessingError(error: unknown): { code: string; retryAction: "retry" | "open_settings" | "none"; } { + if (error instanceof EmbeddingInputLimitError) { + return { code: "model_input_too_long", retryAction: "none" }; + } if (error instanceof ModelHttpError && error.errorCode === "40309") { return { code: "40309", retryAction: "open_settings" }; } diff --git a/Memory/src/service/worker/worker-runner.ts b/Memory/src/service/worker/worker-runner.ts index 20455e650..8aa5ebc6c 100644 --- a/Memory/src/service/worker/worker-runner.ts +++ b/Memory/src/service/worker/worker-runner.ts @@ -1,3 +1,4 @@ +import { ModelHttpError } from "../../model/http.js"; /** * Worker scheduling and execution domain. * @@ -545,8 +546,7 @@ export class WorkerRunner { } } } catch (error) { - const classification = classifyProcessingError(error); - if (classification.code === "model_input_too_long" && batch.length > 1) { + if (batch.length > 1 && shouldIsolateEmbeddingBatch(error)) { for (const item of batch) results.push(await this.runLeasedEmbeddingItem(item)); continue; } @@ -733,8 +733,7 @@ export class WorkerRunner { } } } catch (error) { - const classification = classifyProcessingError(error); - if (classification.code === "model_input_too_long" && batch.length > 1) { + if (batch.length > 1 && shouldIsolateEmbeddingBatch(error)) { for (const item of batch) { results.push(await this.runClaimedEmbeddingRetryItem(item.retry, item.claim, item.attemptNo)); } @@ -933,3 +932,12 @@ function embeddingRetryLogFields(retry: EmbeddingRetryRecord): Record { provider: "openai_compatible", endpoint: "https://embedding.example/v1", model: "deployment-alias", - maxInputTokens: 1_200 + maxInputTokens: 1_200, + maxInputBytes: 8_000 } } })); expect(loadMemmyConfig(configPath).config.embedding.maxInputTokens).toBe(1_200); + expect(loadMemmyConfig(configPath).config.embedding.maxInputBytes).toBe(8_000); + setEnv("MEMMY_EMBEDDING_MAX_INPUT_BYTES", "2048"); + expect(loadMemmyConfig(configPath).config.embedding.maxInputBytes).toBe(2048); setEnv("MEMMY_EMBEDDING_MAX_INPUT_TOKENS", "640"); expect(loadMemmyConfig(configPath).config.embedding.maxInputTokens).toBe(640); }); diff --git a/Memory/tests/embedding-input-guard.test.ts b/Memory/tests/embedding-input-guard.test.ts new file mode 100644 index 000000000..8e1f0453d --- /dev/null +++ b/Memory/tests/embedding-input-guard.test.ts @@ -0,0 +1,50 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { DEFAULT_MEMMY_CONFIG } from "../src/config/index.js"; +import { createEmbedder } from "../src/model/embedder.js"; + +afterEach(() => vi.unstubAllGlobals()); + +it.each([ + ["ASCII boundary", "a".repeat(27_447)], + ["mixed Unicode", "中文🙂 café\n".repeat(5_000)] +])("guards opaque-model %s inputs by bytes without truncation", async (_name, text) => { + const sent: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (_url, init) => { + const { input } = JSON.parse(String(init?.body)); + sent.push(...input); + if (input.some((part: string) => Buffer.byteLength(part, "utf8") > 12_000)) { + return new Response(JSON.stringify({ code: 20015, message: "The parameter is invalid." }), { status: 400 }); + } + return new Response(JSON.stringify({ data: input.map(() => ({ embedding: [1, 0] })) }), { status: 200 }); + })); + const embedder = createEmbedder({ ...DEFAULT_MEMMY_CONFIG.embedding, provider: "openai_compatible", + model: "Pro/BAAI/bge-m3", endpoint: "https://example.test/v1", cache: false, maxRetries: 0 }, + { usageRecorder: { record() { return "skipped"; } } }); + await expect(embedder.embedOne(text)).resolves.toEqual([1, 0]); + expect(sent.length).toBeGreaterThan(1); + expect(sent.every(part => Buffer.byteLength(part, "utf8") <= 12_000)).toBe(true); + expect(sent.join("")).toBe(text); +}); + +it("honors a smaller byte budget and keeps multi-byte characters intact", async () => { + const sent: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (_url, init) => { + const { input } = JSON.parse(String(init?.body)); sent.push(...input); + return new Response(JSON.stringify({ data: input.map(() => ({ embedding: [1, 0] })) }), { status: 200 }); + })); + const text = "a中🙂é".repeat(100); + const embedder = createEmbedder({ ...DEFAULT_MEMMY_CONFIG.embedding, provider: "openai_compatible", + model: "custom-alias", endpoint: "https://example.test/v1", maxInputBytes: 7, cache: false }, + { usageRecorder: { record() { return "skipped"; } } }); + await embedder.embedOne(text); + expect(sent.every(part => Buffer.byteLength(part) <= 7)).toBe(true); + expect(sent.join("")).toBe(text); +}); + +it("rejects an impossible character budget before making any HTTP request", async () => { + const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); + const embedder = createEmbedder({ ...DEFAULT_MEMMY_CONFIG.embedding, provider: "openai_compatible", + model: "custom-alias", endpoint: "https://example.test/v1", maxInputBytes: 1, cache: false }); + await expect(embedder.embedOne("🙂")).rejects.toThrow("one complete character"); + expect(fetchMock).not.toHaveBeenCalled(); +}); diff --git a/Memory/tests/memmy-488-reproduction.test.ts b/Memory/tests/memmy-488-reproduction.test.ts new file mode 100644 index 000000000..0dde1e782 --- /dev/null +++ b/Memory/tests/memmy-488-reproduction.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DEFAULT_MEMMY_CONFIG, type MemoryRow } from '../src/index.js'; +import { createEmbedder } from '../src/model/embedder.js'; +import { Repositories } from '../src/storage/repositories.js'; +import { embeddingTextForMemory } from '../src/service/embedding/embedding-pipeline.js'; +import { createMemoryServiceFixture, createBatchReflectionLlm } from './fixtures/memory-service-fixture.js'; + +const fixture = createMemoryServiceFixture(); +afterEach(() => { fixture.cleanup(); vi.unstubAllGlobals(); }); + +function setup(explicit: boolean) { + const requests: Array<{ sizes: number[]; rejected: boolean }> = []; + vi.stubGlobal('fetch', vi.fn(async (_url: unknown, init: RequestInit) => { + const { input } = JSON.parse(String(init.body)) as { input: string[] }; + const rejected = input.some(text => text.includes('BAD_EMBEDDING_ITEM')); + requests.push({ sizes: input.map(text => text.length), rejected }); + return new Response(JSON.stringify(rejected + ? { code: explicit ? 'context_length_exceeded' : 20015, message: explicit ? 'maximum context length exceeded' : 'The parameter is invalid.' } + : { data: input.map(() => ({ embedding: [1, 0, 0] })) }), + { status: rejected ? 400 : 200, headers: { 'content-type': 'application/json' } }); + })); + const embedder = createEmbedder({ ...DEFAULT_MEMMY_CONFIG.embedding, + provider: 'openai_compatible', model: 'Pro/BAAI/bge-m3', + endpoint: 'https://embedding-reproduction.invalid/v1', apiKey: 'synthetic-test-key', + cache: false, maxRetries: 3 }, { usageRecorder: { record() { return "skipped"; } } }); + return { embedder, requests }; +} +function skill(id: string, content: string): MemoryRow { + const at = new Date().toISOString(); + return { id, timeline: at, userId: 'memmy488-test', memoryType: 'SkillMemory', + status: 'activated', visibility: 'private', memoryKey: id, memoryValue: content, + tags: [], info: {}, properties: { internal_info: { memory_layer: 'Skill', memory_kind: 'skill', + skill: { name: id, status: 'active', invocation_guide: content } } }, + memoryLayer: 'Skill', version: 1, createdAt: at, updatedAt: at }; +} + +describe('MEMMY-488 regression (valid siblings must survive)', () => { + for (const explicit of [true, false]) { + it(`fresh worker batch: ${explicit ? 'explicit length error' : 'screenshot error 20015'}`, async () => { + const { embedder, requests } = setup(explicit); + const { db, service } = fixture.createTestService({ embedder, llm: createBatchReflectionLlm([], 'Synthetic summary.') }); + const session = service.openSession({ namespace: { source: 'codex', profileId: 'memmy488', userId: 'memmy488-test' } }); + const ids = ['GOOD_A', 'GOOD_B', 'GOOD_C', 'BAD_EMBEDDING_ITEM'].map((answer, i) => + service.completeTurn(`turn-${i}`, { sessionId: session.sessionId, query: 'Remember this synthetic test.', answer }).l1MemoryId); + await service.runWorkerOnce(20, { priorityCohortOnly: true }); + await service.runWorkerOnce(20, { priorityCohortOnly: true }); + const repos = new Repositories(db.db); + const states = ids.map(id => ({ id, state: repos.processing.get(id)?.state, error: repos.processing.get(id)?.errorCode })); + expect(states.map(s => s.state)).toEqual(['ready', 'ready', 'ready', explicit ? 'ready_text_only' : 'failed']); + const vectorRows = db.db.prepare('SELECT memory_id, vector_field FROM memory_vector_entries').all(); + expect(vectorRows.length).toBe(3); + const before = requests.length; + await service.runWorkerOnce(20, { priorityCohortOnly: true }); + expect(requests.length).toBe(before); + }); + + it(`legacy retry batch and separate replay: ${explicit ? 'explicit length error' : 'screenshot error 20015'}`, async () => { + const { embedder, requests } = setup(explicit); + const { db, service } = fixture.createTestService({ embedder }); + const repos = new Repositories(db.db); + const memories = ['GOOD_A', 'GOOD_B', 'GOOD_C', 'BAD_EMBEDDING_ITEM'].map((text, i) => skill(`skill-488-${i}`, text)); + const retries = memories.map(memory => { + repos.memories.insert(memory); + return repos.runtime.enqueueEmbeddingRetry({ targetKind: 'skill', targetId: memory.id, vectorField: 'vec', + sourceText: embeddingTextForMemory(memory), embedRole: 'query', now: Date.now() - 1 }); + }); + await service.runWorkerOnce(10); + const states = retries.map(retry => { const row = repos.runtime.getEmbeddingRetry(retry.id)!; return { status: row.status, attempts: row.attempts }; }); + expect(states.map(s => s.status)).toEqual(['succeeded', 'succeeded', 'succeeded', 'failed']); + const vectorRows = db.db.prepare('SELECT memory_id, vector_field FROM memory_vector_entries').all(); + expect(vectorRows.length).toBe(3); + const before = requests.length; + await service.runWorkerOnce(10); + expect(requests.length).toBe(before); + const replay: string[] = []; + for (const memory of memories) { + try { await embedder.embedOne(embeddingTextForMemory(memory), 'query'); replay.push('succeeded'); } + catch { replay.push('failed'); } + } + expect(replay).toEqual(['succeeded', 'succeeded', 'succeeded', 'failed']); + }); + } + + it('long BGE alias text is chunked and preserves the complete input', async () => { + const sent: string[] = []; + vi.stubGlobal('fetch', vi.fn(async (_url: unknown, init: RequestInit) => { + const { input } = JSON.parse(String(init.body)); sent.push(...input); + return new Response(JSON.stringify({ data: input.map(() => ({ embedding: [1, 0, 0] })) }), { status: 200 }); + })); + const embedder = createEmbedder({ ...DEFAULT_MEMMY_CONFIG.embedding, provider: 'openai_compatible', + model: 'Pro/BAAI/bge-m3', endpoint: 'https://embedding-reproduction.invalid/v1', apiKey: 'synthetic-test-key', cache: false }, + { usageRecorder: { record() { return "skipped"; } } }); + const text = '# Synthetic long document\n' + ' memory'.repeat(16000) + '\nEND_MARKER'; + await embedder.embedOne(text); + expect(sent.length).toBeGreaterThan(1); + expect(sent.join('')).toBe(text); + }); +}); diff --git a/Memory/tests/service/embedding/embedding-processing.test.ts b/Memory/tests/service/embedding/embedding-processing.test.ts index a0cdb8266..c5d7f5339 100644 --- a/Memory/tests/service/embedding/embedding-processing.test.ts +++ b/Memory/tests/service/embedding/embedding-processing.test.ts @@ -203,12 +203,17 @@ describe("MemoryService / embedding / processing", () => { db.close(); }); - it("isolates a deterministic embedding failure and keeps the valid sibling", async () => { + it.each([ + [400, "context_length_exceeded", "maximum context length exceeded"], + [400, "20015", "The parameter is invalid."], + [413, "payload_too_large", "Request too large"], + [422, "invalid_input", "Invalid input"] + ])("isolates a deterministic embedding failure and keeps the valid sibling (%s / %s)", async (status, code, message) => { const llmCalls: Array<{ messages: Array<{ role: string; content: string }>; options: { operation: string }; }> = []; - const embedder = createSelectiveFailureEmbedder(); + const embedder = createSelectiveFailureEmbedder(undefined, new ModelHttpError(message, "openai_compatible", status as number, code as string, message as string)); const { db, service } = createTestService({ llm: createBatchReflectionLlm(llmCalls, "Imported memory summary."), embedder @@ -237,10 +242,10 @@ describe("MemoryService / embedding / processing", () => { ])); expect(repositories.processing.get(good.l1MemoryId)?.state).toBe("ready"); expect(repositories.processing.get(bad.l1MemoryId)).toMatchObject({ - state: "ready_text_only", - stage: null, + state: code === "context_length_exceeded" ? "ready_text_only" : "failed", + stage: code === "context_length_exceeded" ? null : "embedding", retryAction: "none", - errorCode: "model_input_too_long" + errorCode: code === "context_length_exceeded" ? "model_input_too_long" : "invalid_model_request" }); db.close(); }); @@ -338,8 +343,13 @@ describe("MemoryService / embedding / processing", () => { db.close(); }); - it("isolates a deterministic legacy retry failure and keeps the valid sibling", async () => { - const { db, service } = createTestService({ embedder: createSelectiveFailureEmbedder() }); + it.each([ + [400, "context_length_exceeded", "maximum context length exceeded"], + [400, "20015", "The parameter is invalid."], + [413, "payload_too_large", "Request too large"], + [422, "invalid_input", "Invalid input"] + ])("isolates a deterministic legacy retry failure and keeps the valid sibling (%s / %s)", async (status, code, message) => { + const { db, service } = createTestService({ embedder: createSelectiveFailureEmbedder(undefined, new ModelHttpError(message, "openai_compatible", status as number, code as string, message as string)) }); const repositories = new Repositories(db.db); const good = skillMemory(undefined, { id: "skill_legacy_retry_good", @@ -380,6 +390,22 @@ describe("MemoryService / embedding / processing", () => { db.close(); }); + it.each([401, 403, 429, 500])("does not fan out a shared HTTP %s failure", async (status) => { + const calls = { batch: 0, single: 0 }; + const failure = new ModelHttpError("Provider unavailable", "openai_compatible", status, undefined, "Provider unavailable"); + const { db, service } = createTestService({ embedder: createSelectiveFailureEmbedder(calls, failure) }); + const repositories = new Repositories(db.db); + for (let index = 0; index < 2; index += 1) { + const memory = skillMemory(undefined, { id: `shared-failure-${index}`, content: "VALID_EMBEDDING_ITEM" }); + repositories.memories.insert(memory); + repositories.runtime.enqueueEmbeddingRetry({ targetKind: "skill", targetId: memory.id, vectorField: "vec", + sourceText: embeddingTextForMemory(memory), embedRole: "query", now: Date.now() - 1 }); + } + await service.runWorkerOnce(10); + expect(calls).toEqual({ batch: 1, single: 0 }); + db.close(); + }); + it("embeds L1 summary together with bounded user and assistant text", async () => { const root = createTestRoot("mindock-memory-dual-embedding-"); const db = new MemoryDb({ @@ -747,8 +773,8 @@ function createFlakyEmbedder(): Embedder { }; } -function createSelectiveFailureEmbedder(calls?: { batch: number; single: number }): Embedder { - const inputTooLong = () => new ModelHttpError( +function createSelectiveFailureEmbedder(calls?: { batch: number; single: number }, failure?: ModelHttpError): Embedder { + const inputTooLong = () => failure ?? new ModelHttpError( "openai_compatible HTTP 400: maximum context length exceeded", "openai_compatible", 400, From b972ec15c08c627af579356dd35e41ca1b295f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Thu, 15 Oct 2026 15:01:00 +0800 Subject: [PATCH 35/56] fix: delete generated spans with task memory --- Memory/src/service/memory-service.ts | 15 +++++++++++++++ .../tests/service/evolution/span-big-turn.test.ts | 11 ++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/Memory/src/service/memory-service.ts b/Memory/src/service/memory-service.ts index bb44dab52..273557f9a 100644 --- a/Memory/src/service/memory-service.ts +++ b/Memory/src/service/memory-service.ts @@ -2009,6 +2009,21 @@ export class MemoryService { const deletedMemoryIds: string[] = []; this.repos.transaction(() => { + const spanIds = new Set(); + for (const memoryId of episode.l1MemoryIds) { + const memory = this.repos.memories.get(memoryId); + const trace = memory?.properties.internal_info.trace; + if (!isRecord(trace) || !Array.isArray(trace.span_ids)) continue; + for (const spanId of trace.span_ids) { + if (typeof spanId === "string") spanIds.add(spanId); + } + } + for (const spanId of spanIds) { + const span = this.repos.memories.get(spanId); + if (!span || kindFromMemory(span) !== "span") continue; + this.deleteMemory(spanId, request); + deletedMemoryIds.push(spanId); + } for (const memoryId of episode.l1MemoryIds) { if (!this.repos.memories.get(memoryId)) continue; this.deleteMemory(memoryId, request); diff --git a/Memory/tests/service/evolution/span-big-turn.test.ts b/Memory/tests/service/evolution/span-big-turn.test.ts index 64110bc41..7b59a5a28 100644 --- a/Memory/tests/service/evolution/span-big-turn.test.ts +++ b/Memory/tests/service/evolution/span-big-turn.test.ts @@ -117,7 +117,7 @@ function createSpanBigTurnLlm( } describe("MemoryService / evolution / span big turn", () => { - it("stores and recalls subtask spans for a positively rewarded complex turn", async () => { + it("stores, recalls, and deletes subtask spans with their task", async () => { const calls: Array<{ messages: LlmMessage[]; options: LlmCompletionOptions }> = []; const embeddedTexts: string[] = []; const embeddedRoles: Array<"query" | "document" | undefined> = []; @@ -321,6 +321,15 @@ describe("MemoryService / evolution / span big turn", () => { toolCalls: toolCalls.slice(0, 4).map(({ id, name, input }) => ({ id, name, input })) }); expect(completed.l1MemoryIds).toHaveLength(1); + + const deleted = service.deletePanelTask(completed.episodeId, { namespace }); + expect(new Set(deleted.deletedMemoryIds)).toEqual(new Set([ + completed.l1MemoryId, + ...storedSpanIds + ])); + expect(service.panelTasks({ namespace, page: 1 }).tasks).toHaveLength(0); + expect(service.panelItems({ namespace, layer: "L1" }).items).toHaveLength(0); + expect(() => service.getMemory(firstSpanId!, { namespace })).toThrow(/not found/i); db.close(); }); From 2b8f186edc81ac4c837ef394ef29cce78d4b1584 Mon Sep 17 00:00:00 2001 From: ZongYue Date: Thu, 15 Oct 2026 15:16:08 +0800 Subject: [PATCH 36/56] fix: emit SHA-256 checksum files for Agent runtime packages --- App/memmy-agent/scripts/build-runtime.mjs | 12 +++----- .../scripts/write-runtime-release.mjs | 16 ++++++++++ tests/runtime-artifacts.test.mjs | 29 ++++++++++++++++++- 3 files changed, 48 insertions(+), 9 deletions(-) create mode 100644 App/memmy-agent/scripts/write-runtime-release.mjs diff --git a/App/memmy-agent/scripts/build-runtime.mjs b/App/memmy-agent/scripts/build-runtime.mjs index 4ca5bd08e..e87216ab4 100644 --- a/App/memmy-agent/scripts/build-runtime.mjs +++ b/App/memmy-agent/scripts/build-runtime.mjs @@ -1,14 +1,13 @@ #!/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 { cp, mkdir, mkdtemp, readFile, rm, 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"; +import { writeAgentRuntimeRelease } from "./write-runtime-release.mjs"; if (Number(process.versions.node.split(".")[0]) < 22) throw new Error("Agent runtime requires Node 22 or newer."); @@ -43,12 +42,9 @@ try { 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 } } - }); + const asset = await writeAgentRuntimeRelease(archive, manifest.version, target); console.log(archive); + console.log(`SHA-256: ${asset.sha256}\nChecksum file: ${archive}.sha256`); } finally { await rm(temporaryRoot, { recursive: true, force: true }); } diff --git a/App/memmy-agent/scripts/write-runtime-release.mjs b/App/memmy-agent/scripts/write-runtime-release.mjs new file mode 100644 index 000000000..048a461f0 --- /dev/null +++ b/App/memmy-agent/scripts/write-runtime-release.mjs @@ -0,0 +1,16 @@ +import { createHash } from "node:crypto"; +import { createReadStream } from "node:fs"; +import { stat, writeFile } from "node:fs/promises"; +import { basename, dirname, join } from "node:path"; + +export async function writeAgentRuntimeRelease(archive, version, target) { + // Hash the finished archive, including native dependencies, rather than its version or source files. + const hash = createHash("sha256"); + for await (const chunk of createReadStream(archive)) hash.update(chunk); + const asset = { name: basename(archive), sha256: hash.digest("hex"), size: (await stat(archive)).size }; + await writeFile(`${archive}.sha256`, `${asset.sha256} ${asset.name}\n`); + await writeFile(join(dirname(archive), `agent-release-${target}.json`), `${JSON.stringify({ + version, assets: { [target]: asset } + }, null, 2)}\n`); + return asset; +} diff --git a/tests/runtime-artifacts.test.mjs b/tests/runtime-artifacts.test.mjs index 6e8379d76..c345d8b57 100644 --- a/tests/runtime-artifacts.test.mjs +++ b/tests/runtime-artifacts.test.mjs @@ -6,6 +6,7 @@ import { join, resolve } from "node:path"; import { afterEach, expect, it } from "vitest"; import YAML from "yaml"; import { buildMemoryAll, verifyMemoryDownload } from "../scripts/internal/shared/build-memory-all.mjs"; +import { writeAgentRuntimeRelease } from "../App/memmy-agent/scripts/write-runtime-release.mjs"; const root = resolve(import.meta.dirname, ".."); const collector = join(root, "scripts/internal/shared/collect-runtime-artifacts.mjs"); @@ -30,7 +31,8 @@ it("collects all target artifacts and rejects corrupted downloads", async () => corrupted = join(input, name); await writeFile(corrupted, bytes); const metadata = { version, assets: { [target]: { name, size: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") } } }; - await writeFile(join(input, kind === "agent" ? `agent-release-${target}.json` : "memory-release.json"), JSON.stringify(metadata)); + if (kind === "agent") await writeAgentRuntimeRelease(corrupted, version, target); + else await writeFile(join(input, "memory-release.json"), JSON.stringify(metadata)); } } const args = [collector, join(directory, "input"), join(directory, "output")]; @@ -55,6 +57,31 @@ it("collects all target artifacts and rejects corrupted downloads", async () => expect(() => execFileSync(process.execPath, memoryArgs, { stdio: "pipe" })).toThrow(); }); +it("writes the finished Agent archive checksum and replaces it when same-version content changes", async () => { + const directory = await mkdtemp(join(tmpdir(), "agent-release-sha-")); + roots.push(directory); + await mkdir(join(directory, "package")); + const version = "1.2.3"; + const target = "darwin-arm64"; + const name = `memmy-agent-runtime-${version}-${target}.tgz`; + const archive = join(directory, name); + let previous; + for (const content of ["first build", "changed content at the same version"]) { + await writeFile(join(directory, "package/main.js"), content); + execFileSync("tar", ["-czf", archive, "-C", directory, "package"], { env: { ...process.env, COPYFILE_DISABLE: "1" } }); + const asset = await writeAgentRuntimeRelease(archive, version, target); + const bytes = await readFile(archive); + const expected = createHash("sha256").update(bytes).digest("hex"); + expect(asset).toEqual({ name, sha256: expected, size: bytes.length }); + expect(await readFile(`${archive}.sha256`, "utf8")).toBe(`${expected} ${name}\n`); + expect(JSON.parse(await readFile(join(directory, `agent-release-${target}.json`)))).toEqual({ + version, assets: { [target]: asset } + }); + if (previous) expect(expected).not.toBe(previous); + previous = expected; + } +}); + it("rejects local changes before uploading source", async () => { await expect(buildMemoryAll({ run: () => " M package.json" })).rejects.toThrow("Commit your local changes"); }); From d0cea1fbf97bca3c313259a501c33f8ab2615aff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Thu, 15 Oct 2026 16:16:50 +0800 Subject: [PATCH 37/56] fix: avoid failure alerts during background memory processing --- .../src/pages/memory/memories-sub-page.tsx | 22 +++----- .../memory/tests/memories-sub-page.test.tsx | 48 +++++++++++------ App/frontend/desktop/src/styles.css | 10 ++++ Memory/src/storage/repositories.ts | 6 +++ .../service/import/import-processing.test.ts | 54 +++++++++++++++++++ 5 files changed, 108 insertions(+), 32 deletions(-) diff --git a/App/frontend/desktop/src/pages/memory/memories-sub-page.tsx b/App/frontend/desktop/src/pages/memory/memories-sub-page.tsx index 9d75b494b..e8cb01bb7 100644 --- a/App/frontend/desktop/src/pages/memory/memories-sub-page.tsx +++ b/App/frontend/desktop/src/pages/memory/memories-sub-page.tsx @@ -721,18 +721,12 @@ function MemoryProcessingFailureCard(props: { const feedback = props.retryFeedback?.memoryId === props.item.id ? props.retryFeedback : null; const retryError = feedback?.status === "error" ? feedback : null; const displayedErrorMessage = retryError?.message ?? processing?.errorMessage ?? null; - const processingRetryInProgress = Boolean( - processing?.errorMessage && - !processing.autoRetryScheduled && - processing.state !== "failed" && - processing.state !== "ready" && - processing.state !== "ready_text_only" - ); - const retryInProgress = feedback?.status === "running" || processingRetryInProgress; + // Background processing keeps its normal progress indicator, even when a + // previous attempt failed. Only an explicit manual retry needs this feedback. + const retryInProgress = feedback?.status === "running"; if ( processing?.state !== "failed" && !retryInProgress && - !processing?.autoRetryScheduled && feedback?.status !== "succeeded" && feedback?.status !== "error" ) { @@ -753,13 +747,11 @@ function MemoryProcessingFailureCard(props: { ? t("memory.memories.processing.retrying") : processing?.errorCode === "40309" ? t(ERROR_NOTICE_KEYS.memory.quotaExhausted) - : processing?.autoRetryScheduled - ? t(ERROR_NOTICE_KEYS.memory.autoRetryScheduled) - : t(ERROR_NOTICE_KEYS.memory.failed); + : t(ERROR_NOTICE_KEYS.memory.failed); return (
{feedback?.status === "succeeded" @@ -769,7 +761,7 @@ function MemoryProcessingFailureCard(props: { :
- {displayedErrorMessage && feedback?.status !== "succeeded" ? ( + {displayedErrorMessage && feedback?.status !== "succeeded" && !retryInProgress ? ( { expect(html).not.toContain("这条记忆处理失败"); }); - it("确有后台重试任务时提示稍后自动重试", () => { + it.each([ + ["summary_pending", "summary", false, null], + // Older services also set autoRetryScheduled on the first queued attempt. + ["summary_pending", "summary", true, null], + ["summary_pending", "summary", true, "temporary provider failure"], + ["summarizing", "summary", false, "temporary provider failure"], + ["embedding_pending", "embedding", true, "temporary provider failure"], + ["embedding", "embedding", false, "temporary provider failure"], + ["ready", null, true, null], + ["ready_text_only", null, false, "input too long"] + ] as const)("后台状态 %s / %s / autoRetry=%s 不显示失败卡", (state, stage, autoRetryScheduled, errorMessage) => { const processing = { memoryId: memoryListItemFixture.id, - state: "summary_pending" as const, - stage: "summary" as const, + state, + stage, activeJobId: "summary-retry", - attemptCount: 1, + attemptCount: errorMessage ? 1 : 0, manualRetryCount: 0, retryAction: "retry" as const, - errorCode: "transient_provider_error", - errorMessage: "temporary provider failure", - failedAt: "2026-06-03T09:30:00.000Z", - autoRetryScheduled: true, + errorCode: errorMessage ? "transient_provider_error" : null, + errorMessage, + failedAt: errorMessage ? "2026-06-03T09:30:00.000Z" : null, + autoRetryScheduled, updatedAt: "2026-06-03T09:30:00.000Z" }; const html = renderMemories({ @@ -606,13 +616,14 @@ describe("MemoriesSubPage", () => { } }); - expect(html).toContain("记忆处理失败,稍后将自动重试"); - expect(html).toContain("temporary provider failure"); - expect(html).toContain("收起详情"); + expect(html).not.toContain("memory-processing-notice"); + expect(html).not.toContain("记忆处理失败"); + expect(html).not.toContain("temporary provider failure"); expect(html).not.toContain("正在重试"); + if (stage) expect(html).toContain(stage === "summary" ? "摘要总结中" : "索引建立中"); }); - it("重试处理中持续展示上次失败原因", () => { + it("用户主动重试时展示进度,不再告警上次失败", () => { const processing = { memoryId: memoryListItemFixture.id, state: "embedding_pending" as const, @@ -638,12 +649,15 @@ describe("MemoriesSubPage", () => { } }, "zh-CN", { onRetryProcessing: vi.fn(), - onOpenSettings: vi.fn() + onOpenSettings: vi.fn(), + retryFeedback: { memoryId: memoryListItemFixture.id, status: "running" } }); expect(html).toContain("正在重试"); - expect(html).toContain("摘要模型未配置"); - expect(html).toContain("收起详情"); + expect(html).toContain('role="status"'); + expect(html).toContain("memory-processing-notice--progress"); + expect(html).not.toContain("摘要模型未配置"); + expect(html).not.toContain("收起详情"); expect(html).not.toContain("上次失败原因"); expect(html).not.toContain("上次失败时间"); expect(html).not.toContain("已尝试次数"); @@ -651,10 +665,10 @@ describe("MemoriesSubPage", () => { expect(html).not.toContain("立即重试"); }); - it("重试接口失败时只在上方展示最新错误", () => { + it.each(["failed", "summary_pending"] as const)("重试接口失败时在 %s 状态仍展示最新错误", (state) => { const processing = { memoryId: memoryListItemFixture.id, - state: "failed" as const, + state, stage: "summary" as const, activeJobId: null, attemptCount: 3, diff --git a/App/frontend/desktop/src/styles.css b/App/frontend/desktop/src/styles.css index 911eeef22..1309f319f 100644 --- a/App/frontend/desktop/src/styles.css +++ b/App/frontend/desktop/src/styles.css @@ -5350,6 +5350,16 @@ body.memmy-platform-windows .memory-drawer { text-transform: uppercase; } +.memory-processing-notice--progress { + border-color: var(--color-border-stone); + background: var(--color-background-paper); +} + +.memory-processing-notice--progress .agent-model-error-notice__icon, +.memory-processing-notice--progress .agent-model-error-notice__title { + color: var(--color-text-ink); +} + .memory-processing-notice--success { border-color: color-mix(in srgb, var(--color-status-success) 40%, transparent); background: color-mix(in srgb, var(--color-status-success) 7%, var(--color-background-paper)); diff --git a/Memory/src/storage/repositories.ts b/Memory/src/storage/repositories.ts index dee1bb81f..88581c05a 100644 --- a/Memory/src/storage/repositories.ts +++ b/Memory/src/storage/repositories.ts @@ -1678,7 +1678,9 @@ export class MemoryProcessingRepository { SELECT 1 FROM evolution_jobs WHERE evolution_jobs.id = memory_processing_state.active_job_id AND evolution_jobs.status IN ('failed', 'queued') + AND evolution_jobs.attempts > 0 AND evolution_jobs.attempts < evolution_jobs.max_attempts + AND evolution_jobs.last_error IS NOT NULL ) AS auto_retry_scheduled FROM memory_processing_state WHERE memory_id = ?` @@ -1695,7 +1697,9 @@ export class MemoryProcessingRepository { SELECT 1 FROM evolution_jobs WHERE evolution_jobs.id = memory_processing_state.active_job_id AND evolution_jobs.status IN ('failed', 'queued') + AND evolution_jobs.attempts > 0 AND evolution_jobs.attempts < evolution_jobs.max_attempts + AND evolution_jobs.last_error IS NOT NULL ) AS auto_retry_scheduled FROM memory_processing_state WHERE memory_id IN (${placeholders})` @@ -1715,7 +1719,9 @@ export class MemoryProcessingRepository { SELECT 1 FROM evolution_jobs WHERE evolution_jobs.id = memory_processing_state.active_job_id AND evolution_jobs.status IN ('failed', 'queued') + AND evolution_jobs.attempts > 0 AND evolution_jobs.attempts < evolution_jobs.max_attempts + AND evolution_jobs.last_error IS NOT NULL ) AS auto_retry_scheduled FROM memory_processing_state WHERE state IN (${placeholders}) diff --git a/Memory/tests/service/import/import-processing.test.ts b/Memory/tests/service/import/import-processing.test.ts index 846476119..6edb10160 100644 --- a/Memory/tests/service/import/import-processing.test.ts +++ b/Memory/tests/service/import/import-processing.test.ts @@ -31,6 +31,60 @@ const { afterEach(cleanup); describe("MemoryService / import / processing", () => { + it("distinguishes new and interrupted work from failed automatic retries across all processing reads", async () => { + const root = createTestRoot("memmy-processing-retry-state-"); + const db = new MemoryDb({ path: join(root, "memory.sqlite") }); + const repos = new Repositories(db.db); + const baseLlm = createBatchReflectionLlm([], "summary recovered after retry"); + let failSummary = true; + const llm: LlmClient = { + ...baseLlm, + async completeJson>(messages: LlmMessage[], options: LlmCompletionOptions): Promise { + if (failSummary) throw new Error("429 temporary provider failure"); + return baseLlm.completeJson(messages, options); + } + }; + const service = createTestMemoryService({ db, mode: "dev", llm, embedder: createCapturingEmbedder([]) }); + const namespace = { source: "codex", profileId: "default", userId: "retry-state-user" }; + const added = addAgentSourceImport(service, namespace, "remember the import queue", "retry-state"); + const expectAutomaticRetry = (expected: boolean) => { + const current = repos.processing.get(added.id)!; + expect(current.autoRetryScheduled).toBe(expected); + expect(repos.processing.getMany([added.id])[0]?.autoRetryScheduled).toBe(expected); + expect(repos.processing.listByStates([current.state]).find((item) => item.memoryId === added.id)?.autoRetryScheduled).toBe(expected); + expect(service.memoryProcessingStatus([added.id], { namespace }).items[0]?.autoRetryScheduled).toBe(expected); + }; + + expectAutomaticRetry(false); + expect(repos.runtime.leaseQueuedJobs(1, 60, [added.id])).toHaveLength(1); + expectAutomaticRetry(false); + const recovered = repos.runtime.requeueLeasedJobsAfterRestart(); + expect(recovered[0]?.after).toMatchObject({ status: "queued", attempts: 0, lastError: null }); + expectAutomaticRetry(false); + + await service.runWorkerOnce(1); + expect(repos.processing.get(added.id)).toMatchObject({ state: "summary_pending", errorCode: "transient_provider_error" }); + expectAutomaticRetry(true); + expect(repos.runtime.requeueFailedJobs(1, undefined, [added.id])).toHaveLength(1); + expectAutomaticRetry(true); + expect(repos.runtime.leaseQueuedJobs(1, 60, [added.id])).toHaveLength(1); + expectAutomaticRetry(false); + repos.runtime.requeueLeasedJobsAfterRestart(); + expectAutomaticRetry(true); + + failSummary = false; + await service.runWorkerOnce(1); + // The next stage retains the old summary error but has a fresh embedding job. + expect(repos.processing.get(added.id)).toMatchObject({ state: "embedding_pending", errorCode: "transient_provider_error" }); + expectAutomaticRetry(false); + await service.runWorkerOnce(1); + expect(repos.processing.get(added.id)).toMatchObject({ state: "ready", errorMessage: null, failedAt: null }); + expectAutomaticRetry(false); + expect(repos.memories.get(added.id)?.memoryValue).toContain("summary recovered after retry"); + expect(repos.memories.hasVector(added.id, "vec_summary")).toBe(true); + db.close(); + }); + it("imports L1 memory with async summary, default score, and embedding only", async () => { const root = createTestRoot("mindock-memory-import-add-"); const db = new MemoryDb({ From d698dd4747f5a3c75ae2be9c28c3a153dce28a7a Mon Sep 17 00:00:00 2001 From: Yicheng Date: Thu, 15 Oct 2026 16:40:30 +0800 Subject: [PATCH 38/56] fix: clarify missing ASR model error --- App/frontend/desktop/src/i18n/messages.ts | 2 ++ App/frontend/desktop/src/pages/home-page.tsx | 15 ++++++++++++++- .../desktop/src/pages/tests/home-page.test.tsx | 7 +++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index a7ddec3d2..1a667ab29 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -546,6 +546,7 @@ export const zhCNMessages = { "home.media.addPhotoFile": "添加图片和文件", "home.voiceInput": "语音输入", "home.asrEmptyAudio": "没有录到有效音频,请稍微说久一点再发送", + "home.asrNotConfigured": "未配置 ASR 模型,请先配置后再使用语音输入", "home.asrFailedWithMessage": "语音识别失败:{message}", "home.asrFailed": "语音识别失败,请重试", "home.suggestion.one": "整理最近的 Agent 任务", @@ -2287,6 +2288,7 @@ export const enUSMessages: Record = { "home.media.addPhotoFile": "Add images and files", "home.voiceInput": "Voice input", "home.asrEmptyAudio": "No valid audio was recorded. Please speak a little longer and try again.", + "home.asrNotConfigured": "No ASR model is configured. Configure one before using voice input.", "home.asrFailedWithMessage": "Speech recognition failed: {message}", "home.asrFailed": "Speech recognition failed. Please try again.", "home.suggestion.one": "Organize recent Agent tasks", diff --git a/App/frontend/desktop/src/pages/home-page.tsx b/App/frontend/desktop/src/pages/home-page.tsx index d823d2eae..55ee911f5 100644 --- a/App/frontend/desktop/src/pages/home-page.tsx +++ b/App/frontend/desktop/src/pages/home-page.tsx @@ -187,6 +187,7 @@ const TRANSLATABLE_AGENT_ERROR_KEYS = new Set([ "home.media.error.sendReadFailed", "home.media.error.sendFailed", "home.media.error.messageTooBig", + "home.asrNotConfigured", "home.agent.messageNotRecorded", "home.agent.executionInterrupted", "home.agent.recoveryTimeout", @@ -3457,6 +3458,9 @@ export function agentErrorText(error: string | null, t: HomeTranslate = defaultH if (TRANSLATABLE_AGENT_ERROR_KEYS.has(error as MessageKey)) { return t(error as MessageKey); } + if (isAsrModelNotConfiguredError(error)) { + return t("home.asrNotConfigured"); + } const stableCode = error.includes(":") ? error.slice(error.lastIndexOf(":") + 1) : error; const mapped = AGENT_OPERATION_ERROR_MESSAGE_KEYS[stableCode]; if (mapped) return t(mapped); @@ -3593,15 +3597,24 @@ function browserStorage(): SlashCommandStorageLike | null { * @param error An unknown exception. * @returns Error text or a MessageKey that can be shown to the user. */ -function toReadableAsrError(error: unknown, t: HomeTranslate = defaultHomeTranslate): string { +export function toReadableAsrError(error: unknown, t: HomeTranslate = defaultHomeTranslate): string { if (error instanceof MicrophonePermissionError) { return microphonePermissionDeniedMessageKey(); } + if (isAsrModelNotConfiguredError(error)) { + return "home.asrNotConfigured"; + } return error instanceof Error && error.message ? t("home.asrFailedWithMessage", { message: error.message }) : t("home.asrFailed"); } +/** Detects backend ASR configuration errors that should not fall through to the generic operation error. */ +function isAsrModelNotConfiguredError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /ASR (?:model catalog|model|client) is not configured|Assigned ASR model is unavailable|ASR requires account or BYOK mode/i.test(message); +} + function readableError(error: unknown): string { return error instanceof Error && error.message ? error.message : String(error); } 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..b6b836fdc 100644 --- a/App/frontend/desktop/src/pages/tests/home-page.test.tsx +++ b/App/frontend/desktop/src/pages/tests/home-page.test.tsx @@ -41,6 +41,7 @@ import { resolveComposerCommandDraft, shouldAcceptAgentStatusResult, submitAgentComposerMessage, + toReadableAsrError, updateAgentComposerOverlayHeight, updateComposerDraftForScope, fileToPendingAttachment, @@ -1163,6 +1164,12 @@ describe("HomePage", () => { expect(agentErrorText(null)).toBeNull(); }); + it("shows a dedicated message when voice input has no ASR model configured", () => { + expect(toReadableAsrError(new Error("Assigned ASR model is unavailable"))).toBe("home.asrNotConfigured"); + expect(agentErrorText("home.asrNotConfigured")).toBe("未配置 ASR 模型,请先配置后再使用语音输入"); + expect(agentErrorText("语音识别失败:ASR model catalog is not configured")).toBe("未配置 ASR 模型,请先配置后再使用语音输入"); + }); + it("keeps the model unavailable reason when a tombstone blocks new-chat creation", async () => { const dispatch = vi.fn(); From bb21412ac179f0eb5eb11dbecce02a482629e731 Mon Sep 17 00:00:00 2001 From: ZongYue Date: Thu, 15 Oct 2026 17:15:26 +0800 Subject: [PATCH 39/56] fix: remove whole-turn deadlines from Memmy API --- App/memmy-agent/src/config/schema.ts | 3 -- .../src/entrypoints/cli/commands.ts | 4 +- .../src/entrypoints/openai-like-api/server.ts | 49 ++++++------------- .../tests/entrypoints/cli/commands.test.ts | 6 +-- .../openai-like-api/api-stream.test.ts | 32 +++++++++++- .../openai-like-api/openai-api.test.ts | 1 - 6 files changed, 50 insertions(+), 45 deletions(-) diff --git a/App/memmy-agent/src/config/schema.ts b/App/memmy-agent/src/config/schema.ts index 9820534b8..317950ec4 100644 --- a/App/memmy-agent/src/config/schema.ts +++ b/App/memmy-agent/src/config/schema.ts @@ -1028,14 +1028,12 @@ export class HeartbeatConfig extends Base { export class ApiConfig extends Base { host = "127.0.0.1"; port = 18990; - timeout = 120; apiKey: string | null = null; constructor(init: Dict = {}) { super(); this.host = pick(init, ["host"], this.host); this.port = pick(init, ["port"], this.port); - this.timeout = pick(init, ["timeout"], this.timeout); this.apiKey = pick(init, ["apiKey"], null); } @@ -1043,7 +1041,6 @@ export class ApiConfig extends Base { return { host: this.host, port: this.port, - timeout: this.timeout, apiKey: this.apiKey, }; } diff --git a/App/memmy-agent/src/entrypoints/cli/commands.ts b/App/memmy-agent/src/entrypoints/cli/commands.ts index 87c72cd63..1d5aca651 100644 --- a/App/memmy-agent/src/entrypoints/cli/commands.ts +++ b/App/memmy-agent/src/entrypoints/cli/commands.ts @@ -597,7 +597,7 @@ export async function main(argv: string[] = process.argv): Promise { .description("Start the OpenAI-compatible API server.") .option("-p, --port ", "API server port") .option("-H, --host ", "Bind address") - .option("-t, --timeout ", "Per-request timeout") + .option("-t, --timeout ", "Deprecated; agent turns have no total time limit") .option("-w, --workspace ", "Workspace directory") .option("-c, --config ", "Path to config file") .option("-v, --verbose", "Enable verbose runtime logs", false) @@ -923,7 +923,7 @@ export async function serve({ const appServer = createApp( loop, resolved.model, - timeout == null ? loaded.api.timeout : Number(timeout), + ); const bindHost = host ?? loaded.api.host; const bindPort = port == null ? loaded.api.port : Number(port); 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 5abfd2dd1..4a4c41a92 100644 --- a/App/memmy-agent/src/entrypoints/openai-like-api/server.ts +++ b/App/memmy-agent/src/entrypoints/openai-like-api/server.ts @@ -24,7 +24,6 @@ export class RequestBodyTooLarge extends Error { type ApiContext = { agentLoop: any; modelName: string; - requestTimeout: number; sessionLocks: Map>; }; @@ -188,33 +187,20 @@ async function parseMultipart(request: Request): Promise<[string, string[], stri return [text, mediaPaths, sessionId, model]; } -async function withTimeout(promise: Promise, timeoutS: number): Promise { - let timer: NodeJS.Timeout | undefined; - const timeout = new Promise((resolve, reject) => { - void resolve; - timer = setTimeout(() => reject(new Error(`Request timed out after ${timeoutS}s`)), timeoutS * 1000); - timer.unref?.(); - }); - try { - return await Promise.race([promise, timeout]); - } finally { - if (timer) clearTimeout(timer); - } -} - async function withSessionLock(ctx: ApiContext, sessionKey: string, fn: () => Promise): Promise { const previous = ctx.sessionLocks.get(sessionKey) ?? Promise.resolve(); let release!: () => void; const current = new Promise((resolve) => { release = resolve; }); - ctx.sessionLocks.set(sessionKey, previous.then(() => current, () => current)); + const queued = previous.then(() => current, () => current); + ctx.sessionLocks.set(sessionKey, queued); await previous.catch(() => undefined); try { return await fn(); } finally { release(); - if (ctx.sessionLocks.get(sessionKey) === current) ctx.sessionLocks.delete(sessionKey); + if (ctx.sessionLocks.get(sessionKey) === queued) ctx.sessionLocks.delete(sessionKey); } } @@ -259,7 +245,6 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P const context = ctx ?? { agentLoop: null, modelName: "memmy", - requestTimeout: 120, sessionLocks: new Map>(), }; const contentType = request.headers.get("content-type") ?? ""; @@ -367,17 +352,13 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P try { await withSessionLock(context, sessionKey, async () => { requestTiming("api.session_lock_acquired"); - const response = await withTimeout( - Promise.resolve( - callAgent(context, { - ...baseArgs, - onProgress, - onStream, - onStreamEnd, - }), - ), - context.requestTimeout, - ); + // Model and tool calls own their deadlines; a whole turn has none. + const response = await callAgent(context, { + ...baseArgs, + onProgress, + onStream, + onStreamEnd, + }); if (!emitted) { const finalText = responseText(response); if (finalText.trim()) write(sseChunk(finalText, context.modelName, chunkId)); @@ -417,11 +398,11 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P try { const reply = await withSessionLock(context, sessionKey, async () => { - const first = await withTimeout(Promise.resolve(callAgent(context, baseArgs)), context.requestTimeout); + const first = await callAgent(context, baseArgs); let textResponse = responseText(first); let usage = responseUsage(first); if (!textResponse.trim()) { - const retry = await withTimeout(Promise.resolve(callAgent(context, baseArgs)), context.requestTimeout); + const retry = await callAgent(context, baseArgs); textResponse = responseText(retry); usage = mergeUsageRecords(usage, responseUsage(retry)); if (!textResponse.trim()) textResponse = EMPTY_FINAL_RESPONSE_MESSAGE; @@ -430,8 +411,6 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P }); return Response.json(chatCompletionResponse(reply.text, context.modelName, reply.usage)); } catch (err) { - const message = (err as Error).message ?? ""; - if (message.startsWith("Request timed out")) return errorJson(504, message); return errorJson(500, "Internal server error", "server_error"); } } @@ -487,13 +466,13 @@ export async function handleHealth(): Promise { export function createApp( agentLoopOrConfig: any = null, modelName = "memmy", - requestTimeout = 120, + // Accepted for callers built against older runtimes; never limits a turn. + _legacyRequestTimeout?: number, ): { fetch: (request: Request) => Promise; context: ApiContext } { const agentLoop = agentLoopOrConfig instanceof Config || agentLoopOrConfig == null ? null : agentLoopOrConfig; const ctx: ApiContext = { agentLoop, modelName, - requestTimeout, sessionLocks: new Map>(), }; return { diff --git a/App/memmy-agent/tests/entrypoints/cli/commands.test.ts b/App/memmy-agent/tests/entrypoints/cli/commands.test.ts index 361b34048..eb1e1e3b1 100644 --- a/App/memmy-agent/tests/entrypoints/cli/commands.test.ts +++ b/App/memmy-agent/tests/entrypoints/cli/commands.test.ts @@ -531,7 +531,7 @@ describe("CLI command helpers", () => { .toThrow("unsupported config key"); }); - it("serve uses api.timeout from config when no CLI timeout override is supplied", async () => { + it("serve ignores legacy api.timeout while a task is still executing", async () => { const root = tempRoot("memmy-serve-timeout-"); const workspace = path.join(root, "workspace"); const configPath = writeConfig(root, { @@ -561,8 +561,8 @@ describe("CLI command helpers", () => { }); const body = await response.json() as any; - expect(response.status).toBe(504); - expect(body.error.message).toContain("Request timed out after 0.01s"); + expect(response.status).toBe(200); + expect(body.choices[0].message.content).toBe("late"); expect(fs.existsSync(path.join(workspace, "AGENTS.md"))).toBe(true); } finally { await closeServer(server); 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 e0d19056b..6f3ca300a 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,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { SSE_DONE, sseChunk, sseMemmyEvent, createApp } from "../../../src/entrypoints/openai-like-api/server.js"; function request(body: unknown): Request { @@ -307,3 +307,33 @@ describe("SSE chat completions", () => { expect(body).not.toContain("[DONE]"); }); }); + +for (const stream of [true, false]) { + it(`keeps a silent long-running task alive (stream=${stream})`, async () => { + vi.useFakeTimers(); + let release!: () => void; + let started!: () => void; + const entered = new Promise((resolve) => { started = resolve; }); + const gate = new Promise((resolve) => { release = resolve; }); + try { + const app = createApp({ processDirect: async () => { + started(); + await gate; + return "long tool finished"; + } }, "m", 600); + let settled = false; + const result = app.fetch(request({ messages: [{ role: "user", content: "long tool" }], stream })) + .then((response) => response.text()); + void result.then(() => { settled = true; }); + await entered; + await vi.advanceTimersByTimeAsync(2 * 60 * 60 * 1000); + expect(settled).toBe(false); + release(); + expect(await result).toContain("long tool finished"); + expect(app.context.sessionLocks.size).toBe(0); + } finally { + release?.(); + vi.useRealTimers(); + } + }); +} 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 fe01a530e..dbcdb9508 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 @@ -122,7 +122,6 @@ describe("OpenAI-compatible API request validation", () => { { agentLoop: { processDirect: async () => "unused" }, modelName: "test-model", - requestTimeout: 10, sessionLocks: new Map(), }, ); From b596123f37156763bb4f31c0d9565ad56c510387 Mon Sep 17 00:00:00 2001 From: ZongYue Date: Thu, 15 Oct 2026 17:20:33 +0800 Subject: [PATCH 40/56] fix: cancel Memmy turns only through explicit stop requests --- .../src/entrypoints/cli/commands.ts | 4 + .../src/entrypoints/openai-like-api/server.ts | 65 +++++++++++++++-- .../tests/entrypoints/cli/commands.test.ts | 50 +++++++++++++ .../openai-like-api/api-stream.test.ts | 73 +++++++++++++++++++ .../openai-like-api/openai-api.test.ts | 1 + 5 files changed, 188 insertions(+), 5 deletions(-) diff --git a/App/memmy-agent/src/entrypoints/cli/commands.ts b/App/memmy-agent/src/entrypoints/cli/commands.ts index 1d5aca651..98ec75e89 100644 --- a/App/memmy-agent/src/entrypoints/cli/commands.ts +++ b/App/memmy-agent/src/entrypoints/cli/commands.ts @@ -896,6 +896,10 @@ async function writeNodeResponse(res: http.ServerResponse, response: Response): return; } const nodeStream = Readable.fromWeb(response.body as any); + // A silent tool may run for a long time. Expose admission before the first delta. + res.flushHeaders(); + nodeStream.on("error", () => res.destroy()); + res.once("close", () => nodeStream.destroy()); nodeStream.pipe(res); } 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 4a4c41a92..325100c0e 100644 --- a/App/memmy-agent/src/entrypoints/openai-like-api/server.ts +++ b/App/memmy-agent/src/entrypoints/openai-like-api/server.ts @@ -25,6 +25,7 @@ type ApiContext = { agentLoop: any; modelName: string; sessionLocks: Map>; + activeRequests: Map }>; }; type ChatCompletionUsage = { @@ -215,6 +216,7 @@ async function callAgent(ctx: ApiContext, args: Record): Promise): Promise>(), + activeRequests: new Map(), }; const contentType = request.headers.get("content-type") ?? ""; let text = ""; @@ -253,6 +258,7 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P let sessionId: string | null | undefined; let requestedModel: string | null | undefined; let stream = false; + let requestId: string = crypto.randomUUID(); try { if (contentType.startsWith("multipart/")) { @@ -268,6 +274,12 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P requestedModel = body.model; [text, mediaPaths] = parseJsonContent(body); sessionId = body.session_id; + if (body.request_id !== undefined) { + if (typeof body.request_id !== "string" || !body.request_id.trim() || body.request_id.length > 4096) { + return errorJson(400, "Invalid request_id"); + } + requestId = body.request_id; + } } } catch (err) { if (err instanceof FileSizeExceeded) return errorJson(413, err.message); @@ -279,7 +291,18 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P } const sessionKey = sessionId ? `api:${sessionId}` : API_SESSION_KEY; + const requestKey = JSON.stringify([sessionKey, requestId]); + if (context.activeRequests.has(requestKey)) return errorJson(409, "Request is already running"); + const controller = new AbortController(); + let finish!: () => void; + const done = new Promise((resolve) => { finish = resolve; }); + context.activeRequests.set(requestKey, { controller, done }); + const finishRequest = (): void => { + context.activeRequests.delete(requestKey); + finish(); + }; const baseArgs = { + abortSignal: controller.signal, content: text, media: mediaPaths.length ? mediaPaths : null, session_key: sessionKey, @@ -297,12 +320,12 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P const encoder = new TextEncoder(); let closed = false; const body = new ReadableStream({ - start(controller) { + start(streamController) { let emitted = false; const write = (chunk: string): void => { if (closed) return; try { - controller.enqueue(encoder.encode(chunk)); + streamController.enqueue(encoder.encode(chunk)); } catch { closed = true; } @@ -311,7 +334,7 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P if (closed) return; closed = true; try { - controller.close(); + streamController.close(); } catch { // The client may have already disconnected. } @@ -319,11 +342,13 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P void (async () => { let failed = false; const onStream = async (token: string) => { + if (controller.signal.aborted) return; 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 (controller.signal.aborted) return; 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)); @@ -351,6 +376,7 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P const onStreamEnd = async () => undefined; try { await withSessionLock(context, sessionKey, async () => { + controller.signal.throwIfAborted(); requestTiming("api.session_lock_acquired"); // Model and tool calls own their deadlines; a whole turn has none. const response = await callAgent(context, { @@ -359,6 +385,7 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P onStream, onStreamEnd, }); + controller.signal.throwIfAborted(); if (!emitted) { const finalText = responseText(response); if (finalText.trim()) write(sseChunk(finalText, context.modelName, chunkId)); @@ -369,8 +396,8 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P failed = true; const selectionUnavailable = error instanceof Error && error.message === "model_selection_unavailable"; write(`data: ${JSON.stringify({ error: { - code: selectionUnavailable ? "model_selection_unavailable" : "server_error", - message: selectionUnavailable ? "会话绑定的模型已不可用,请重新选择模型。" : "Memmy runtime failed while processing the request.", + code: controller.signal.aborted ? "cancelled" : selectionUnavailable ? "model_selection_unavailable" : "server_error", + message: controller.signal.aborted ? "Task cancelled by user." : selectionUnavailable ? "会话绑定的模型已不可用,请重新选择模型。" : "Memmy runtime failed while processing the request.", } })}\n\n`); } finally { if (!failed) { @@ -379,6 +406,7 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P } requestTiming("api.finished"); close(); + finishRequest(); } })(); }, @@ -398,11 +426,14 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P try { const reply = await withSessionLock(context, sessionKey, async () => { + controller.signal.throwIfAborted(); const first = await callAgent(context, baseArgs); + controller.signal.throwIfAborted(); let textResponse = responseText(first); let usage = responseUsage(first); if (!textResponse.trim()) { const retry = await callAgent(context, baseArgs); + controller.signal.throwIfAborted(); textResponse = responseText(retry); usage = mergeUsageRecords(usage, responseUsage(retry)); if (!textResponse.trim()) textResponse = EMPTY_FINAL_RESPONSE_MESSAGE; @@ -411,8 +442,30 @@ async function handleTimedChatCompletions(request: Request, ctx?: ApiContext): P }); return Response.json(chatCompletionResponse(reply.text, context.modelName, reply.usage)); } catch (err) { + if (controller.signal.aborted) return errorJson(409, "Task cancelled by user.", "cancelled"); return errorJson(500, "Internal server error", "server_error"); + } finally { + finishRequest(); + } +} + +async function handleCancel(request: Request, ctx: ApiContext): Promise { + let body: any; + try { + body = await request.json(); + } catch { + return errorJson(400, "Invalid JSON body"); + } + if (typeof body?.session_id !== "string" || !body.session_id.trim() + || typeof body?.request_id !== "string" || !body.request_id.trim()) { + return errorJson(400, "session_id and request_id are required"); } + const key = JSON.stringify([`api:${body.session_id}`, body.request_id]); + const active = ctx.activeRequests.get(key); + if (!active) return errorJson(404, "Running request not found"); + active.controller.abort(); + await active.done; + return Response.json({ status: "cancelled" }); } function mediaFromToolEvent(event: Record): Array> { @@ -474,6 +527,7 @@ export function createApp( agentLoop, modelName, sessionLocks: new Map>(), + activeRequests: new Map(), }; return { context: ctx, @@ -481,6 +535,7 @@ export function createApp( const url = new URL(request.url); if (url.pathname === "/health" && request.method === "GET") return handleHealth(); if (url.pathname === "/v1/models" && request.method === "GET") return handleModels(request, ctx); + if (url.pathname === "/v1/chat/cancel" && request.method === "POST") return handleCancel(request, ctx); if (url.pathname === "/v1/chat/completions" && request.method === "POST") return handleChatCompletions(request, ctx); return errorJson(404, "not found"); }, diff --git a/App/memmy-agent/tests/entrypoints/cli/commands.test.ts b/App/memmy-agent/tests/entrypoints/cli/commands.test.ts index eb1e1e3b1..580a5d4f8 100644 --- a/App/memmy-agent/tests/entrypoints/cli/commands.test.ts +++ b/App/memmy-agent/tests/entrypoints/cli/commands.test.ts @@ -2099,3 +2099,53 @@ describe("CLI command parity with memmy test_commands", () => { expect(fs.existsSync(path.join(customWorkspace, "cron", "jobs.json"))).toBe(false); }); }); + +it("serve admits silent tasks immediately and reserves cancellation for explicit stop", async () => { + const root = tempRoot("memmy-serve-stop-"); + const configPath = writeConfig(root, { + agents: { defaults: { workspace: path.join(root, "workspace"), model: "openai/test-model" } }, + api: { host: "127.0.0.1", port: 0 }, + }); + let signal!: AbortSignal; + let release!: () => void; + vi.spyOn(AgentLoop, "fromConfig").mockReturnValue({ + processMessageInternal() {}, + async processDirect(_text: string, opts: any) { + signal = opts.abortSignal; + await new Promise((resolve) => { + release = resolve; + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + return { content: "finished" }; + }, + connectMcp: vi.fn(async () => undefined), + closeMcp: vi.fn(async () => undefined), + } as any); + vi.spyOn(console, "log").mockImplementation(() => undefined); + const server = await serve({ config: configPath }); + try { + const port = (server.address() as any).port; + const base = `http://127.0.0.1:${port}`; + const response = await fetch(`${base}/v1/chat/completions`, { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ messages: [{ role: "user", content: "go" }], stream: true, session_id: "s", request_id: "r" }), + signal: AbortSignal.timeout(2000), + }); + expect(response.status).toBe(200); + expect(signal.aborted).toBe(false); + await response.body!.cancel(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(signal.aborted).toBe(false); + const stopped = await fetch(`${base}/v1/chat/cancel`, { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ session_id: "s", request_id: "r" }), + signal: AbortSignal.timeout(2000), + }); + expect(await stopped.json()).toEqual({ status: "cancelled" }); + expect(signal.aborted).toBe(true); + expect((await fetch(`${base}/health`)).status).toBe(200); + } finally { + release?.(); + await closeServer(server); + } +}); 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 6f3ca300a..1d83d4eb1 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 @@ -337,3 +337,76 @@ for (const stream of [true, false]) { } }); } + +function cancelRequest(sessionId: string, requestId: string): Request { + return new Request("http://localhost/v1/chat/cancel", { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ session_id: sessionId, request_id: requestId }), + }); +} + +it("only an explicit stop cancels its matching request, including after disconnect", async () => { + const signals = new Map(); + const releases = new Map void>(); + const app = createApp({ + processMessageInternal() {}, + async processDirect(_content: string, options: any) { + const signal: AbortSignal = options.abortSignal; + signals.set(options.sessionKey, signal); + await new Promise((resolve) => { + releases.set(options.sessionKey, resolve); + signal.addEventListener("abort", () => resolve(), { once: true }); + }); + return "finished"; + }, + }, "m"); + const start = (session_id: string, request_id: string) => app.fetch(request({ + messages: [{ role: "user", content: "long tool" }], stream: true, session_id, request_id, + })); + const first = await start("a", "run-a"); + const second = await start("b", "run-b"); + await first.body!.cancel(); + expect(signals.get("api:a")!.aborted).toBe(false); + expect(app.context.activeRequests.size).toBe(2); + expect((await start("a", "run-a")).status).toBe(409); + expect((await app.fetch(cancelRequest("a", "run-b"))).status).toBe(404); + expect(signals.get("api:b")!.aborted).toBe(false); + expect((await app.fetch(cancelRequest("a", "run-a"))).status).toBe(200); + expect(signals.get("api:a")!.aborted).toBe(true); + expect(signals.get("api:b")!.aborted).toBe(false); + releases.get("api:b")!(); + expect(await second.text()).toContain("[DONE]"); + expect(app.context.activeRequests.size).toBe(0); + expect(app.context.sessionLocks.size).toBe(0); + const next = await start("a", "run-next"); + expect((await app.fetch(cancelRequest("a", "run-a"))).status).toBe(404); + expect(signals.get("api:a")!.aborted).toBe(false); + releases.get("api:a")!(); + expect(await next.text()).toContain("[DONE]"); +}); + +it("explicit cancellation ends SSE as cancelled, never as successful completion", async () => { + const app = createApp({ processDirect: async (args: any) => { + await new Promise((resolve) => args.abortSignal.addEventListener("abort", () => resolve(), { once: true })); + return "must not become a successful reply"; + } }, "m"); + const response = await app.fetch(request({ messages: [{ role: "user", content: "go" }], stream: true, session_id: "s", request_id: "r" })); + const output = response.text(); + await app.fetch(cancelRequest("s", "r")); + const text = await output; + expect(text).toContain('"code":"cancelled"'); + expect(text).not.toContain("[DONE]"); + expect(text).not.toContain("successful reply"); +}); + +it("a model or tool error remains a failure, not user cancellation", async () => { + let signal!: AbortSignal; + const app = createApp({ processDirect: async (args: any) => { + signal = args.abortSignal; + throw new Error("tool timed out"); + } }, "m"); + const response = await app.fetch(request({ messages: [{ role: "user", content: "go" }], stream: true })); + expect(await response.text()).toContain('"code":"server_error"'); + expect(signal.aborted).toBe(false); + expect(app.context.activeRequests.size).toBe(0); +}); 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 dbcdb9508..5bc6ad651 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 @@ -123,6 +123,7 @@ describe("OpenAI-compatible API request validation", () => { agentLoop: { processDirect: async () => "unused" }, modelName: "test-model", sessionLocks: new Map(), + activeRequests: new Map(), }, ); const body = (await response.json()) as any; From 30a11991819b75548776a08229577f9396e35839 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BA=AA=E5=B9=BF=E5=B8=9D?= Date: Thu, 15 Oct 2026 19:21:23 +0800 Subject: [PATCH 41/56] fix(desktop): render skill markdown and restore slash command suggestions --- .../desktop/src/api/memmy-agent-client.ts | 13 ------- .../src/api/tests/memmy-agent-client.test.ts | 13 ++++++- .../src/pages/agent-command-palette.tsx | 12 +++++- .../src/pages/memory/skills-sub-page.tsx | 6 ++- ...agent-command-palette.interaction.test.tsx | 39 +++++++++++++++++++ .../tests/agent-command-palette.test.tsx | 13 ++++++- App/frontend/desktop/src/styles.css | 8 ++++ 7 files changed, 84 insertions(+), 20 deletions(-) create mode 100644 App/frontend/desktop/src/pages/tests/agent-command-palette.interaction.test.tsx diff --git a/App/frontend/desktop/src/api/memmy-agent-client.ts b/App/frontend/desktop/src/api/memmy-agent-client.ts index 97ccbd236..2f8d87931 100644 --- a/App/frontend/desktop/src/api/memmy-agent-client.ts +++ b/App/frontend/desktop/src/api/memmy-agent-client.ts @@ -350,18 +350,6 @@ const SlashCommandsResponseSchema = z.object({ commands: z.array(SlashCommandSchema) }); -const WEBUI_HIDDEN_SLASH_COMMANDS = new Set([ - "/stop", - "/restart", - "/dream", - "/dream-log", - "/dream-restore", - "/history", - "/pairing", - "/help", - "/model" -]); - const SidebarStateSchema = z.object({ schema_version: z.literal(1), pinned_keys: z.array(z.string()), @@ -1186,7 +1174,6 @@ class HttpMemmyAgentClient implements MemmyAgentClient { async listSlashCommands(): Promise { const response = await this.request("/api/commands", SlashCommandsResponseSchema); return response.commands - .filter((command) => !WEBUI_HIDDEN_SLASH_COMMANDS.has(command.command)) .map((command) => ({ command: command.command, title: command.title, diff --git a/App/frontend/desktop/src/api/tests/memmy-agent-client.test.ts b/App/frontend/desktop/src/api/tests/memmy-agent-client.test.ts index 63f775c70..d815bd030 100644 --- a/App/frontend/desktop/src/api/tests/memmy-agent-client.test.ts +++ b/App/frontend/desktop/src/api/tests/memmy-agent-client.test.ts @@ -397,7 +397,7 @@ describe("memmy-agent client", () => { expect(paths).toEqual(["/webui/bootstrap"]); }); - it("lists slash commands with bearer token, camelCase mapping, goal exposure, and control-command filtering", async () => { + it("lists all server-supported slash commands with bearer token and camelCase mapping", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { const url = new URL(String(input)); if (url.pathname === "/webui/bootstrap") { @@ -427,9 +427,18 @@ describe("memmy-agent client", () => { const client = createMemmyAgentClient({ baseUrl: "http://127.0.0.1:18980", clientId: "frontend-test", fetchFn: fetchMock as typeof fetch }); await expect(client.listSlashCommands()).resolves.toEqual([ + { command: "/stop", title: "Stop", description: "Stop turn", icon: "square", argHint: "" }, + { command: "/restart", title: "Restart", description: "Restart agent", icon: "rotate-cw", argHint: "" }, + { command: "/dream", title: "Dream", description: "Run Dream", icon: "sparkles", argHint: "" }, + { command: "/dream-log", title: "Dream log", description: "Show Dream log", icon: "book-open", argHint: "" }, + { command: "/dream-restore", title: "Dream restore", description: "Restore Dream", icon: "undo-2", argHint: "" }, + { command: "/history", title: "History", description: "Show history", icon: "history", argHint: "[n]" }, { command: "/goal", title: "Goal", description: "Start goal", icon: "activity", argHint: "" }, + { command: "/pairing", title: "Pairing", description: "Manage pairing", icon: "shield", argHint: "" }, + { command: "/help", title: "Help", description: "Show help", icon: "circle-help", argHint: "" }, { command: "/status", title: "Status", description: "Show status", icon: "activity", argHint: "" }, - { command: "/new", title: "New", description: "New chat", icon: "square-pen", argHint: "" } + { command: "/new", title: "New", description: "New chat", icon: "square-pen", argHint: "" }, + { command: "/model", title: "Model", description: "Switch model", icon: "brain", argHint: "[preset]" } ]); }); diff --git a/App/frontend/desktop/src/pages/agent-command-palette.tsx b/App/frontend/desktop/src/pages/agent-command-palette.tsx index d73271218..64cd18972 100644 --- a/App/frontend/desktop/src/pages/agent-command-palette.tsx +++ b/App/frontend/desktop/src/pages/agent-command-palette.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from "react"; import { Activity, BookOpen, @@ -17,7 +18,6 @@ import type { MemmyAgentSlashCommand } from "../api/memmy-agent-client.js"; import type { MessageKey, ResolvedLanguage } from "../i18n/messages.js"; export const RECENT_SLASH_COMMANDS_STORAGE_KEY = "memmy-agent-recent-slash-commands"; -export const MAX_VISIBLE_SLASH_COMMANDS = 8; export const MAX_RECENT_SLASH_COMMANDS = 5; export type SlashCommandPaletteItem = MemmyAgentSlashCommand & { @@ -132,6 +132,13 @@ export function localizeSlashCommands( } export function AgentCommandPalette(props: AgentCommandPaletteProps) { + const selectedOptionRef = useRef(null); + const selectedCommand = props.commands[props.selectedIndex]?.command; + + useEffect(() => { + selectedOptionRef.current?.scrollIntoView({ block: "nearest" }); + }, [props.selectedIndex, selectedCommand]); + if (!props.commands.length) { return null; } @@ -156,6 +163,7 @@ export function AgentCommandPalette(props: AgentCommandPaletteProps) { type="button" role="option" aria-selected={selected} + ref={selected ? selectedOptionRef : undefined} onClick={() => props.onSelect(command)} className={`w-full rounded-btn px-2.5 py-2 text-left cursor-pointer transition-colors flex items-start gap-2.5 ${selected ? "bg-canvas-oat/80" : "hover:bg-canvas-oat/50"}`} > @@ -203,7 +211,7 @@ export function filterSlashCommands( const matched = normalizedQuery ? commands.filter((command) => commandMatchesQuery(command, normalizedQuery)) : sortByRecent(commands, recentCommands); - return matched.slice(0, MAX_VISIBLE_SLASH_COMMANDS); + return matched; } export function readRecentSlashCommands(storage: SlashCommandStorageLike | null = browserStorage()): string[] { diff --git a/App/frontend/desktop/src/pages/memory/skills-sub-page.tsx b/App/frontend/desktop/src/pages/memory/skills-sub-page.tsx index a244761c6..620f85a50 100644 --- a/App/frontend/desktop/src/pages/memory/skills-sub-page.tsx +++ b/App/frontend/desktop/src/pages/memory/skills-sub-page.tsx @@ -16,6 +16,7 @@ import { MemoryDrawerDeleteAction } from "./memory-delete-action.js"; import { toMemoryDetailErrorMessage } from "./memory-detail-error.js"; import { cleanMemoryBody, cleanMemoryText, drawerEyebrow } from "./memory-display.js"; import { displayMemoryId } from "./memory-id.js"; +import { MemoryMarkdown } from "./memory-markdown.js"; import { MemoryReferenceTags, type MemoryReferenceOpenRequest, @@ -482,7 +483,10 @@ function SkillDetail(props: { {skill.usageGuide && } - +
+
{t("memory.skills.body")}
+ +
{hasDecisionGuidance && (
diff --git a/App/frontend/desktop/src/pages/tests/agent-command-palette.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/agent-command-palette.interaction.test.tsx new file mode 100644 index 000000000..1157fd9bf --- /dev/null +++ b/App/frontend/desktop/src/pages/tests/agent-command-palette.interaction.test.tsx @@ -0,0 +1,39 @@ +// @vitest-environment happy-dom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; +import { AgentCommandPalette, filterSlashCommands } from "../agent-command-palette.js"; + +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +it("scrolls to and selects commands beyond the initial visible rows", () => { + const names = ["/new", "/restart", "/status", "/model", "/history", "/history-dag", "/goal", "/help", "/pairing"]; + const commands = filterSlashCommands(names.map((command) => ({ + command, title: command, description: "", icon: "activity", argHint: "" + })), "", []); + const scrollIntoView = vi.fn(); + const originalScroll = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollIntoView"); + Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { configurable: true, value: scrollIntoView }); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const onSelect = vi.fn(); + try { + act(() => root.render()); + expect(container.querySelectorAll('[role="option"]')).toHaveLength(names.length); + scrollIntoView.mockClear(); + act(() => root.render()); + const selected = container.querySelector('[aria-selected="true"]')!; + expect(selected.textContent).toContain("/pairing"); + expect(scrollIntoView).toHaveBeenCalledWith({ block: "nearest" }); + expect(scrollIntoView.mock.contexts[0]).toBe(selected); + act(() => selected.click()); + expect(onSelect).toHaveBeenCalledWith(commands[8]); + } finally { + act(() => root.unmount()); + container.remove(); + if (originalScroll) Object.defineProperty(HTMLElement.prototype, "scrollIntoView", originalScroll); + else Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); + } +}); diff --git a/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx b/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx index 09128ee84..8d61354f7 100644 --- a/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx +++ b/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx @@ -29,10 +29,19 @@ describe("AgentCommandPalette", () => { expect(slashQueryFromInput("show /history")).toBeNull(); }); - it("filters by command metadata and caps visible results", () => { + it("filters by command metadata", () => { expect(filterSlashCommands(commands, "his", []).map((command) => command.command)).toEqual(["/history"]); expect(filterSlashCommands(commands, "preset", []).map((command) => command.command)).toEqual(["/model"]); - expect(filterSlashCommands([...commands, ...commands, ...commands], "", [])).toHaveLength(8); + }); + + it("keeps every available command reachable beyond the first eight results", () => { + const available = ["/new", "/restart", "/status", "/model", "/history", "/history-dag", "/goal", "/help", "/pairing", "/last-compaction"] + .map((command) => ({ command, title: command, description: "Available command", icon: "activity", argHint: "" })); + expect(filterSlashCommands(available, "", [])).toEqual(available); + expect(filterSlashCommands(available, "available", [])).toEqual(available); + for (const command of available) { + expect(filterSlashCommands(available, slashQueryFromInput(command.command)!, [])).toContainEqual(command); + } }); it("orders empty-query results by recent commands without storing /stop", () => { diff --git a/App/frontend/desktop/src/styles.css b/App/frontend/desktop/src/styles.css index 375ca9c0f..c60c44ba3 100644 --- a/App/frontend/desktop/src/styles.css +++ b/App/frontend/desktop/src/styles.css @@ -5781,6 +5781,14 @@ body.memmy-platform-windows .memory-drawer { margin-top: 3px; } +ul.memory-markdown__list { + list-style-type: disc; +} + +ol.memory-markdown__list { + list-style-type: decimal; +} + .memory-markdown__quote, .memory-log-markdown blockquote { margin: 0 0 8px; From 16920de1374b0d28440168e8dda37bacec9e49fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BA=AA=E5=B9=BF=E5=B8=9D?= Date: Thu, 15 Oct 2026 19:37:52 +0800 Subject: [PATCH 42/56] fix(desktop): limit command palette height and surface stop in search --- .../src/pages/agent-command-palette.tsx | 11 +++- App/frontend/desktop/src/pages/home-page.tsx | 2 +- .../tests/agent-command-palette.test.tsx | 12 +++- .../src/pages/tests/home-page.test.tsx | 2 +- docs/cn/reference/meta.json | 2 +- docs/cn/reference/slash-commands.mdx | 65 +++++++++++++++++++ 6 files changed, 86 insertions(+), 8 deletions(-) create mode 100644 docs/cn/reference/slash-commands.mdx diff --git a/App/frontend/desktop/src/pages/agent-command-palette.tsx b/App/frontend/desktop/src/pages/agent-command-palette.tsx index 64cd18972..acce36bfa 100644 --- a/App/frontend/desktop/src/pages/agent-command-palette.tsx +++ b/App/frontend/desktop/src/pages/agent-command-palette.tsx @@ -19,6 +19,9 @@ import type { MessageKey, ResolvedLanguage } from "../i18n/messages.js"; export const RECENT_SLASH_COMMANDS_STORAGE_KEY = "memmy-agent-recent-slash-commands"; export const MAX_RECENT_SLASH_COMMANDS = 5; +const DEFAULT_VISIBLE_COMMAND_ROWS = 5; +const COMMAND_ROW_HEIGHT = 56; +const COMMAND_LIST_BOTTOM_PADDING = 6; export type SlashCommandPaletteItem = MemmyAgentSlashCommand & { synthetic?: boolean; @@ -152,7 +155,7 @@ export function AgentCommandPalette(props: AgentCommandPaletteProps) { role="listbox" aria-labelledby="agent-command-palette-heading" className="px-1.5 pb-1.5 overflow-y-auto" - style={{ maxHeight: "min(432px, calc(100vh - 260px))" }} + style={{ maxHeight: `min(${DEFAULT_VISIBLE_COMMAND_ROWS * COMMAND_ROW_HEIGHT + COMMAND_LIST_BOTTOM_PADDING}px, calc(100vh - 260px))` }} > {props.commands.map((command, index) => { const Icon = iconByName[command.icon] ?? Activity; @@ -164,6 +167,7 @@ export function AgentCommandPalette(props: AgentCommandPaletteProps) { role="option" aria-selected={selected} ref={selected ? selectedOptionRef : undefined} + style={{ height: COMMAND_ROW_HEIGHT, boxSizing: "border-box" }} onClick={() => props.onSelect(command)} className={`w-full rounded-btn px-2.5 py-2 text-left cursor-pointer transition-colors flex items-start gap-2.5 ${selected ? "bg-canvas-oat/80" : "hover:bg-canvas-oat/50"}`} > @@ -196,10 +200,11 @@ export function slashQueryFromInput(input: string): string | null { export function buildVisibleSlashCommands( commands: MemmyAgentSlashCommand[], isSending: boolean, - stopCommand: SlashCommandPaletteItem + stopCommand: SlashCommandPaletteItem, + query = "" ): SlashCommandPaletteItem[] { const filtered = commands.filter((command) => command.command !== "/stop"); - return isSending ? [stopCommand, ...filtered] : filtered; + return isSending || query.length > 0 ? [stopCommand, ...filtered] : filtered; } export function filterSlashCommands( diff --git a/App/frontend/desktop/src/pages/home-page.tsx b/App/frontend/desktop/src/pages/home-page.tsx index d823d2eae..6f069d929 100644 --- a/App/frontend/desktop/src/pages/home-page.tsx +++ b/App/frontend/desktop/src/pages/home-page.tsx @@ -1539,7 +1539,7 @@ export function HomePage() { lastCompactionSlashCommand, ...localizedSlashCommands.filter((command) => command.command !== "/last-compaction") ]; - const visibleSlashCommands = buildVisibleSlashCommands(slashCommandsWithLocal, state.agent.isSending, stopSlashCommand); + const visibleSlashCommands = buildVisibleSlashCommands(slashCommandsWithLocal, state.agent.isSending, stopSlashCommand, slashQuery ?? ""); const modeVisibleSlashCommands = selectedComposerCommand ? filterGoalModeSlashCommands(visibleSlashCommands, Boolean(state.agent.currentChatId)) : visibleSlashCommands; diff --git a/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx b/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx index 8d61354f7..2a8f53e78 100644 --- a/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx +++ b/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx @@ -58,11 +58,18 @@ describe("AgentCommandPalette", () => { expect(readRecentSlashCommands(storage)).toEqual(["/history", "/model"]); }); - it("prepends a synthetic /stop command only while streaming", () => { + it("shows /stop while streaming or searching, but not in idle default suggestions", () => { const stopCommand: SlashCommandPaletteItem = { command: "/stop", title: "Stop", description: "Cancel turn", icon: "square", argHint: "", synthetic: true }; expect(buildVisibleSlashCommands(commands, false, stopCommand).map((command) => command.command)).not.toContain("/stop"); expect(buildVisibleSlashCommands([{ ...stopCommand, synthetic: false }, ...commands], true, stopCommand).map((command) => command.command).slice(0, 2)).toEqual(["/stop", "/status"]); + for (const input of ["/s", "/stop"]) { + const query = slashQueryFromInput(input)!; + const visible = buildVisibleSlashCommands([stopCommand, ...commands], false, stopCommand, query); + const matches = filterSlashCommands(visible, query, []); + expect(matches.filter((command) => command.command === "/stop")).toEqual([stopCommand]); + } + expect(filterSlashCommands(buildVisibleSlashCommands(commands, false, stopCommand, "model"), "model", [])).not.toContainEqual(stopCommand); }); it("localizes builtin command titles and descriptions for Chinese UI", () => { @@ -110,7 +117,8 @@ describe("AgentCommandPalette", () => { expect(html).toContain('aria-selected="true"'); expect(html).toContain("w-full"); expect(html).toContain('aria-labelledby="agent-command-palette-heading"'); - expect(html).toContain("max-height:min(432px, calc(100vh - 260px))"); + expect(html).toContain("max-height:min(286px, calc(100vh - 260px))"); + expect(html).toContain("height:56px;box-sizing:border-box"); expect(html).toContain("rounded-card"); expect(html).toContain("rounded-btn px-2.5 py-2"); expect(html).toContain("shrink-0 whitespace-nowrap font-mono"); 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..fc55e1bee 100644 --- a/App/frontend/desktop/src/pages/tests/home-page.test.tsx +++ b/App/frontend/desktop/src/pages/tests/home-page.test.tsx @@ -270,7 +270,7 @@ describe("HomePage", () => { expect(source).toContain("const slashCommandsWithLocal = ["); expect(source).toContain("lastCompactionSlashCommand,"); expect(source).toContain('...localizedSlashCommands.filter((command) => command.command !== "/last-compaction")'); - expect(source).toContain("buildVisibleSlashCommands(slashCommandsWithLocal, state.agent.isSending, stopSlashCommand)"); + expect(source).toContain('buildVisibleSlashCommands(slashCommandsWithLocal, state.agent.isSending, stopSlashCommand, slashQuery ?? "")'); expect(source).toContain("{statusPanel.open && !slashMenuOpen && ("); expect(source).toContain("{lastCompactionPanel.open && !slashMenuOpen && ("); expect(source).toContain("{lastCompactionPanel.open && !statusPanel.open && !slashMenuOpen && ("); diff --git a/docs/cn/reference/meta.json b/docs/cn/reference/meta.json index 839fef3da..710a84cef 100644 --- a/docs/cn/reference/meta.json +++ b/docs/cn/reference/meta.json @@ -1,5 +1,5 @@ { "title": "参考", "icon": "Library", - "pages": ["openai-api", "memory-api", "local-api", "ports"] + "pages": ["slash-commands", "openai-api", "memory-api", "local-api", "ports"] } diff --git a/docs/cn/reference/slash-commands.mdx b/docs/cn/reference/slash-commands.mdx new file mode 100644 index 000000000..2b3609fde --- /dev/null +++ b/docs/cn/reference/slash-commands.mdx @@ -0,0 +1,65 @@ +--- +title: 斜杠命令一览 +icon: Terminal +--- + +本文整理聊天输入框中的全部 14 个斜杠命令,包括 13 个 Agent 内置命令和桌面专用的 `/last-compaction`,不包含终端里的 `memmy` CLI 子命令。 + +## 输入与提示 + +- 输入 `/` 后,面板默认显示最多 5 行,其余命令可滚动查看。较小窗口下可见行数会进一步减少。 +- 继续输入名称可筛选命令,例如 `/s`、`/model`、`/history`。筛选不会截断结果。 +- 使用上下方向键选择,Enter 或 Tab 选中,Escape 关闭提示。带参数的命令可继续填写参数后发送。 +- 空闲时,默认列表不显示 `/stop`;输入 `/s` 或 `/stop` 时仍会显示匹配提示。任务运行时,默认列表也会显示 `/stop`。 +- 最近使用的命令优先排列,`/stop` 不计入最近使用记录。 + +## 完整命令表 + +方括号表示可选参数,尖括号表示需要替换为实际内容的参数。 + +| 命令 | 用途 | 参数与可用条件 | +| --- | --- | --- | +| `/new` | 停止当前任务并开始全新对话。 | 无参数。桌面中选择后直接执行新会话操作。 | +| `/stop` | 停止当前任务;后端也支持暂停当前活跃 Goal。 | 无参数。桌面提示项在有任务运行时执行停止,空闲时选中不会创建任务。 | +| `/restart` | 重启 Agent 进程。 | 无参数。桌面托管运行时由桌面管理器处理重启。 | +| `/status` | 查看运行时、模型供应商及频道状态。 | 无参数。桌面中打开状态面板。 | +| `/model [list\|preset]` | 查看当前模型预设、列出预设或切换模型预设。 | 无参数查看当前配置;`/model list` 列出预设;`/model ` 切换至已配置的预设。 | +| `/history [n]` | 查看已保存的对话消息。 | 默认 10 条,最多 50 条;例如 `/history 5`。不包含命令消息及内部 Goal 续跑上下文。 | +| `/history-dag` | 查看当前会话的任务状态 DAG。 | 需要启用 `sessionDag.enabled`;关闭时不会出现在后端提供的命令列表中。桌面中打开 DAG 面板。 | +| `/goal [子命令或目标]` | 创建、查询、暂停、恢复或调整长期目标。 | 完整子命令见下表。桌面中从提示选中 `/goal` 后,会进入目标输入模式。 | +| `/dream` | 手动触发文件记忆整合。 | 仅在 `fileMemory.enabled=true` 时提供。 | +| `/dream-log [sha]` | 查看最近一次 Dream 修改,或指定版本的修改。 | 需要启用文件记忆,并已有可用的记忆版本记录。 | +| `/dream-restore [sha]` | 列出最近 10 个记忆版本,或撤销指定 Dream 修改。 | 需要启用文件记忆及版本记录;指定 `sha` 后恢复到该次修改之前的状态,并创建新的安全提交。 | +| `/help` | 列出 Agent 内置斜杠命令及说明。 | 不包含桌面本地命令 `/last-compaction`。Dream 命令随文件记忆配置显示。 | +| `/pairing [子命令]` | 管理频道接入配对请求与授权。 | 支持列出、批准、拒绝及撤销,完整子命令见下表。 | +| `/last-compaction` | 查看当前会话最近一次文本压缩或 DAG 上下文摘要。 | 桌面专用,打开本地面板;内容取决于当前会话是否已有压缩快照。 | + +## Goal 子命令 + +| 写法 | 行为 | +| --- | --- | +| `/goal` 或 `/goal status` | 查看当前 Goal 状态。 | +| `/goal help` | 查看 Goal 使用说明。 | +| `/goal ` 或 `/goal create ` | 创建长期目标。 | +| `/goal pause` | 暂停 Goal,并停止当前 Goal 回合。 | +| `/goal resume` | 恢复可续跑的 Goal。达到用量限制时需先恢复供应商容量;达到预算限制时需先提高或移除预算。 | +| `/goal edit ` | 修改非运行状态的 Goal 目标。 | +| `/goal budget <正整数>` | 设置累计 token 预算。 | +| `/goal budget none` | 移除累计 token 预算。 | +| `/goal clear` | 取消并移除当前 Goal。 | + +桌面中从提示选中 `/goal` 会将其显示为目标标签,后续输入作为目标内容。若需要执行管理子命令,可直接输入完整命令及参数后发送。目标标签模式只提供状态、DAG 和压缩摘要等辅助命令。 + +## Pairing 子命令 + +| 写法 | 行为 | +| --- | --- | +| `/pairing` 或 `/pairing list` | 列出待处理配对请求。 | +| `/pairing approve ` | 批准指定配对码。 | +| `/pairing deny ` | 拒绝指定配对码。 | +| `/pairing revoke ` | 撤销当前频道中该用户的授权。 | +| `/pairing revoke ` | 撤销指定频道中该用户的授权。 | + +## 维护来源 + +命令定义和执行逻辑位于 `App/memmy-agent/src/command/builtin.ts`,配对子命令位于 `App/memmy-agent/src/integrations/channel-auth/store.ts`。桌面本地命令及交互位于 `App/frontend/desktop/src/pages/home-page.tsx`,提示过滤和展示位于 `App/frontend/desktop/src/pages/agent-command-palette.tsx`。 From 1755344ad368d3f6099097ceb531d4dd17107f9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BA=AA=E5=B9=BF=E5=B8=9D?= Date: Thu, 15 Oct 2026 19:40:01 +0800 Subject: [PATCH 43/56] fix(desktop): match slash suggestions by command name prefix --- .../src/pages/agent-command-palette.tsx | 3 +-- .../tests/agent-command-palette.test.tsx | 22 ++++++++++++++++--- docs/cn/reference/slash-commands.mdx | 2 +- 3 files changed, 21 insertions(+), 6 deletions(-) diff --git a/App/frontend/desktop/src/pages/agent-command-palette.tsx b/App/frontend/desktop/src/pages/agent-command-palette.tsx index acce36bfa..307c14898 100644 --- a/App/frontend/desktop/src/pages/agent-command-palette.tsx +++ b/App/frontend/desktop/src/pages/agent-command-palette.tsx @@ -248,8 +248,7 @@ export function updateRecentSlashCommands(command: string, previous: string[]): } function commandMatchesQuery(command: SlashCommandPaletteItem, query: string): boolean { - return [command.command, command.title, command.description, command.argHint] - .some((value) => value.toLowerCase().includes(query)); + return command.command.toLowerCase().startsWith(`/${query}`); } function sortByRecent(commands: SlashCommandPaletteItem[], recentCommands: string[]): SlashCommandPaletteItem[] { diff --git a/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx b/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx index 2a8f53e78..daeae09d5 100644 --- a/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx +++ b/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx @@ -29,16 +29,17 @@ describe("AgentCommandPalette", () => { expect(slashQueryFromInput("show /history")).toBeNull(); }); - it("filters by command metadata", () => { + it("filters only by command-name prefix, case-insensitively", () => { expect(filterSlashCommands(commands, "his", []).map((command) => command.command)).toEqual(["/history"]); - expect(filterSlashCommands(commands, "preset", []).map((command) => command.command)).toEqual(["/model"]); + expect(filterSlashCommands(commands, "MoD", []).map((command) => command.command)).toEqual(["/model"]); + expect(filterSlashCommands(commands, "preset", [])).toEqual([]); + expect(filterSlashCommands(commands, "istory", [])).toEqual([]); }); it("keeps every available command reachable beyond the first eight results", () => { const available = ["/new", "/restart", "/status", "/model", "/history", "/history-dag", "/goal", "/help", "/pairing", "/last-compaction"] .map((command) => ({ command, title: command, description: "Available command", icon: "activity", argHint: "" })); expect(filterSlashCommands(available, "", [])).toEqual(available); - expect(filterSlashCommands(available, "available", [])).toEqual(available); for (const command of available) { expect(filterSlashCommands(available, slashQueryFromInput(command.command)!, [])).toContainEqual(command); } @@ -72,6 +73,21 @@ describe("AgentCommandPalette", () => { expect(filterSlashCommands(buildVisibleSlashCommands(commands, false, stopCommand, "model"), "model", [])).not.toContainEqual(stopCommand); }); + it("does not suggest /new when its description contains Stop", () => { + const stopCommand: SlashCommandPaletteItem = { command: "/stop", title: "Stop", description: "Cancel turn", icon: "square", argHint: "", synthetic: true }; + const available = [ + ...commands, + { command: "/new", title: "New chat", description: "Stop the current task and start a fresh conversation.", icon: "square-pen", argHint: "" } + ]; + for (const isSending of [false, true]) { + for (const [input, expected] of [["/s", ["/stop", "/status"]], ["/stop", ["/stop"]]] as const) { + const query = slashQueryFromInput(input)!; + const visible = buildVisibleSlashCommands(available, isSending, stopCommand, query); + expect(filterSlashCommands(visible, query, []).map((command) => command.command)).toEqual(expected); + } + } + }); + it("localizes builtin command titles and descriptions for Chinese UI", () => { const localized = localizeSlashCommands( [ diff --git a/docs/cn/reference/slash-commands.mdx b/docs/cn/reference/slash-commands.mdx index 2b3609fde..48e92f94e 100644 --- a/docs/cn/reference/slash-commands.mdx +++ b/docs/cn/reference/slash-commands.mdx @@ -8,7 +8,7 @@ icon: Terminal ## 输入与提示 - 输入 `/` 后,面板默认显示最多 5 行,其余命令可滚动查看。较小窗口下可见行数会进一步减少。 -- 继续输入名称可筛选命令,例如 `/s`、`/model`、`/history`。筛选不会截断结果。 +- 继续输入名称可按命令名前缀筛选,不匹配说明文字。例如 `/s` 匹配 `/stop` 和 `/status`,`/stop` 只匹配 `/stop`。筛选不会截断结果。 - 使用上下方向键选择,Enter 或 Tab 选中,Escape 关闭提示。带参数的命令可继续填写参数后发送。 - 空闲时,默认列表不显示 `/stop`;输入 `/s` 或 `/stop` 时仍会显示匹配提示。任务运行时,默认列表也会显示 `/stop`。 - 最近使用的命令优先排列,`/stop` 不计入最近使用记录。 From 7de08d598a5667dbaf21e05612c59ee8e5e245fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BA=AA=E5=B9=BF=E5=B8=9D?= Date: Thu, 15 Oct 2026 19:47:15 +0800 Subject: [PATCH 44/56] feat(desktop): complete slash subcommands and model presets --- .../src/pages/agent-command-palette.tsx | 42 +++++++++++++++--- App/frontend/desktop/src/pages/home-page.tsx | 2 +- .../tests/agent-command-palette.test.tsx | 44 ++++++++++++++++++- docs/cn/reference/slash-commands.mdx | 2 + 4 files changed, 83 insertions(+), 7 deletions(-) diff --git a/App/frontend/desktop/src/pages/agent-command-palette.tsx b/App/frontend/desktop/src/pages/agent-command-palette.tsx index 307c14898..959ff4565 100644 --- a/App/frontend/desktop/src/pages/agent-command-palette.tsx +++ b/App/frontend/desktop/src/pages/agent-command-palette.tsx @@ -23,6 +23,20 @@ const DEFAULT_VISIBLE_COMMAND_ROWS = 5; const COMMAND_ROW_HEIGHT = 56; const COMMAND_LIST_BOTTOM_PADDING = 6; +// Literal subcommands only; free-form arguments are displayed as hints. +const SUBCOMMANDS: Record> = { + "/goal": [ + ["status", ""], ["help", ""], ["create", ""], + ["pause", ""], ["resume", ""], ["edit", ""], + ["budget", ""], ["budget none", ""], ["clear", ""] + ], + "/pairing": [ + ["list", ""], ["approve", ""], ["deny", ""], + ["revoke", "[channel] "] + ], + "/model": [["list", ""]] +}; + export type SlashCommandPaletteItem = MemmyAgentSlashCommand & { synthetic?: boolean; }; @@ -190,11 +204,10 @@ export function AgentCommandPalette(props: AgentCommandPaletteProps) { } export function slashQueryFromInput(input: string): string | null { - if (!input.startsWith("/")) { + if (!/^\/(?:[^\s/]+(?:[\t ][^\r\n]*)?)?$/.test(input)) { return null; } - const token = input.slice(1); - return /\s/.test(token) ? null : token.toLowerCase(); + return input.slice(1).replace(/[\t ]+/g, " ").toLowerCase(); } export function buildVisibleSlashCommands( @@ -210,9 +223,28 @@ export function buildVisibleSlashCommands( export function filterSlashCommands( commands: SlashCommandPaletteItem[], query: string, - recentCommands: string[] + recentCommands: string[], + modelPresets: ReadonlyArray<{ name: string; available: boolean }> = [] ): SlashCommandPaletteItem[] { - const normalizedQuery = query.toLowerCase(); + const normalizedQuery = query.toLowerCase().replace(/[\t ]+/g, " "); + if (normalizedQuery.includes(" ")) { + const [root] = normalizedQuery.split(" "); + const parent = commands.find((command) => command.command.toLowerCase() === `/${root}`); + if (!parent) return []; + const suffixes = [...(SUBCOMMANDS[parent.command.toLowerCase()] ?? [])]; + if (parent.command === "/model") { + for (const preset of modelPresets) { + if (preset.available && !/\s/.test(preset.name) && preset.name.toLowerCase() !== "list") { + suffixes.push([preset.name, ""]); + } + } + } + const depth = normalizedQuery.split(" ").length - 1; + return suffixes + .filter(([suffix]) => suffix.split(" ").length === depth) + .map(([suffix, argHint]) => ({ ...parent, command: `${parent.command} ${suffix}`, argHint })) + .filter((command) => commandMatchesQuery(command, normalizedQuery)); + } const matched = normalizedQuery ? commands.filter((command) => commandMatchesQuery(command, normalizedQuery)) : sortByRecent(commands, recentCommands); diff --git a/App/frontend/desktop/src/pages/home-page.tsx b/App/frontend/desktop/src/pages/home-page.tsx index 6f069d929..ff74512f9 100644 --- a/App/frontend/desktop/src/pages/home-page.tsx +++ b/App/frontend/desktop/src/pages/home-page.tsx @@ -1543,7 +1543,7 @@ export function HomePage() { const modeVisibleSlashCommands = selectedComposerCommand ? filterGoalModeSlashCommands(visibleSlashCommands, Boolean(state.agent.currentChatId)) : visibleSlashCommands; - const filteredSlashCommands = slashQuery == null ? [] : filterSlashCommands(modeVisibleSlashCommands, slashQuery, recentSlashCommands); + const filteredSlashCommands = slashQuery == null ? [] : filterSlashCommands(modeVisibleSlashCommands, slashQuery, recentSlashCommands, state.agent.modelPresets); const slashMenuOpen = filteredSlashCommands.length > 0; const displayConnectionStatus = state.agent.recoveryKind === "initial" ? "connecting" diff --git a/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx b/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx index daeae09d5..d46204039 100644 --- a/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx +++ b/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx @@ -25,7 +25,10 @@ describe("AgentCommandPalette", () => { it("detects slash query mode only for a leading command token", () => { expect(slashQueryFromInput("/")).toBe(""); expect(slashQueryFromInput("/his")).toBe("his"); - expect(slashQueryFromInput("/history 5")).toBeNull(); + expect(slashQueryFromInput("/history 5")).toBe("history 5"); + expect(slashQueryFromInput("/GOAL p")).toBe("goal p"); + expect(slashQueryFromInput("/goal ")).toBe("goal "); + expect(slashQueryFromInput("/goal\npause")).toBeNull(); expect(slashQueryFromInput("show /history")).toBeNull(); }); @@ -45,6 +48,45 @@ describe("AgentCommandPalette", () => { } }); + it("matches every literal subcommand at its current depth", () => { + const available = ["/goal", "/pairing", "/model", "/history", "/dream-log"].map((command) => ({ + command, title: command, description: "", icon: "activity", argHint: "" + })); + const match = (input: string) => filterSlashCommands(available, slashQueryFromInput(input)!, []); + const expected = { + "/goal ": ["status", "help", "create", "pause", "resume", "edit", "budget", "clear"], + "/pairing ": ["list", "approve", "deny", "revoke"], + "/model ": ["list"], + "/goal budget ": ["none"] + }; + for (const [prefix, suffixes] of Object.entries(expected)) { + expect(match(prefix).map((item) => item.command)).toEqual(suffixes.map((suffix) => prefix + suffix)); + for (const suffix of suffixes) { + expect(match(prefix + suffix).map((item) => item.command)).toEqual([prefix + suffix]); + expect(match(prefix + suffix[0]).map((item) => item.command)).toContain(prefix + suffix); + } + } + expect(match("/GOAL P").map((item) => item.command)).toEqual(["/goal pause"]); + expect(match("/pairing a")[0]).toMatchObject({ command: "/pairing approve", argHint: "" }); + expect(match("/goal c").map((item) => item.command)).toEqual(["/goal create", "/goal clear"]); + expect(match("/goal create")[0]?.argHint).toBe(""); + for (const input of ["/history 5", "/dream-log abc", "/pairing approve abc", "/goal create write tests", "/goal budget 1000", "/goal status ", "/goal my objective"]) { + expect(match(input)).toEqual([]); + } + expect(filterSlashCommands([], "goal p", [])).toEqual([]); + }); + + it("matches available model preset names without changing their case", () => { + const presets = [ + { name: "FastModel", available: true }, + { name: "unavailable", available: false } + ]; + expect(filterSlashCommands(commands, "model ", [], presets).map((item) => item.command)).toEqual(["/model list", "/model FastModel"]); + expect(filterSlashCommands(commands, "model fa", [], presets)[0]).toMatchObject({ command: "/model FastModel", argHint: "" }); + expect(filterSlashCommands(commands, "model un", [], presets)).toEqual([]); + expect(filterSlashCommands(commands, "model FastModel ", [], presets)).toEqual([]); + }); + it("orders empty-query results by recent commands without storing /stop", () => { const recent = updateRecentSlashCommands("/history", updateRecentSlashCommands("/stop", ["/model"])); expect(recent).toEqual(["/history", "/model"]); diff --git a/docs/cn/reference/slash-commands.mdx b/docs/cn/reference/slash-commands.mdx index 48e92f94e..742b03681 100644 --- a/docs/cn/reference/slash-commands.mdx +++ b/docs/cn/reference/slash-commands.mdx @@ -9,6 +9,8 @@ icon: Terminal - 输入 `/` 后,面板默认显示最多 5 行,其余命令可滚动查看。较小窗口下可见行数会进一步减少。 - 继续输入名称可按命令名前缀筛选,不匹配说明文字。例如 `/s` 匹配 `/stop` 和 `/status`,`/stop` 只匹配 `/stop`。筛选不会截断结果。 +- 主命令后输入空格可查看子命令,继续输入则按子命令前缀筛选。例如 `/goal p` 匹配 `/goal pause`,`/pairing a` 匹配 `/pairing approve`,`/goal budget n` 匹配 `/goal budget none`。`/model ` 还会显示当前可用的模型预设,支持按预设名前缀筛选。 +- 选中子命令后会填入完整命令;需要参数时保留空格供继续填写。目标内容、数字、配对码、用户 ID 和版本 SHA 是自由参数,不会被替换成子命令。填写完成后发送执行。 - 使用上下方向键选择,Enter 或 Tab 选中,Escape 关闭提示。带参数的命令可继续填写参数后发送。 - 空闲时,默认列表不显示 `/stop`;输入 `/s` 或 `/stop` 时仍会显示匹配提示。任务运行时,默认列表也会显示 `/stop`。 - 最近使用的命令优先排列,`/stop` 不计入最近使用记录。 From 117f7b2b6b5d359d29d1cb259f6586512bee6fff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BA=AA=E5=B9=BF=E5=B8=9D?= Date: Thu, 15 Oct 2026 19:54:27 +0800 Subject: [PATCH 45/56] fix(desktop): expand exact commands and place closest suggestions last --- .../src/pages/agent-command-palette.tsx | 49 ++++++++++++------- App/frontend/desktop/src/pages/home-page.tsx | 8 ++- ...agent-command-palette.interaction.test.tsx | 15 +++++- .../tests/agent-command-palette.test.tsx | 38 ++++++++++++-- docs/cn/reference/slash-commands.mdx | 4 +- 5 files changed, 84 insertions(+), 30 deletions(-) diff --git a/App/frontend/desktop/src/pages/agent-command-palette.tsx b/App/frontend/desktop/src/pages/agent-command-palette.tsx index 959ff4565..82d5a6ee3 100644 --- a/App/frontend/desktop/src/pages/agent-command-palette.tsx +++ b/App/frontend/desktop/src/pages/agent-command-palette.tsx @@ -227,28 +227,39 @@ export function filterSlashCommands( modelPresets: ReadonlyArray<{ name: string; available: boolean }> = [] ): SlashCommandPaletteItem[] { const normalizedQuery = query.toLowerCase().replace(/[\t ]+/g, " "); - if (normalizedQuery.includes(" ")) { - const [root] = normalizedQuery.split(" "); - const parent = commands.find((command) => command.command.toLowerCase() === `/${root}`); - if (!parent) return []; - const suffixes = [...(SUBCOMMANDS[parent.command.toLowerCase()] ?? [])]; - if (parent.command === "/model") { - for (const preset of modelPresets) { - if (preset.available && !/\s/.test(preset.name) && preset.name.toLowerCase() !== "list") { - suffixes.push([preset.name, ""]); - } + if (!normalizedQuery) return sortByRecent(commands, recentCommands); + + const [root] = normalizedQuery.split(" "); + const parent = commands.find((command) => command.command.toLowerCase() === `/${root}`); + const suffixes = [...(SUBCOMMANDS[parent?.command.toLowerCase() ?? ""] ?? [])]; + if (parent?.command === "/model") { + for (const preset of modelPresets) { + if (preset.available && !/\s/.test(preset.name) && preset.name.toLowerCase() !== "list") { + suffixes.push([preset.name, ""]); } } - const depth = normalizedQuery.split(" ").length - 1; - return suffixes - .filter(([suffix]) => suffix.split(" ").length === depth) - .map(([suffix, argHint]) => ({ ...parent, command: `${parent.command} ${suffix}`, argHint })) - .filter((command) => commandMatchesQuery(command, normalizedQuery)); } - const matched = normalizedQuery - ? commands.filter((command) => commandMatchesQuery(command, normalizedQuery)) - : sortByRecent(commands, recentCommands); - return matched; + const descendants = parent ? suffixes.map(([suffix, argHint]) => ({ + ...parent, command: `${parent.command} ${suffix}`, argHint + })) : []; + const depth = normalizedQuery.split(" ").length; + const candidates = depth === 1 ? commands : descendants.filter((command) => command.command.split(" ").length === depth); + const matched = candidates.filter((command) => commandMatchesQuery(command, normalizedQuery)); + + // Expand a complete command immediately, without requiring a trailing space. + if (matched.some((command) => command.command.toLowerCase() === `/${normalizedQuery}`)) { + matched.push(...descendants.filter((command) => + command.command.split(" ").length === depth + 1 + && command.command.toLowerCase().startsWith(`/${normalizedQuery} `) + )); + } + + // Suggestions open above the input: the shortest remaining completion is closest. + return matched.sort((left, right) => right.command.length - left.command.length); +} + +export function defaultSlashCommandIndex(query: string | null, count: number): number { + return query ? Math.max(0, count - 1) : 0; } export function readRecentSlashCommands(storage: SlashCommandStorageLike | null = browserStorage()): string[] { diff --git a/App/frontend/desktop/src/pages/home-page.tsx b/App/frontend/desktop/src/pages/home-page.tsx index ff74512f9..b078950e0 100644 --- a/App/frontend/desktop/src/pages/home-page.tsx +++ b/App/frontend/desktop/src/pages/home-page.tsx @@ -55,6 +55,7 @@ import { createModelWorkspace, defaultModelSelectionInput, resolveModelSelection import { AgentCommandPalette, buildVisibleSlashCommands, + defaultSlashCommandIndex, filterSlashCommands, localizeSlashCommands, readRecentSlashCommands, @@ -1587,10 +1588,8 @@ export function HomePage() { const composerSubmitDisabled = composerPrimaryAction === "stop" ? composerStopDisabled : composerSendDisabled; useEffect(() => { - if (selectedCommandIndex >= filteredSlashCommands.length) { - setSelectedCommandIndex(0); - } - }, [filteredSlashCommands.length, selectedCommandIndex]); + setSelectedCommandIndex(defaultSlashCommandIndex(slashQuery, filteredSlashCommands.length)); + }, [slashQuery, filteredSlashCommands.length]); useEffect(() => { composerDraftsRef.current = composerDrafts; @@ -2122,7 +2121,6 @@ export function HomePage() { function updateComposerInput(value: string) { setCurrentComposerDraft(buildComposerCommandDraft(selectedComposerCommand, value)); setSlashMenuDismissed(false); - setSelectedCommandIndex(0); if ( slashQueryFromInput(value) != null && clients?.memmyAgent && diff --git a/App/frontend/desktop/src/pages/tests/agent-command-palette.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/agent-command-palette.interaction.test.tsx index 1157fd9bf..0447d6bb6 100644 --- a/App/frontend/desktop/src/pages/tests/agent-command-palette.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/tests/agent-command-palette.interaction.test.tsx @@ -3,7 +3,7 @@ import { act } from "react"; import { createRoot } from "react-dom/client"; import { expect, it, vi } from "vitest"; -import { AgentCommandPalette, filterSlashCommands } from "../agent-command-palette.js"; +import { AgentCommandPalette, defaultSlashCommandIndex, filterSlashCommands } from "../agent-command-palette.js"; (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; @@ -30,6 +30,19 @@ it("scrolls to and selects commands beyond the initial visible rows", () => { expect(scrollIntoView.mock.contexts[0]).toBe(selected); act(() => selected.click()); expect(onSelect).toHaveBeenCalledWith(commands[8]); + + const goalMatches = filterSlashCommands(commands, "goal", []); + scrollIntoView.mockClear(); + act(() => root.render()); + const goalOptions = container.querySelectorAll('[role="option"]'); + const closest = goalOptions[goalOptions.length - 1]!; + expect(goalOptions).toHaveLength(9); + expect(closest.getAttribute("aria-selected")).toBe("true"); + expect(closest.querySelector(".font-mono")?.textContent).toBe("/goal"); + expect(scrollIntoView.mock.contexts[0]).toBe(closest); + expect(container.textContent).toContain("/goal pause"); + act(() => closest.click()); + expect(onSelect).toHaveBeenLastCalledWith(expect.objectContaining({ command: "/goal" })); } finally { act(() => root.unmount()); container.remove(); diff --git a/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx b/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx index d46204039..a8a68c207 100644 --- a/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx +++ b/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx @@ -4,6 +4,7 @@ import { enUSMessages, zhCNMessages } from "../../i18n/messages.js"; import { AgentCommandPalette, buildVisibleSlashCommands, + defaultSlashCommandIndex, filterSlashCommands, localizeSlashCommands, readRecentSlashCommands, @@ -60,9 +61,9 @@ describe("AgentCommandPalette", () => { "/goal budget ": ["none"] }; for (const [prefix, suffixes] of Object.entries(expected)) { - expect(match(prefix).map((item) => item.command)).toEqual(suffixes.map((suffix) => prefix + suffix)); + expect(match(prefix).map((item) => item.command)).toEqual(suffixes.map((suffix) => prefix + suffix).sort((a, b) => b.length - a.length)); for (const suffix of suffixes) { - expect(match(prefix + suffix).map((item) => item.command)).toEqual([prefix + suffix]); + expect(match(prefix + suffix).at(-1)?.command).toBe(prefix + suffix); expect(match(prefix + suffix[0]).map((item) => item.command)).toContain(prefix + suffix); } } @@ -76,12 +77,41 @@ describe("AgentCommandPalette", () => { expect(filterSlashCommands([], "goal p", [])).toEqual([]); }); + it("expands exact parent commands without a space and places exact matches last", () => { + const available = ["/goal", "/pairing", "/model"].map((command) => ({ + command, title: command, description: "", icon: "activity", argHint: "" + })); + const match = (query: string) => filterSlashCommands(available, query, []).map((item) => item.command); + expect(match("goal")).toEqual([ + "/goal status", "/goal create", "/goal resume", "/goal budget", + "/goal pause", "/goal clear", "/goal help", "/goal edit", "/goal" + ]); + expect(match("goal ")).not.toContain("/goal"); + expect(match("goal budget")).toEqual(["/goal budget none", "/goal budget"]); + expect(match("goal budget ")).toEqual(["/goal budget none"]); + expect(match("pairing")).toEqual(["/pairing approve", "/pairing revoke", "/pairing list", "/pairing deny", "/pairing"]); + expect(match("model")).toEqual(["/model list", "/model"]); + }); + + it("places closer prefix matches nearest the input and selects the bottom result", () => { + const available = ["/history", "/history-dag"].map((command) => ({ + command, title: command, description: "", icon: "activity", argHint: "" + })); + for (const query of ["h", "history"]) { + const matches = filterSlashCommands(available, query, []); + expect(matches.map((item) => item.command)).toEqual(["/history-dag", "/history"]); + expect(matches[defaultSlashCommandIndex(query, matches.length)]?.command).toBe("/history"); + } + expect(defaultSlashCommandIndex("", 5)).toBe(0); + expect(defaultSlashCommandIndex("missing", 0)).toBe(0); + }); + it("matches available model preset names without changing their case", () => { const presets = [ { name: "FastModel", available: true }, { name: "unavailable", available: false } ]; - expect(filterSlashCommands(commands, "model ", [], presets).map((item) => item.command)).toEqual(["/model list", "/model FastModel"]); + expect(filterSlashCommands(commands, "model ", [], presets).map((item) => item.command)).toEqual(["/model FastModel", "/model list"]); expect(filterSlashCommands(commands, "model fa", [], presets)[0]).toMatchObject({ command: "/model FastModel", argHint: "" }); expect(filterSlashCommands(commands, "model un", [], presets)).toEqual([]); expect(filterSlashCommands(commands, "model FastModel ", [], presets)).toEqual([]); @@ -122,7 +152,7 @@ describe("AgentCommandPalette", () => { { command: "/new", title: "New chat", description: "Stop the current task and start a fresh conversation.", icon: "square-pen", argHint: "" } ]; for (const isSending of [false, true]) { - for (const [input, expected] of [["/s", ["/stop", "/status"]], ["/stop", ["/stop"]]] as const) { + for (const [input, expected] of [["/s", ["/status", "/stop"]], ["/stop", ["/stop"]]] as const) { const query = slashQueryFromInput(input)!; const visible = buildVisibleSlashCommands(available, isSending, stopCommand, query); expect(filterSlashCommands(visible, query, []).map((command) => command.command)).toEqual(expected); diff --git a/docs/cn/reference/slash-commands.mdx b/docs/cn/reference/slash-commands.mdx index 742b03681..f77adfc49 100644 --- a/docs/cn/reference/slash-commands.mdx +++ b/docs/cn/reference/slash-commands.mdx @@ -9,7 +9,9 @@ icon: Terminal - 输入 `/` 后,面板默认显示最多 5 行,其余命令可滚动查看。较小窗口下可见行数会进一步减少。 - 继续输入名称可按命令名前缀筛选,不匹配说明文字。例如 `/s` 匹配 `/stop` 和 `/status`,`/stop` 只匹配 `/stop`。筛选不会截断结果。 -- 主命令后输入空格可查看子命令,继续输入则按子命令前缀筛选。例如 `/goal p` 匹配 `/goal pause`,`/pairing a` 匹配 `/pairing approve`,`/goal budget n` 匹配 `/goal budget none`。`/model ` 还会显示当前可用的模型预设,支持按预设名前缀筛选。 +- 输入完整主命令即展开子命令,无需等待空格,例如 `/goal` 同时展示它的子命令及主命令自身;输入 `/goal ` 后只保留子命令。完整的 `/goal budget` 同样会展开 `none` 选项。 +- 查询时按剩余补全文字的长度排列:完全匹配或更接近输入的候选放在底部,靠近输入框,并默认选中底部候选;只输入 `/` 时仍按最近使用记录排列。 +- 继续输入可按子命令前缀筛选。例如 `/goal p` 匹配 `/goal pause`,`/pairing a` 匹配 `/pairing approve`,`/goal budget n` 匹配 `/goal budget none`。`/model` 还会展示当前可用的模型预设,支持按预设名前缀筛选。 - 选中子命令后会填入完整命令;需要参数时保留空格供继续填写。目标内容、数字、配对码、用户 ID 和版本 SHA 是自由参数,不会被替换成子命令。填写完成后发送执行。 - 使用上下方向键选择,Enter 或 Tab 选中,Escape 关闭提示。带参数的命令可继续填写参数后发送。 - 空闲时,默认列表不显示 `/stop`;输入 `/s` 或 `/stop` 时仍会显示匹配提示。任务运行时,默认列表也会显示 `/stop`。 From 1cebfe06c26721e85e7d189685bb74c0b96dbb3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BA=AA=E5=B9=BF=E5=B8=9D?= Date: Thu, 15 Oct 2026 19:58:35 +0800 Subject: [PATCH 46/56] fix(desktop): match commands and subcommands as flat candidates --- .../src/pages/agent-command-palette.tsx | 36 +++++++------------ ...agent-command-palette.interaction.test.tsx | 9 ++--- .../tests/agent-command-palette.test.tsx | 18 ++++++---- docs/cn/reference/slash-commands.mdx | 2 +- 4 files changed, 31 insertions(+), 34 deletions(-) diff --git a/App/frontend/desktop/src/pages/agent-command-palette.tsx b/App/frontend/desktop/src/pages/agent-command-palette.tsx index 82d5a6ee3..56001db96 100644 --- a/App/frontend/desktop/src/pages/agent-command-palette.tsx +++ b/App/frontend/desktop/src/pages/agent-command-palette.tsx @@ -227,33 +227,23 @@ export function filterSlashCommands( modelPresets: ReadonlyArray<{ name: string; available: boolean }> = [] ): SlashCommandPaletteItem[] { const normalizedQuery = query.toLowerCase().replace(/[\t ]+/g, " "); - if (!normalizedQuery) return sortByRecent(commands, recentCommands); - - const [root] = normalizedQuery.split(" "); - const parent = commands.find((command) => command.command.toLowerCase() === `/${root}`); - const suffixes = [...(SUBCOMMANDS[parent?.command.toLowerCase() ?? ""] ?? [])]; - if (parent?.command === "/model") { - for (const preset of modelPresets) { - if (preset.available && !/\s/.test(preset.name) && preset.name.toLowerCase() !== "list") { - suffixes.push([preset.name, ""]); + const descendants = commands.flatMap((parent) => { + const suffixes = [...(SUBCOMMANDS[parent.command.toLowerCase()] ?? [])]; + if (parent.command.toLowerCase() === "/model") { + for (const preset of modelPresets) { + if (preset.available && !/\s/.test(preset.name) && preset.name.toLowerCase() !== "list") { + suffixes.push([preset.name, ""]); + } } } - } - const descendants = parent ? suffixes.map(([suffix, argHint]) => ({ - ...parent, command: `${parent.command} ${suffix}`, argHint - })) : []; - const depth = normalizedQuery.split(" ").length; - const candidates = depth === 1 ? commands : descendants.filter((command) => command.command.split(" ").length === depth); + return suffixes.map(([suffix, argHint]) => ({ + ...parent, command: `${parent.command} ${suffix}`, argHint + })); + }); + const candidates = [...commands, ...descendants]; + if (!normalizedQuery) return sortByRecent(candidates, recentCommands); const matched = candidates.filter((command) => commandMatchesQuery(command, normalizedQuery)); - // Expand a complete command immediately, without requiring a trailing space. - if (matched.some((command) => command.command.toLowerCase() === `/${normalizedQuery}`)) { - matched.push(...descendants.filter((command) => - command.command.split(" ").length === depth + 1 - && command.command.toLowerCase().startsWith(`/${normalizedQuery} `) - )); - } - // Suggestions open above the input: the shortest remaining completion is closest. return matched.sort((left, right) => right.command.length - left.command.length); } diff --git a/App/frontend/desktop/src/pages/tests/agent-command-palette.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/agent-command-palette.interaction.test.tsx index 0447d6bb6..8f9df00de 100644 --- a/App/frontend/desktop/src/pages/tests/agent-command-palette.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/tests/agent-command-palette.interaction.test.tsx @@ -21,7 +21,7 @@ it("scrolls to and selects commands beyond the initial visible rows", () => { const onSelect = vi.fn(); try { act(() => root.render()); - expect(container.querySelectorAll('[role="option"]')).toHaveLength(names.length); + expect(container.querySelectorAll('[role="option"]')).toHaveLength(commands.length); scrollIntoView.mockClear(); act(() => root.render()); const selected = container.querySelector('[aria-selected="true"]')!; @@ -31,16 +31,17 @@ it("scrolls to and selects commands beyond the initial visible rows", () => { act(() => selected.click()); expect(onSelect).toHaveBeenCalledWith(commands[8]); - const goalMatches = filterSlashCommands(commands, "goal", []); + const goalMatches = filterSlashCommands(commands.filter((command) => !command.command.includes(" ")), "g", []); scrollIntoView.mockClear(); - act(() => root.render()); + act(() => root.render()); const goalOptions = container.querySelectorAll('[role="option"]'); const closest = goalOptions[goalOptions.length - 1]!; - expect(goalOptions).toHaveLength(9); + expect(goalOptions).toHaveLength(10); expect(closest.getAttribute("aria-selected")).toBe("true"); expect(closest.querySelector(".font-mono")?.textContent).toBe("/goal"); expect(scrollIntoView.mock.contexts[0]).toBe(closest); expect(container.textContent).toContain("/goal pause"); + expect(container.textContent).toContain("/goal budget none"); act(() => closest.click()); expect(onSelect).toHaveBeenLastCalledWith(expect.objectContaining({ command: "/goal" })); } finally { diff --git a/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx b/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx index a8a68c207..3e1639e12 100644 --- a/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx +++ b/App/frontend/desktop/src/pages/tests/agent-command-palette.test.tsx @@ -35,7 +35,7 @@ describe("AgentCommandPalette", () => { it("filters only by command-name prefix, case-insensitively", () => { expect(filterSlashCommands(commands, "his", []).map((command) => command.command)).toEqual(["/history"]); - expect(filterSlashCommands(commands, "MoD", []).map((command) => command.command)).toEqual(["/model"]); + expect(filterSlashCommands(commands, "MoD", []).map((command) => command.command)).toEqual(["/model list", "/model"]); expect(filterSlashCommands(commands, "preset", [])).toEqual([]); expect(filterSlashCommands(commands, "istory", [])).toEqual([]); }); @@ -43,19 +43,20 @@ describe("AgentCommandPalette", () => { it("keeps every available command reachable beyond the first eight results", () => { const available = ["/new", "/restart", "/status", "/model", "/history", "/history-dag", "/goal", "/help", "/pairing", "/last-compaction"] .map((command) => ({ command, title: command, description: "Available command", icon: "activity", argHint: "" })); - expect(filterSlashCommands(available, "", [])).toEqual(available); + expect(filterSlashCommands(available, "", []).slice(0, available.length)).toEqual(available); + expect(filterSlashCommands(available, "", []).map((item) => item.command)).toContain("/goal budget none"); for (const command of available) { expect(filterSlashCommands(available, slashQueryFromInput(command.command)!, [])).toContainEqual(command); } }); - it("matches every literal subcommand at its current depth", () => { + it("matches every literal subcommand in the same flat candidate list", () => { const available = ["/goal", "/pairing", "/model", "/history", "/dream-log"].map((command) => ({ command, title: command, description: "", icon: "activity", argHint: "" })); const match = (input: string) => filterSlashCommands(available, slashQueryFromInput(input)!, []); const expected = { - "/goal ": ["status", "help", "create", "pause", "resume", "edit", "budget", "clear"], + "/goal ": ["status", "help", "create", "pause", "resume", "edit", "budget", "budget none", "clear"], "/pairing ": ["list", "approve", "deny", "revoke"], "/model ": ["list"], "/goal budget ": ["none"] @@ -77,15 +78,19 @@ describe("AgentCommandPalette", () => { expect(filterSlashCommands([], "goal p", [])).toEqual([]); }); - it("expands exact parent commands without a space and places exact matches last", () => { + it("matches parent and descendant commands together and places exact matches last", () => { const available = ["/goal", "/pairing", "/model"].map((command) => ({ command, title: command, description: "", icon: "activity", argHint: "" })); const match = (query: string) => filterSlashCommands(available, query, []).map((item) => item.command); expect(match("goal")).toEqual([ - "/goal status", "/goal create", "/goal resume", "/goal budget", + "/goal budget none", "/goal status", "/goal create", "/goal resume", "/goal budget", "/goal pause", "/goal clear", "/goal help", "/goal edit", "/goal" ]); + expect(match("g")).toEqual(match("goal")); + expect(match("goal b")).toEqual(["/goal budget none", "/goal budget"]); + expect(match("p")).toEqual(match("pairing")); + expect(match("m")).toEqual(match("model")); expect(match("goal ")).not.toContain("/goal"); expect(match("goal budget")).toEqual(["/goal budget none", "/goal budget"]); expect(match("goal budget ")).toEqual(["/goal budget none"]); @@ -112,6 +117,7 @@ describe("AgentCommandPalette", () => { { name: "unavailable", available: false } ]; expect(filterSlashCommands(commands, "model ", [], presets).map((item) => item.command)).toEqual(["/model FastModel", "/model list"]); + expect(filterSlashCommands(commands, "m", [], presets).map((item) => item.command)).toEqual(["/model FastModel", "/model list", "/model"]); expect(filterSlashCommands(commands, "model fa", [], presets)[0]).toMatchObject({ command: "/model FastModel", argHint: "" }); expect(filterSlashCommands(commands, "model un", [], presets)).toEqual([]); expect(filterSlashCommands(commands, "model FastModel ", [], presets)).toEqual([]); diff --git a/docs/cn/reference/slash-commands.mdx b/docs/cn/reference/slash-commands.mdx index f77adfc49..fba044273 100644 --- a/docs/cn/reference/slash-commands.mdx +++ b/docs/cn/reference/slash-commands.mdx @@ -9,7 +9,7 @@ icon: Terminal - 输入 `/` 后,面板默认显示最多 5 行,其余命令可滚动查看。较小窗口下可见行数会进一步减少。 - 继续输入名称可按命令名前缀筛选,不匹配说明文字。例如 `/s` 匹配 `/stop` 和 `/status`,`/stop` 只匹配 `/stop`。筛选不会截断结果。 -- 输入完整主命令即展开子命令,无需等待空格,例如 `/goal` 同时展示它的子命令及主命令自身;输入 `/goal ` 后只保留子命令。完整的 `/goal budget` 同样会展开 `none` 选项。 +- 主命令、完整子命令和可用模型预设处于同一候选列表,统一按完整输入做前缀匹配,不限制层级。例如 `/g` 同时匹配 `/goal`、`/goal pause`、`/goal budget none` 等所有相关项;`/goal ` 只匹配带空格的子命令;`/goal b` 同时匹配 `/goal budget` 和 `/goal budget none`。 - 查询时按剩余补全文字的长度排列:完全匹配或更接近输入的候选放在底部,靠近输入框,并默认选中底部候选;只输入 `/` 时仍按最近使用记录排列。 - 继续输入可按子命令前缀筛选。例如 `/goal p` 匹配 `/goal pause`,`/pairing a` 匹配 `/pairing approve`,`/goal budget n` 匹配 `/goal budget none`。`/model` 还会展示当前可用的模型预设,支持按预设名前缀筛选。 - 选中子命令后会填入完整命令;需要参数时保留空格供继续填写。目标内容、数字、配对码、用户 ID 和版本 SHA 是自由参数,不会被替换成子命令。填写完成后发送执行。 From 4d3228f490ef22ece8f51d4d9c8d96f987f5f18d Mon Sep 17 00:00:00 2001 From: jiachengzhen Date: Thu, 15 Oct 2026 22:53:01 +0800 Subject: [PATCH 47/56] test: distinguish unexpected memory endpoint fixtures --- App/shell/desktop/tests/runtime-memory-upgrade.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/App/shell/desktop/tests/runtime-memory-upgrade.test.ts b/App/shell/desktop/tests/runtime-memory-upgrade.test.ts index dc7d28e53..9ea1fa861 100644 --- a/App/shell/desktop/tests/runtime-memory-upgrade.test.ts +++ b/App/shell/desktop/tests/runtime-memory-upgrade.test.ts @@ -70,7 +70,7 @@ async function fixture(options: { "const lock = db + '.server.lock'; const version = process.env.FIXTURE_VERSION || '2.1.1';", "fs.writeFileSync(lock, JSON.stringify({ pid: process.pid, sqlitePath: db, host: '127.0.0.1', port }));", "const server = http.createServer((request, response) => {", - " if (request.url === '/api/v1/health') { response.setHeader('content-type', 'application/json'); response.end(JSON.stringify({ ok: process.env.FIXTURE_IDENTITY !== 'false' && !fs.existsSync(path.join(serviceHome, 'unexpected-service')), protocolVersion: 1, serviceVersion: version })); return; }", + " if (request.url === '/api/v1/health') { response.setHeader('content-type', 'application/json'); const ownsMemory = process.env.FIXTURE_IDENTITY !== 'false' && !fs.existsSync(path.join(serviceHome, 'unexpected-service')); const health = ownsMemory ? { ok: true, protocolVersion: 1, serviceVersion: version } : { serviceVersion: version }; response.end(JSON.stringify(health)); return; }", " if (request.url === '/api/v1/admin/shutdown') { response.end('{}'); setImmediate(shutdown); return; }", " response.writeHead(404); response.end();", "});", From 896ca85d77ee1726fd682ee716781104bd7c158c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Fri, 16 Oct 2026 10:20:39 +0800 Subject: [PATCH 48/56] test(windows): model non-Memory health responses --- App/shell/desktop/tests/runtime-memory-upgrade.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/App/shell/desktop/tests/runtime-memory-upgrade.test.ts b/App/shell/desktop/tests/runtime-memory-upgrade.test.ts index dc7d28e53..c112cc514 100644 --- a/App/shell/desktop/tests/runtime-memory-upgrade.test.ts +++ b/App/shell/desktop/tests/runtime-memory-upgrade.test.ts @@ -70,7 +70,12 @@ async function fixture(options: { "const lock = db + '.server.lock'; const version = process.env.FIXTURE_VERSION || '2.1.1';", "fs.writeFileSync(lock, JSON.stringify({ pid: process.pid, sqlitePath: db, host: '127.0.0.1', port }));", "const server = http.createServer((request, response) => {", - " if (request.url === '/api/v1/health') { response.setHeader('content-type', 'application/json'); response.end(JSON.stringify({ ok: process.env.FIXTURE_IDENTITY !== 'false' && !fs.existsSync(path.join(serviceHome, 'unexpected-service')), protocolVersion: 1, serviceVersion: version })); return; }", + " if (request.url === '/api/v1/health') {", + " const hasMemoryIdentity = process.env.FIXTURE_IDENTITY !== 'false' && !fs.existsSync(path.join(serviceHome, 'unexpected-service'));", + // A non-Memory endpoint lacks the health contract; `ok: false` is valid for a degraded Memory service. + " const health = hasMemoryIdentity ? { ok: true, protocolVersion: 1, serviceVersion: version } : { serviceVersion: version };", + " response.setHeader('content-type', 'application/json'); response.end(JSON.stringify(health)); return;", + " }", " if (request.url === '/api/v1/admin/shutdown') { response.end('{}'); setImmediate(shutdown); return; }", " response.writeHead(404); response.end();", "});", From e0f27e8b1043cbfd92093ef2406b09e70bfc5953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Fri, 16 Oct 2026 10:20:44 +0800 Subject: [PATCH 49/56] test: distinguish degraded Memory health from unrelated services --- .../desktop/tests/runtime-memory-upgrade.test.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/App/shell/desktop/tests/runtime-memory-upgrade.test.ts b/App/shell/desktop/tests/runtime-memory-upgrade.test.ts index dc7d28e53..e4474a78f 100644 --- a/App/shell/desktop/tests/runtime-memory-upgrade.test.ts +++ b/App/shell/desktop/tests/runtime-memory-upgrade.test.ts @@ -28,6 +28,7 @@ async function fixture(options: { delay?: number; independent?: boolean; identity?: boolean; + healthy?: boolean; shutdownDelay?: number; wrongConfig?: boolean; stopFails?: boolean; @@ -70,7 +71,9 @@ async function fixture(options: { "const lock = db + '.server.lock'; const version = process.env.FIXTURE_VERSION || '2.1.1';", "fs.writeFileSync(lock, JSON.stringify({ pid: process.pid, sqlitePath: db, host: '127.0.0.1', port }));", "const server = http.createServer((request, response) => {", - " if (request.url === '/api/v1/health') { response.setHeader('content-type', 'application/json'); response.end(JSON.stringify({ ok: process.env.FIXTURE_IDENTITY !== 'false' && !fs.existsSync(path.join(serviceHome, 'unexpected-service')), protocolVersion: 1, serviceVersion: version })); return; }", + // Model health can be false while Memory still owns the endpoint. An + // unrelated service must omit the Memory identity fields instead. + " if (request.url === '/api/v1/health') { response.setHeader('content-type', 'application/json'); const isMemory = process.env.FIXTURE_IDENTITY !== 'false' && !fs.existsSync(path.join(serviceHome, 'unexpected-service')); response.end(JSON.stringify(isMemory ? { ok: process.env.FIXTURE_HEALTHY !== 'false', protocolVersion: 1, serviceVersion: version } : { status: 'ok' })); return; }", " if (request.url === '/api/v1/admin/shutdown') { response.end('{}'); setImmediate(shutdown); return; }", " response.writeHead(404); response.end();", "});", @@ -117,6 +120,7 @@ async function fixture(options: { const old = spawnNodeService("memory", entry, ["--config", config.configPath, "--db", config.memoryDatabasePath, "--port", String(port)], { FIXTURE_VERSION: options.version ?? "2.1.0", FIXTURE_DELAY: String(options.delay ?? 0), FIXTURE_IDENTITY: String(options.identity ?? true), + FIXTURE_HEALTHY: String(options.healthy ?? true), FIXTURE_SHUTDOWN_DELAY: String(options.shutdownDelay ?? 0), FIXTURE_WRONG_CONFIG: String(options.wrongConfig ?? false), }, { executablePath: process.execPath, logFilePath: join(root, "old.log"), logLevel: "info" }); processes.push(old); @@ -317,6 +321,15 @@ describe("bundled Memory upgrades", () => { expect(running.old.process.exitCode).toBeNull(); }); + it("reuses a compatible Memory endpoint when model health is degraded", async () => { + const running = await fixture({ version: "2.1.1", healthy: false }); + await running.ensure(); + expect(running.children).toHaveLength(0); + expect(running.old.process.exitCode).toBeNull(); + const health = await (await fetch(running.config.memoryBaseUrl + "/api/v1/health")).json(); + expect(health).toMatchObject({ ok: false, protocolVersion: 1, serviceVersion: "2.1.1" }); + }); + it("rejects an endpoint without Memory identity without stopping it", async () => { const running = await fixture({ identity: false }); await expect(running.ensure()).rejects.toThrow("unexpected service"); From 083e5e1079efde624f59fa84f546356bae6ca92b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Fri, 16 Oct 2026 11:00:45 +0800 Subject: [PATCH 50/56] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=20BYOK=20?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E3=80=81=E6=B5=8B=E8=AF=95=E4=B8=8E=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E7=9A=84=E5=87=AD=E6=8D=AE=E6=9D=A5=E6=BA=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一已保存凭据的复用条件,绑定角色表单与 endpoint 来源,覆盖返回配置页、复用切换和凭据变更。 纳入 516ba152、43ead001 的 endpoint 去重依赖思路,并使用后端返回的 endpointAliases 替代掩码匹配;补齐相同模型去重和完整保存回归。 --- App/backend/local-api-contracts/src/index.ts | 2 + .../memmy-config/model-config-catalog.ts | 60 ++- .../tests/model-config-catalog.test.ts | 108 +++++- App/frontend/desktop/src/api/config-client.ts | 10 +- .../src/api/tests/config-client.test.ts | 67 ++++ App/frontend/desktop/src/i18n/messages.ts | 2 + .../desktop/src/pages/api-key-form-fields.tsx | 7 +- .../src/pages/api-key-optional-page.tsx | 94 ++--- .../desktop/src/pages/api-key-page.tsx | 95 ++--- .../src/pages/model-config-validation.ts | 6 +- .../desktop/src/pages/model-config.ts | 56 ++- .../desktop/src/pages/model-credentials.ts | 61 +++ App/frontend/desktop/src/pages/model-page.tsx | 93 ++--- .../api-key-optional-page-source.test.ts | 5 - .../pages/tests/api-key-page-source.test.ts | 14 +- ...redential-consistency.interaction.test.tsx | 351 ++++++++++++++++++ ...k-setup-save-feedback.interaction.test.tsx | 50 --- .../src/pages/tests/model-config.test.ts | 2 +- .../src/pages/tests/model-page-source.test.ts | 12 +- 19 files changed, 789 insertions(+), 306 deletions(-) create mode 100644 App/frontend/desktop/src/pages/model-credentials.ts create mode 100644 App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx diff --git a/App/backend/local-api-contracts/src/index.ts b/App/backend/local-api-contracts/src/index.ts index 69bf78250..5fac3e6e5 100644 --- a/App/backend/local-api-contracts/src/index.ts +++ b/App/backend/local-api-contracts/src/index.ts @@ -1127,6 +1127,8 @@ export type MemoryRuntimeModelSettings = z.infer { const target = resolve(configPath); + const endpointAliases: NonNullable = {}; try { const result = await mutateRuntimeConfig(target, (current) => { if (input.configRevision !== revisionFor(current)) throw new ModelConfigChangedError(); - const next = mergeModelConfig(current, input); + const next = mergeModelConfig(current, input, endpointAliases); for (const key of Object.keys(current)) delete current[key]; Object.assign(current, next); return next; }); - return buildModelConfigView(result.value, revisionFor(result.value), new Date().toISOString()); + return { + ...buildModelConfigView(result.value, revisionFor(result.value), new Date().toISOString()), + ...(Object.keys(endpointAliases).length ? { endpointAliases } : {}) + }; } catch (error) { if (isErrorCode(error, "migration_lock_timeout")) { throw Object.assign(new Error("Model configuration is busy; try again"), { @@ -97,7 +101,9 @@ export function generateDesktopPresetName(provider: string, model: string): stri return `desktop-${readableIdPart(provider, 24) || "provider"}-${readableIdPart(model, 48) || "model"}-${hash}`; } -function mergeModelConfig(config: ConfigRecord, input: ModelConfigInput): ConfigRecord { +function mergeModelConfig( + config: ConfigRecord, input: ModelConfigInput, endpointAliases: NonNullable +): ConfigRecord { const existingProviders = record(config.providers); const existingPresets = record(config.modelPresets); const existingAssignments = normalizeStoredAssignments(config.modelAssignments); @@ -134,10 +140,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, previousProvider, endpointAliases); 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 +452,19 @@ function mergeEndpoint(previous: ConfigRecord, input: CatalogEndpointInput): Con return next; } -function validateEndpointDefinitions(input: TextModelProviderInput, provider: ConfigRecord): void { - const signatures = new Set(); - for (const endpoint of input.endpoints) { - const merged = record(record(provider.endpoints)[endpoint.endpointId]); +function canonicalizeEndpointDefinitions( + input: TextModelProviderInput, provider: ConfigRecord, previous: ConfigRecord, + aliases: NonNullable +): TextModelItemInput[] { + const canonicalIds = new Map(); + const redirects = new Map(); + const endpoints = record(provider.endpoints); + const previousEndpoints = record(previous.endpoints); + // Keep persisted connection identities even if a new duplicate is submitted first. + const ordered = [...input.endpoints].sort((left, right) => + Number(right.endpointId in previousEndpoints) - Number(left.endpointId in previousEndpoints)); + for (const endpoint of ordered) { + const merged = record(endpoints[endpoint.endpointId]); const signature = stableJson({ provider: input.provider, protocol: merged.protocol, @@ -458,11 +473,32 @@ 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); } + if (redirects.size) aliases[input.provider] = Object.fromEntries(redirects); + const models = input.models.map((model) => ({ + ...model, endpointId: redirects.get(model.endpointId) ?? model.endpointId + })); + // Only coalesce a new model made redundant by credential-based endpoint merging. + // Existing preset IDs and ordinary duplicate-model validation remain intact. + const removed = new Set(); + for (const [index, original] of input.models.entries()) { + if (original.presetId || original.ownerAccountId || original.source !== "byok" || !redirects.has(original.endpointId)) continue; + const duplicate = models.findIndex((model, candidate) => candidate !== index + && !removed.has(candidate) + && model.endpointId === models[index]!.endpointId + && model.model === original.model && model.source === original.source); + if (duplicate < 0) continue; + models[duplicate]!.capabilities = [...new Set([...models[duplicate]!.capabilities, ...models[index]!.capabilities])]; + removed.add(index); + } + return models.filter((_, index) => !removed.has(index)); } 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..25c85eddc 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,115 @@ 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.each([false, true])("coalesces a reentered key and identical model while retaining the saved preset (new endpoint first: %s)", async (newFirst) => { + const file = fixture(); + const first = await writeModelConfigCatalog(file, openAiInput((await readModelConfigCatalog(file)).configRevision)); + const originalId = first.providers[0]!.models[0]!.presetId; + const input = openAiInput(first.configRevision); + input.providers[0]!.models[0]!.presetId = originalId; + input.providers[0]!.models[0]!.capabilities = ["agent"]; + input.providers[0]!.endpoints.push({ ...input.providers[0]!.endpoints[0]!, endpointId: "chat-copy" }); + input.providers[0]!.models.push({ + ...input.providers[0]!.models[0]!, presetId: undefined, endpointId: "chat-copy", capabilities: ["memory_evolution"] + }); + if (newFirst) { + input.providers[0]!.endpoints.reverse(); + input.providers[0]!.models.reverse(); + } + const saved = await writeModelConfigCatalog(file, input); + expect(saved.endpointAliases).toEqual({ openai: { "chat-copy": "chat" } }); + expect(saved.providers[0]!.models).toHaveLength(1); + expect(saved.providers[0]!.models[0]).toMatchObject({ + presetId: originalId, endpointId: "chat", capabilities: ["agent", "memory_evolution"] + }); + // Aliases describe this write only; neither transient metadata nor plaintext is added to GET views. + expect((await readModelConfigCatalog(file)).endpointAliases).toBeUndefined(); + }); + 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..d89c85597 100644 --- a/App/frontend/desktop/src/api/config-client.ts +++ b/App/frontend/desktop/src/api/config-client.ts @@ -646,12 +646,14 @@ function withoutPendingPresetAssignments(input: ModelConfigInput, pendingIds: Re function resolvePendingPresetIds(pending: PendingPreset[], view: ModelConfigView): Map { const mapping = new Map(); for (const item of pending) { + // Never infer credential equality from a mask. Only the backend can resolve aliases. + item.endpointId = view.endpointAliases?.[item.provider]?.[item.endpointId] ?? item.endpointId; const matches = view.providers.flatMap((provider) => provider.models).filter((model) => ( model.provider === item.provider && model.endpointId === item.endpointId && model.model === item.model && model.source === item.source - && sameStringSet(model.capabilities, item.capabilities) + && item.capabilities.every((capability) => model.capabilities.includes(capability)) )); if (matches.length !== 1) { throw new Error(`Unable to resolve server preset for ${item.provider}/${item.endpointId}/${item.model}`); @@ -710,17 +712,13 @@ function assertResolvedPresetsStillExist( || preset.endpointId !== item.endpointId || preset.model !== item.model || preset.source !== item.source - || !sameStringSet(preset.capabilities, item.capabilities) + || !item.capabilities.every((capability) => preset.capabilities.includes(capability)) ) { throw new Error(`Created model preset changed before assignment: ${item.provider}/${item.endpointId}/${item.model}`); } } } -function sameStringSet(left: readonly string[], right: readonly string[]): boolean { - return left.length === right.length && left.every((value) => right.includes(value)); -} - function isModelConfigChanged(error: unknown): boolean { return Boolean(error && typeof error === "object" && "code" in error && error.code === "model_config_changed"); } 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..29f2ee110 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,73 @@ describe("config-client canonical model catalog", () => { expect(saved.catalog?.modelAssignments.byok.agent.default).toBe(serverPresetId); }); + 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.endpointAliases = { openai: { "chat-duplicate": "chat" } }; + 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"] + }); + 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 } }); diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index 9e79ffc53..2acbe6900 100644 --- a/App/frontend/desktop/src/i18n/messages.ts +++ b/App/frontend/desktop/src/i18n/messages.ts @@ -327,6 +327,7 @@ export const zhCNMessages = { "apiKey.testInvalidConfig": "连接失败,请检查 API Key、模型 ID 或 API 地址", "apiKey.testRequestFailed": "连接测试失败,请检查本地 API 或网络", "apiKey.testSaveFailed": "连接成功,但配置保存失败,请点击保存重试", + "apiKey.credentialsChanged": "连接地址或服务商已更改,请重新填写 API Key", "apiKey.testStale": "配置已变更,请重新测试", "apiKey.showKey": "显示 API Key", "apiKey.hideKey": "隐藏 API Key", @@ -2067,6 +2068,7 @@ export const enUSMessages: Record = { "apiKey.testInvalidConfig": "Connection failed. Check API key, model ID, or API URL.", "apiKey.testRequestFailed": "Connection test failed. Check local API or network.", "apiKey.testSaveFailed": "Connected, but saving the config failed. Click Save to retry.", + "apiKey.credentialsChanged": "Connection URL or provider changed. Enter the API key again.", "apiKey.testStale": "Configuration changed. Test again.", "apiKey.showKey": "Show API key", "apiKey.hideKey": "Hide API key", diff --git a/App/frontend/desktop/src/pages/api-key-form-fields.tsx b/App/frontend/desktop/src/pages/api-key-form-fields.tsx index 1abe4951a..c76409c26 100644 --- a/App/frontend/desktop/src/pages/api-key-form-fields.tsx +++ b/App/frontend/desktop/src/pages/api-key-form-fields.tsx @@ -55,6 +55,7 @@ interface TestButtonProps { interface ValidationMessageProps { validation: ModelConfigValidationState; stale: boolean; + credentialRequired?: boolean; } /** Handles config field. */ @@ -152,12 +153,12 @@ export function TestButton(props: TestButtonProps) { export function ValidationMessage(props: ValidationMessageProps) { const { t } = useTranslation(); - if (props.validation.status === "idle" && !props.stale) { + if (props.validation.status === "idle" && !props.stale && !props.credentialRequired) { return null; } - const isSuccess = props.validation.status === "success" && !props.stale; - const message = props.stale ? t("apiKey.testStale") : props.validation.message; + const isSuccess = props.validation.status === "success" && !props.stale && !props.credentialRequired; + const message = props.credentialRequired ? t("apiKey.credentialsChanged") : props.stale ? t("apiKey.testStale") : props.validation.message; return ( hydrateModelConfigForm(state.modelConfig, "local")); + const [initialWorkspace] = useState(() => createModelWorkspace(state.modelConfig)); const initialAsrEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "asr"); const initialImageEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "image_generation"); + const savedEndpointIdentitiesRef = useRef>>({ + ...(initialAsrEndpointId + ? { asr: { endpointId: initialAsrEndpointId, credentialSignature: endpointCredentialSignature("qwen", "dashscope-input-audio-chat", initialModelForm.asrEndpoint, initialModelForm.asrApiKey, initialModelForm.asrApiKeyMasked) } } + : {}), + ...(initialImageEndpointId + ? { imageGeneration: { endpointId: initialImageEndpointId, credentialSignature: endpointCredentialSignature(initialModelForm.imageGenProtocol, initialModelForm.imageGenProtocol === "qwen" ? "dashscope-multimodal-generation" : "openai-images", initialModelForm.imageGenEndpoint, initialModelForm.imageGenApiKey, initialModelForm.imageGenApiKeyMasked) } } + : {}) + }); const initialAsrModel = initialModelForm.asrModelId; const initialAsrEndpoint = initialModelForm.asrEndpoint || ASR_DEFAULT_ENDPOINT; const [asrModel, setAsrModel] = useState(initialAsrModel); const [asrEndpoint, setAsrEndpoint] = useState(initialAsrEndpoint); const [asrApiKey, setAsrApiKey] = useState(initialModelForm.asrApiKey); const [showAsrApiKey, setShowAsrApiKey] = useState(false); - const [asrValidation, setAsrValidation] = useState(initialModelForm.asrValidation); const [asrWarningAcknowledged, setAsrWarningAcknowledged] = useState(false); const [asrApiKeyMasked] = useState(initialModelForm.asrApiKeyMasked); - const asrFormValues = { + const asrFormValues = resolveModelCredentials({ ...createAsrModelFormValues(asrModel, asrEndpoint, asrApiKey, asrApiKeyMasked), - endpointId: !asrApiKey.trim() && asrApiKeyMasked ? initialAsrEndpointId : undefined - }; + protocol: "dashscope-input-audio-chat" + }, savedEndpointIdentitiesRef.current.asr); + const [asrValidation, setAsrValidation] = useState(() => bindSavedValidation(initialModelForm.asrValidation, asrFormValues)); const isAsrUsable = canSaveModelConfig(asrFormValues, asrValidation); const asrTestKey = createModelConfigValidationKey(asrFormValues); const isAsrTestStale = Boolean(asrValidation.testedKey && asrValidation.testedKey !== asrTestKey); @@ -85,17 +90,13 @@ export function ApiKeyOptionalPage() { const [imageGenEndpoint, setImageGenEndpoint] = useState(initialModelForm.imageGenEndpoint); const [imageGenApiKey, setImageGenApiKey] = useState(initialModelForm.imageGenApiKey); const [showImageGenApiKey, setShowImageGenApiKey] = useState(false); - const [imageGenValidation, setImageGenValidation] = useState(initialModelForm.imageGenValidation); const [imageGenWarningAcknowledged, setImageGenWarningAcknowledged] = useState(false); const [imageGenApiKeyMasked] = useState(initialModelForm.imageGenApiKeyMasked); - const imageGenFormValues = { + const imageGenFormValues = resolveModelCredentials({ ...createImageGenModelFormValues(imageGenProtocol, imageGenModel, imageGenEndpoint, imageGenApiKey, imageGenApiKeyMasked), - endpointId: !imageGenApiKey.trim() - && imageGenApiKeyMasked - && imageGenProtocol === initialModelForm.imageGenProtocol - ? initialImageEndpointId - : undefined - }; + protocol: imageGenProtocol === "qwen" ? "dashscope-multimodal-generation" : "openai-images" + }, savedEndpointIdentitiesRef.current.imageGeneration); + const [imageGenValidation, setImageGenValidation] = useState(() => bindSavedValidation(initialModelForm.imageGenValidation, imageGenFormValues)); const isImageGenUsable = canSaveModelConfig(imageGenFormValues, imageGenValidation); const imageGenTestKey = createModelConfigValidationKey(imageGenFormValues); const isImageGenTestStale = Boolean(imageGenValidation.testedKey && imageGenValidation.testedKey !== imageGenTestKey); @@ -121,14 +122,7 @@ export function ApiKeyOptionalPage() { imageGeneration: isImageGenUsable ? imageGenTestKey : null }); const savedCatalogSignatureRef = useRef(null); - const savedEndpointIdentitiesRef = useRef>>({ - ...(initialAsrEndpointId - ? { asr: { endpointId: initialAsrEndpointId, credentialSignature: asrCredentialSignature } } - : {}), - ...(initialImageEndpointId - ? { imageGeneration: { endpointId: initialImageEndpointId, credentialSignature: imageGenCredentialSignature } } - : {}) - }); + const [optionalModelMissingWarning, setOptionalModelMissingWarning] = useState(null); const [modePersistencePending, setModePersistencePending] = useState(false); const [nextValidationError, setNextValidationError] = useState<"noInput" | "testRequired" | null>(null); @@ -190,34 +184,26 @@ export function ApiKeyOptionalPage() { const latest = await clients.config.getModelConfig(); let workspace = createModelWorkspace(latest); if (isAsrUsable) { - const savedAsrIdentity = savedEndpointIdentitiesRef.current.asr; - const asrEndpointId = savedAsrIdentity?.credentialSignature === asrCredentialSignature - ? savedAsrIdentity.endpointId - : undefined; const asr = upsertByokPreset(workspace, { provider: "qwen", - ...(asrEndpointId ? { endpointId: asrEndpointId } : {}), + ...(asrFormValues.endpointId ? { endpointId: asrFormValues.endpointId } : {}), endpoint: asrEndpoint, protocol: "dashscope-input-audio-chat", ...(asrApiKey.trim() ? { apiKey: asrApiKey.trim() } : {}), - ...(asrApiKeyMasked ? { apiKeyMasked: asrApiKeyMasked } : {}), + ...(asrFormValues.apiKeyMasked ? { apiKeyMasked: asrFormValues.apiKeyMasked } : {}), model: asrModel || ASR_MODEL_ID, capabilities: ["asr"] }); workspace = assignCatalogPreset(asr.workspace, "byok", "asr", asr.presetId); } if (isImageGenUsable) { - const savedImageIdentity = savedEndpointIdentitiesRef.current.imageGeneration; - const imageEndpointId = savedImageIdentity?.credentialSignature === imageGenCredentialSignature - ? savedImageIdentity.endpointId - : undefined; const image = upsertByokPreset(workspace, { provider: imageGenProtocol, - ...(imageEndpointId ? { endpointId: imageEndpointId } : {}), + ...(imageGenFormValues.endpointId ? { endpointId: imageGenFormValues.endpointId } : {}), endpoint: imageGenEndpoint, protocol: imageGenEndpointProtocol, ...(imageGenApiKey.trim() ? { apiKey: imageGenApiKey.trim() } : {}), - ...(imageGenApiKeyMasked ? { apiKeyMasked: imageGenApiKeyMasked } : {}), + ...(imageGenFormValues.apiKeyMasked ? { apiKeyMasked: imageGenFormValues.apiKeyMasked } : {}), model: imageGenModel, capabilities: ["image_generation"] }); @@ -289,8 +275,8 @@ export function ApiKeyOptionalPage() { setSaveError(null); setNextValidationError(null); - const asrHasInput = Boolean(asrApiKey.trim()); - const imageGenHasInput = Boolean(imageGenApiKey.trim()); + const asrHasInput = Boolean(asrApiKey.trim() || asrApiKeyMasked); + const imageGenHasInput = Boolean(imageGenApiKey.trim() || imageGenApiKeyMasked); if (!asrHasInput && !imageGenHasInput) { setNextValidationError("noInput"); @@ -357,12 +343,12 @@ export function ApiKeyOptionalPage() { setAsrWarningAcknowledged(false); }} showPassword={showAsrApiKey} - maskedValue={asrApiKeyMasked} + maskedValue={asrFormValues.apiKeyMasked} onTogglePassword={() => setShowAsrApiKey((value) => !value)} />
- - + +
@@ -408,12 +394,12 @@ export function ApiKeyOptionalPage() { setImageGenWarningAcknowledged(false); }} showPassword={showImageGenApiKey} - maskedValue={imageGenApiKeyMasked} + maskedValue={imageGenFormValues.apiKeyMasked} onTogglePassword={() => setShowImageGenApiKey((value) => !value)} />
- - + +
@@ -463,22 +449,6 @@ export function ApiKeyOptionalPage() { ); } -function endpointCredentialSignature( - provider: string, - protocol: string, - endpoint: string, - apiKey: string, - apiKeyMasked: string -): string { - const normalizedApiKey = apiKey.trim(); - const normalizedMaskedApiKey = apiKeyMasked.trim(); - return JSON.stringify({ - provider: provider.trim().toLowerCase(), - protocol, - endpoint: endpoint.trim().replace(/\/+$/, ""), - credential: normalizedMaskedApiKey ? `masked:${normalizedMaskedApiKey}` : `raw:${normalizedApiKey}` - }); -} function optionalStepSaveErrorText( error: unknown, diff --git a/App/frontend/desktop/src/pages/api-key-page.tsx b/App/frontend/desktop/src/pages/api-key-page.tsx index 6e917ad2d..7d32b640d 100644 --- a/App/frontend/desktop/src/pages/api-key-page.tsx +++ b/App/frontend/desktop/src/pages/api-key-page.tsx @@ -1,3 +1,4 @@ +import { bindSavedValidation, endpointCredentialSignature, resolveModelCredentials, type SavedEndpointIdentity, chatProtocol } from "./model-credentials.js"; import { useRef, useState } from "react"; import { Brain, ChevronLeft, Search } from "lucide-react"; import { useAnalytics } from "../analytics/use-analytics.js"; @@ -55,10 +56,7 @@ interface ProviderOption { defaultModelId: string; } -interface SavedEndpointIdentity { - endpointId: string; - credentialSignature: string; -} + const providerOptions: ProviderOption[] = [ { value: "openai", labelKey: "apiKey.provider.openai", endpoint: "https://api.openai.com/v1", defaultModelId: "gpt-4o" }, @@ -82,28 +80,36 @@ export function ApiKeyPage() { const { clients } = useApiClients(); const { t } = useTranslation(); const { track } = useAnalytics(); - const initialModelForm = hydrateModelConfigForm(state.modelConfig, "local"); - const initialWorkspace = createModelWorkspace(state.modelConfig); + const [initialModelForm] = useState(() => hydrateModelConfigForm(state.modelConfig, "local")); + const [initialWorkspace] = useState(() => createModelWorkspace(state.modelConfig)); const initialAgentEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "agent"); const initialEmbeddingEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "embedding"); const initialProvider = fromProtocol(initialModelForm.protocol); + const savedEndpointIdentitiesRef = useRef>>({ + ...(initialAgentEndpointId + ? { agent: { endpointId: initialAgentEndpointId, credentialSignature: endpointCredentialSignature(initialProvider, chatProtocol(initialProvider), initialModelForm.endpoint, initialModelForm.apiKey, initialModelForm.apiKeyMasked) } } + : {}), + ...(initialEmbeddingEndpointId + ? { embedding: { endpointId: initialEmbeddingEndpointId, credentialSignature: endpointCredentialSignature("openai", "openai-embeddings", initialModelForm.embEndpoint, initialModelForm.embApiKey, initialModelForm.embApiKeyMasked) } } + : {}) + }); const [provider, setProvider] = useState(initialProvider); const selectedProvider = providerOptions.find((option) => option.value === provider) ?? defaultProvider; const [endpoint, setEndpoint] = useState(initialModelForm.endpoint || selectedProvider.endpoint); const [model, setModel] = useState(initialModelForm.modelId); const [apiKey, setApiKey] = useState(initialModelForm.apiKey); - const [apiKeyMasked, setApiKeyMasked] = useState(initialModelForm.apiKeyMasked); + const [apiKeyMasked] = useState(initialModelForm.apiKeyMasked); const [showApiKey, setShowApiKey] = useState(false); - const modelFormValues = { + const modelFormValues = resolveModelCredentials({ + protocol: chatProtocol(provider), provider, - endpointId: !apiKey.trim() && apiKeyMasked ? initialAgentEndpointId : undefined, endpoint, model, apiKey, apiKeyMasked, hasExistingApiKey: Boolean(apiKeyMasked) - }; - const [llmValidation, setLlmValidation] = useState(initialModelForm.llmValidation); + }, savedEndpointIdentitiesRef.current.agent); + const [llmValidation, setLlmValidation] = useState(() => bindSavedValidation(initialModelForm.llmValidation, modelFormValues)); const initialEmbeddingMode: EmbeddingMode = initialWorkspace.catalog.modelAssignments.byok.embedding ? "custom" : "local"; @@ -115,16 +121,16 @@ export function ApiKeyPage() { apiKeyMasked: initialModelForm.embApiKeyMasked }); const [showEmbeddingApiKey, setShowEmbeddingApiKey] = useState(false); - const embeddingFormValues = { + const embeddingFormValues = resolveModelCredentials({ + protocol: "openai-embeddings", provider: "openai", - endpointId: !embeddingConfig.apiKey.trim() && embeddingConfig.apiKeyMasked ? initialEmbeddingEndpointId : undefined, endpoint: embeddingConfig.endpoint, model: embeddingConfig.model, apiKey: embeddingConfig.apiKey, apiKeyMasked: embeddingConfig.apiKeyMasked, hasExistingApiKey: Boolean(embeddingConfig.apiKeyMasked) - }; - const [embeddingValidation, setEmbeddingValidation] = useState(initialModelForm.embValidation); + }, savedEndpointIdentitiesRef.current.embedding); + const [embeddingValidation, setEmbeddingValidation] = useState(() => bindSavedValidation(initialModelForm.embValidation, embeddingFormValues)); const canSave = canSaveModelConfig(modelFormValues, llmValidation) && canSaveOptionalModelConfig(embeddingMode === "custom", embeddingFormValues, embeddingValidation); const [savePending, setSavePending] = useState(false); @@ -149,14 +155,7 @@ export function ApiKeyPage() { ); const saveSignature = `${embeddingMode}\n${testedKey}\n${embeddingTestKey}`; const savedCatalogSignatureRef = useRef(null); - const savedEndpointIdentitiesRef = useRef>>({ - ...(initialAgentEndpointId - ? { agent: { endpointId: initialAgentEndpointId, credentialSignature: agentCredentialSignature } } - : {}), - ...(initialEmbeddingEndpointId - ? { embedding: { endpointId: initialEmbeddingEndpointId, credentialSignature: embeddingCredentialSignature } } - : {}) - }); + function changeProvider(nextProvider: string) { const next = providerOptions.find((option) => option.value === nextProvider) ?? defaultProvider; @@ -164,7 +163,6 @@ export function ApiKeyPage() { setEndpoint(next.endpoint); setModel(""); setApiKey(""); - setApiKeyMasked(""); } function testLlmConnection() { @@ -202,33 +200,26 @@ export function ApiKeyPage() { if (savedCatalogSignatureRef.current !== saveSignature) { const latest = await clients.config.getModelConfig(); let workspace = createModelWorkspace(latest); - const savedAgentIdentity = savedEndpointIdentitiesRef.current.agent; - const agentEndpointId = savedAgentIdentity?.credentialSignature === agentCredentialSignature - ? savedAgentIdentity.endpointId - : undefined; const agent = upsertByokPreset(workspace, { provider, - ...(agentEndpointId ? { endpointId: agentEndpointId } : {}), + ...(modelFormValues.endpointId ? { endpointId: modelFormValues.endpointId } : {}), endpoint, protocol: chatProtocol(provider), ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}), - ...(apiKeyMasked ? { apiKeyMasked } : {}), + ...(modelFormValues.apiKeyMasked ? { apiKeyMasked: modelFormValues.apiKeyMasked } : {}), model, capabilities: ["agent"] }); workspace = assignCatalogPreset(agent.workspace, "byok", "agent", agent.presetId); const savedEmbeddingIdentity = savedEndpointIdentitiesRef.current.embedding; if (embeddingMode === "custom") { - const embeddingEndpointId = savedEmbeddingIdentity?.credentialSignature === embeddingCredentialSignature - ? savedEmbeddingIdentity.endpointId - : undefined; const embedding = upsertByokPreset(workspace, { provider: "openai", - ...(embeddingEndpointId ? { endpointId: embeddingEndpointId } : {}), + ...(embeddingFormValues.endpointId ? { endpointId: embeddingFormValues.endpointId } : {}), endpoint: embeddingConfig.endpoint, protocol: "openai-embeddings", ...(embeddingConfig.apiKey.trim() ? { apiKey: embeddingConfig.apiKey.trim() } : {}), - ...(embeddingConfig.apiKeyMasked ? { apiKeyMasked: embeddingConfig.apiKeyMasked } : {}), + ...(embeddingFormValues.apiKeyMasked ? { apiKeyMasked: embeddingFormValues.apiKeyMasked } : {}), model: embeddingConfig.model, capabilities: ["embedding"] }); @@ -317,14 +308,14 @@ export function ApiKeyPage() { placeholder="sk-..." value={apiKey} onChange={setApiKey} - maskedValue={apiKeyMasked} + maskedValue={modelFormValues.apiKeyMasked} showPassword={showApiKey} onTogglePassword={() => setShowApiKey((value) => !value)} />
- - + +
@@ -366,13 +357,13 @@ export function ApiKeyPage() { placeholder="sk-..." value={embeddingConfig.apiKey} onChange={(value) => updateEmbeddingConfig("apiKey", value)} - maskedValue={embeddingConfig.apiKeyMasked} + maskedValue={embeddingFormValues.apiKeyMasked} showPassword={showEmbeddingApiKey} onTogglePassword={() => setShowEmbeddingApiKey((value) => !value)} />
- - + +
) : null} @@ -393,22 +384,6 @@ export function ApiKeyPage() { ); } -function endpointCredentialSignature( - provider: string, - protocol: string, - endpoint: string, - apiKey: string, - apiKeyMasked: string -): string { - const normalizedApiKey = apiKey.trim(); - const normalizedMaskedApiKey = apiKeyMasked.trim(); - return JSON.stringify({ - provider: provider.trim().toLowerCase(), - protocol, - endpoint: endpoint.trim().replace(/\/+$/, ""), - credential: normalizedMaskedApiKey ? `masked:${normalizedMaskedApiKey}` : `raw:${normalizedApiKey}` - }); -} function firstStepSaveErrorText( error: unknown, @@ -421,9 +396,3 @@ function firstStepSaveErrorText( ? error.message : t("settings.modelWorkspace.saveFailed"); } - -function chatProtocol(provider: string) { - if (provider === "anthropic") return "anthropic-messages" as const; - if (provider === "gemini") return "gemini-generate-content" as const; - return "openai-chat-completions" as const; -} diff --git a/App/frontend/desktop/src/pages/model-config-validation.ts b/App/frontend/desktop/src/pages/model-config-validation.ts index 5c6280e5e..d5a56d78d 100644 --- a/App/frontend/desktop/src/pages/model-config-validation.ts +++ b/App/frontend/desktop/src/pages/model-config-validation.ts @@ -10,6 +10,8 @@ export interface ModelConfigFormValues { apiKey: string; apiKeyMasked?: string; hasExistingApiKey?: boolean; + credentialSource?: import("./model-credentials.js").SavedEndpointIdentity | null; + requiresApiKey?: boolean; } /** Type definition for model config validation status. */ @@ -26,7 +28,9 @@ export interface ModelConfigValidationState { export function createModelConfigValidationKey(values: ModelConfigFormValues): string { return JSON.stringify({ provider: values.provider.trim(), - endpoint: values.endpoint.trim(), + endpoint: values.endpoint.trim().replace(/\/+$/, ""), + protocol: values.protocol, + endpointId: values.apiKey.trim() ? undefined : values.endpointId, model: values.model.trim(), apiKey: values.apiKey.trim() || existingSecretKey(values) }); diff --git a/App/frontend/desktop/src/pages/model-config.ts b/App/frontend/desktop/src/pages/model-config.ts index 130097964..e8bcf5a70 100644 --- a/App/frontend/desktop/src/pages/model-config.ts +++ b/App/frontend/desktop/src/pages/model-config.ts @@ -1,4 +1,5 @@ /** Model config module. */ +import { resolveModelCredentials, type SavedEndpointIdentity } from "./model-credentials.js"; import { ASR_DEFAULT_BASE_URL, ASR_PROVIDER, QWEN_ASR_MODEL_ID, type ModelConfigTestSecretTarget } from "@memmy/local-api-contracts"; import type { AsrProviderConfig, @@ -24,6 +25,7 @@ export type Protocol = "openai" | "anthropic" | "gemini" | "deepseek" | "zhipu" /** Contract for model config. */ export interface ModelConfig { + credentialSource?: SavedEndpointIdentity | null; reuse: boolean; protocol: Protocol; modelId: string; @@ -48,6 +50,7 @@ export interface TextProviderOption { /** Contract for primary model values. */ export interface PrimaryModelValues { + credentialSource?: SavedEndpointIdentity | null; protocol: Protocol; modelId: string; endpoint: string; @@ -100,6 +103,7 @@ export interface TestModelConnectionInput { /** Contract for test model connection messages. */ export interface TestModelConnectionMessages { missingFields: string; + credentialsChanged?: string; localApiUnavailable: string; testing: string; success: string; @@ -195,6 +199,7 @@ export const IMAGE_DEFAULT_MODEL_IDS: Record = { export function createTestModelConnectionMessages(t: (key: MessageKey) => string): TestModelConnectionMessages { return { missingFields: t("apiKey.testMissingFields"), + credentialsChanged: t("apiKey.credentialsChanged"), localApiUnavailable: t("apiKey.testLocalApiUnavailable"), testing: t("apiKey.testConnecting"), success: t("apiKey.testSuccess"), @@ -236,30 +241,24 @@ export function createModelProtocolPatch(protocol: Protocol): Pick hydrateModelConfigForm(state.modelConfig, "local")); + const [initialWorkspace] = useState(() => createModelWorkspace(state.modelConfig)); const primaryEndpointId = assignedCatalogEndpointId(initialWorkspace, "byok", "agent"); const [primaryModel] = useState(() => ({ protocol: initialModelForm.protocol, @@ -92,33 +94,34 @@ export function ModelPage() { endpoint: initialModelForm.endpoint, apiKey: initialModelForm.apiKey, apiKeyMasked: state.modelConfig.apiKeyMasked, - configured: state.modelConfig.configured + configured: state.modelConfig.configured, + credentialSource: primaryEndpointId ? { + endpointId: primaryEndpointId, + credentialSignature: endpointCredentialSignature(initialModelForm.protocol, chatProtocol(initialModelForm.protocol), initialModelForm.endpoint, initialModelForm.apiKey, initialModelForm.apiKeyMasked) + } : null })); - const [mem, setMem] = useState(() => initialModelForm.memoryModel); - const [skill, setSkill] = useState(() => initialModelForm.skillModel); + const [skill, setSkill] = useState(() => bindRoleCredential( + initialModelForm.skillModel, primaryModel, assignedCatalogEndpointId(initialWorkspace, "byok", "memory_evolution") + )); + const [mem, setMem] = useState(() => bindRoleCredential( + initialModelForm.memoryModel, modelFormValuesAsPrimary(createModelFormValues(skill, primaryModel)), + assignedCatalogEndpointId(initialWorkspace, "byok", "memory_summary") + )); const [savePending, setSavePending] = useState(false); const [saveFeedback, setSaveFeedback] = useState<{ text: string; tone: "error" | "success" } | null>(null); 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. */ function patchMem(patch: Partial) { - setMem((current) => ({ ...current, ...patch })); + setMem((current) => patchModelConfig(current, patch)); } /** Handles patch skill. */ function patchSkill(patch: Partial) { - setSkill((current) => ({ ...current, ...patch })); + setSkill((current) => patchModelConfig(current, patch)); } /** Handles test model config connection. */ @@ -131,12 +134,7 @@ export function ModelPage() { const values = createModelFormValues(config, inheritedModel); testModelConnection({ configClient: clients?.config, - values: { - ...values, - endpointId: values.apiKeyMasked - ? secretTarget === "skill" ? skillEndpointId : memoryEndpointId - : undefined - }, + values, setValidation: (validation) => patch({ validation }), secretTarget, messages: createTestModelConnectionMessages(t) @@ -157,15 +155,9 @@ export function ModelPage() { let workspace = createModelWorkspace(latest); const evolutionValues = createModelFormValues(skill, primaryModel); const memoryValues = createModelFormValues(mem, modelFormValuesAsPrimary(evolutionValues)); - const assignedEvolutionEndpointId = skill.reuse - ? assignedCatalogEndpointId(workspace, "byok", "agent") - : assignedCatalogEndpointId(workspace, "byok", "memory_evolution") - ?? (!evolutionValues.apiKey.trim() && evolutionValues.apiKeyMasked - ? assignedCatalogEndpointId(workspace, "byok", "agent") - : undefined); const evolution = upsertByokPreset(workspace, { provider: evolutionValues.provider, - ...(evolutionValues.apiKeyMasked && assignedEvolutionEndpointId ? { endpointId: assignedEvolutionEndpointId } : {}), + ...(evolutionValues.endpointId ? { endpointId: evolutionValues.endpointId } : {}), endpoint: evolutionValues.endpoint, protocol: chatProtocol(evolutionValues.provider), ...(evolutionValues.apiKey.trim() ? { apiKey: evolutionValues.apiKey.trim() } : {}), @@ -174,15 +166,9 @@ export function ModelPage() { capabilities: ["memory_evolution"] }); workspace = assignCatalogPreset(evolution.workspace, "byok", "memory_evolution", evolution.presetId); - const assignedMemoryEndpointId = mem.reuse - ? evolution.endpointId - : assignedCatalogEndpointId(workspace, "byok", "memory_summary") - ?? (!memoryValues.apiKey.trim() && memoryValues.apiKeyMasked - ? evolution.endpointId - : undefined); const memory = upsertByokPreset(workspace, { provider: memoryValues.provider, - ...(memoryValues.apiKeyMasked && assignedMemoryEndpointId ? { endpointId: assignedMemoryEndpointId } : {}), + ...(memoryValues.endpointId ? { endpointId: memoryValues.endpointId } : {}), endpoint: memoryValues.endpoint, protocol: chatProtocol(memoryValues.provider), ...(memoryValues.apiKey.trim() ? { apiKey: memoryValues.apiKey.trim() } : {}), @@ -277,10 +263,26 @@ function modelPageSaveErrorText( return message || t("settings.modelWorkspace.saveFailed"); } -function chatProtocol(provider: string) { - if (provider === "anthropic") return "anthropic-messages" as const; - if (provider === "gemini" || provider === "google") return "gemini-generate-content" as const; - return "openai-chat-completions" as const; +function patchModelConfig(current: ModelConfig, patch: Partial): ModelConfig { + return { + ...current, + ...patch, + ...(patch.reuse !== undefined + ? { validation: { status: "idle" as const, message: null, testedKey: null } } + : {}) + }; +} + +function bindRoleCredential(config: ModelConfig, primary: PrimaryModelValues, endpointId: string | undefined): ModelConfig { + const next: ModelConfig = { + ...config, + credentialSource: endpointId ? { + endpointId, + credentialSignature: endpointCredentialSignature(config.protocol, chatProtocol(config.protocol), config.endpoint, config.apiKey, config.apiKeyMasked) + } : null + }; + next.validation = bindSavedValidation(config.validation, createModelFormValues(next, primary)); + return next; } /** @@ -305,6 +307,7 @@ function ModelCard(props: ModelCardProps) { endpoint: props.primary.endpoint, apiKey: props.primary.apiKey, apiKeyMasked: props.primary.apiKeyMasked ?? "", + credentialSource: props.primary.credentialSource, configured: Boolean(props.primary.configured || props.primary.apiKeyMasked) }); return; @@ -385,12 +388,12 @@ function ModelCard(props: ModelCardProps) { value={props.cfg.apiKey} onChange={(value) => props.onPatch({ apiKey: value })} show={props.cfg.showKey} - maskedValue={props.cfg.apiKeyMasked} + maskedValue={formValues.apiKeyMasked} onToggle={() => props.onPatch({ showKey: !props.cfg.showKey })} />
- - + +
)} @@ -499,12 +502,12 @@ function TestButton(props: TestButtonProps) { function ValidationMessage(props: ValidationMessageProps) { const { t } = useTranslation(); - if (props.validation.status === "idle" && !props.stale) { + if (props.validation.status === "idle" && !props.stale && !props.credentialRequired) { return null; } - const isSuccess = props.validation.status === "success" && !props.stale; - const message = props.stale ? t("apiKey.testStale") : props.validation.message; + const isSuccess = props.validation.status === "success" && !props.stale && !props.credentialRequired; + const message = props.credentialRequired ? t("apiKey.credentialsChanged") : props.stale ? t("apiKey.testStale") : props.validation.message; return ( diff --git a/App/frontend/desktop/src/pages/tests/api-key-optional-page-source.test.ts b/App/frontend/desktop/src/pages/tests/api-key-optional-page-source.test.ts index b092e1cff..7db7df8a2 100644 --- a/App/frontend/desktop/src/pages/tests/api-key-optional-page-source.test.ts +++ b/App/frontend/desktop/src/pages/tests/api-key-optional-page-source.test.ts @@ -35,12 +35,7 @@ describe("ApiKeyOptionalPage catalog source", () => { expect(source).toContain("savedCatalogSignatureRef.current !== saveSignature"); expect(source).toContain("savedCatalogSignatureRef.current = saveSignature"); expect(source).toContain("const savedEndpointIdentitiesRef = useRef"); - expect(source).toContain("savedAsrIdentity?.credentialSignature === asrCredentialSignature"); - expect(source).toContain("savedImageIdentity?.credentialSignature === imageGenCredentialSignature"); expect(source).toContain("endpointCredentialSignature("); - expect(source).toContain("provider: provider.trim().toLowerCase()"); - expect(source).toContain('endpoint: endpoint.trim().replace(/\\/+$/, "")'); - expect(source).toContain("credential: normalizedMaskedApiKey ?"); expect(source).toContain('role="alert"'); expect(source).toContain("disabled={modePersistencePending}"); }); diff --git a/App/frontend/desktop/src/pages/tests/api-key-page-source.test.ts b/App/frontend/desktop/src/pages/tests/api-key-page-source.test.ts index 581753311..13adc501b 100644 --- a/App/frontend/desktop/src/pages/tests/api-key-page-source.test.ts +++ b/App/frontend/desktop/src/pages/tests/api-key-page-source.test.ts @@ -10,8 +10,8 @@ describe("ApiKeyPage source", () => { it("第一步仅保留大模型与 Embedding,测试连接按钮布局固定", () => { const source = readFileSync(apiKeyPageSourcePath, "utf8"); const fieldsSource = readFileSync(fileURLToPath(new URL("../api-key-form-fields.tsx", import.meta.url)), "utf8"); - const messageIndex = source.indexOf(""); - const buttonIndex = source.indexOf(""); + const messageIndex = source.indexOf(" { expect(source).toContain("savedCatalogSignatureRef.current !== saveSignature"); expect(source).toContain("savedCatalogSignatureRef.current = saveSignature"); expect(source).toContain("const savedEndpointIdentitiesRef = useRef"); - expect(source).toContain("savedAgentIdentity?.credentialSignature === agentCredentialSignature"); - expect(source).toContain("savedEmbeddingIdentity?.credentialSignature === embeddingCredentialSignature"); expect(source).toContain("endpointCredentialSignature("); - expect(source).toContain("provider: provider.trim().toLowerCase()"); - expect(source).toContain('endpoint: endpoint.trim().replace(/\\/+$/, "")'); - expect(source).toContain("credential: normalizedMaskedApiKey ?"); expect(source).toContain('role="alert"'); expect(source).toContain("} finally {"); }); @@ -95,7 +90,7 @@ describe("ApiKeyPage source", () => { expect(source).toContain('hydrateModelConfigForm(state.modelConfig, "local")'); expect(source).toContain("fromProtocol(initialModelForm.protocol)"); - expect(source).toContain("const [apiKeyMasked, setApiKeyMasked] = useState(initialModelForm.apiKeyMasked)"); + expect(source).toContain("const [apiKeyMasked] = useState(initialModelForm.apiKeyMasked)"); expect(source).toContain("hasExistingApiKey: Boolean(apiKeyMasked)"); expect(source).toContain("const [embeddingMode, setEmbeddingMode] = useState(initialEmbeddingMode)"); expect(source).toContain("apiKeyMasked: embeddingConfig.apiKeyMasked"); @@ -110,7 +105,7 @@ describe("ApiKeyPage source", () => { expect(source).not.toContain("showSavedSecret"); expect(source).not.toContain('savedLabel={t("apiKey.savedKey")}'); - expect(source).toContain("maskedValue={apiKeyMasked}"); + expect(source).toContain("maskedValue={modelFormValues.apiKeyMasked}"); expect(fieldsSource).toContain("const placeholder = !props.value.trim() && props.maskedValue ? props.maskedValue : props.placeholder;"); expect(fieldsSource).toContain("placeholder={placeholder}"); }); @@ -140,7 +135,6 @@ describe("ApiKeyPage source", () => { expect(source).toContain("setEndpoint(next.endpoint)"); expect(source).toContain('setModel("")'); expect(source).toContain('setApiKey("");'); - expect(source).toContain('setApiKeyMasked("");'); expect(source).toContain('placeholder={`${t("apiKey.examplePrefix")} ${selectedProvider.defaultModelId}`}'); expect(source).not.toContain("modelPlaceholder"); }); diff --git a/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx new file mode 100644 index 000000000..39619a7ad --- /dev/null +++ b/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx @@ -0,0 +1,351 @@ +// @vitest-environment happy-dom + +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ModelCapability, ModelConfigInput, ModelConfigTestInput } from "@memmy/local-api-contracts"; +import { createMemmyConfigWriter } from "../../../../../backend/src/infrastructure/memmy-config/index.js"; +import { createAppConfigService, type CreateAppConfigServiceOptions } from "../../../../../backend/src/services/app-config-service.js"; +import { createHttpConfigClient } from "../../api/config-client.js"; +import type { AppClients } from "../../api/client-types.js"; +import { createInitialAppState, type AppState } from "../../state/app-reducer.js"; +import { assignedCatalogEndpointId, assignCatalogPreset, createModelWorkspace, modelConfigInput, upsertByokPreset } from "../../state/model-workspace.js"; +import { ApiKeyPage } from "../api-key-page.js"; +import { ApiKeyOptionalPage } from "../api-key-optional-page.js"; +import { ModelPage } from "../model-page.js"; + +const mocks = vi.hoisted(() => ({ clients: null as AppClients | null, state: null as AppState | null, dispatch: vi.fn() })); +vi.mock("../../app/providers.js", () => ({ useApiClients: () => ({ clients: mocks.clients }) })); +vi.mock("../../state/app-state.js", () => ({ useAppState: () => ({ state: mocks.state, dispatch: mocks.dispatch }) })); +vi.mock("../../analytics/use-analytics.js", () => ({ useAnalytics: () => ({ track: vi.fn() }) })); +vi.mock("../../i18n/use-translation.js", () => ({ useTranslation: () => ({ t: (key: string) => key, language: "zh-CN" }) })); +(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +// Use the real HTTP client conversion, backend secret resolution and catalog file writer. +// Only transport and the external model provider are replaced; no real keys or network calls. +async function createServer(directory: string, independent = true) { + const writer = createMemmyConfigWriter({ configPath: join(directory, "config.yaml") }); + const probe = vi.fn(async (_input: ModelConfigTestInput & { apiKey: string }) => ({ + ok: true, message: "ok", checkedAt: "2026-10-16T00:00:00.000Z" + })); + const service = createAppConfigService({ + bootstrapRepository: {} as CreateAppConfigServiceOptions["bootstrapRepository"], + memmyConfigWriter: writer, + modelConfigTester: { test: probe } + }); + const write = vi.spyOn(service, "setModelConfig"); + vi.stubGlobal("fetch", vi.fn(async (url: URL, init: RequestInit) => { + const path = new URL(url).pathname; + let data: unknown; + if (path.endsWith("/test")) data = await service.testModelConfig(JSON.parse(String(init.body))); + else if (path === "/api/app/model-config") data = init.method === "GET" + ? await service.getModelConfig() : await service.setModelConfig(JSON.parse(String(init.body)) as ModelConfigInput); + else throw new Error(`Unexpected request: ${path}`); + return new Response(JSON.stringify(data), { headers: { "content-type": "application/json" } }); + })); + const client = createHttpConfigClient({ baseUrl: "http://test.invalid", localToken: "test-only" }); + client.updateSettings = vi.fn(async (input) => input) as typeof client.updateSettings; + client.updateOnboarding = vi.fn(async (input) => input) as typeof client.updateOnboarding; + let workspace = createModelWorkspace(await service.getModelConfig()); + for (const [capability, provider, protocol, model, key] of [ + ["agent", "openai", "openai-chat-completions", "same-model", "sk-personal-1111"], + ["embedding", "openai", "openai-embeddings", "embed-model", "sk-embedding-2222"], + ["asr", "qwen", "dashscope-input-audio-chat", "qwen3-asr-flash", "sk-asr-3333"], + ["image_generation", "openai", "openai-images", "image-model", "sk-image-4444"], + ...(independent ? [ + ["memory_evolution", "openai", "openai-chat-completions", "same-model", "sk-team-1111"], + ["memory_summary", "openai", "openai-chat-completions", "same-model", "sk-summary-1111"] + ] : []) + ] as const) { + const preset = upsertByokPreset(workspace, { + provider, protocol, model, apiKey: key, endpoint: "https://provider.example/v1", capabilities: [capability] + }); + workspace = assignCatalogPreset(preset.workspace, "byok", capability, preset.presetId); + } + // Same route/model/capabilities and same displayed mask, but a different secret. + // Saving the primary key must never resolve to this unassigned alternative. + workspace = upsertByokPreset(workspace, { + provider: "openai", protocol: "openai-chat-completions", model: "same-model", + apiKey: "sk-alternative-1111", endpoint: "https://provider.example/v1", capabilities: ["agent"] + }).workspace; + await client.saveModelCatalog(modelConfigInput(workspace)); + write.mockClear(); + return { + client, probe, write, writer, + read: () => client.getModelConfig(), + async key(capability: ModelCapability) { + const config = await client.getModelConfig(); + const workspace = createModelWorkspace(config); + const id = assignedCatalogEndpointId(workspace, "byok", capability)!; + const provider = config.catalog!.providers.find((provider) => provider.endpoints.some((endpoint) => endpoint.endpointId === id))!; + return writer.readEndpointApiKey!(provider.provider, id); + } + }; +} + +type Scenario = { role: ModelCapability; page: () => ReactNode; endpoint: string; model: string; key: string; expected: string; index: number; fieldIndex?: number }; +const scenarios: Scenario[] = [ + { role: "agent", page: () => , endpoint: "apiKey.endpoint", model: "apiKey.model", key: "apiKey.key", expected: "sk-personal-1111", index: 0 }, + { role: "embedding", page: () => , endpoint: "apiKey.embeddingEndpoint", model: "apiKey.embeddingModel", key: "apiKey.embeddingKey", expected: "sk-embedding-2222", index: 1 }, + { role: "asr", page: () => , endpoint: "apiKey.asrEndpoint", model: "apiKey.asrModel", key: "apiKey.asrKey", expected: "sk-asr-3333", index: 0 }, + { role: "image_generation", page: () => , endpoint: "apiKey.imageGenEndpoint", model: "apiKey.imageGenModel", key: "apiKey.imageGenKey", expected: "sk-image-4444", index: 1 }, + { role: "memory_evolution", page: () => , endpoint: "apiKey.endpoint", model: "apiKey.model", key: "API Key", expected: "sk-team-1111", index: 1, fieldIndex: 1 }, + { role: "memory_summary", page: () => , endpoint: "apiKey.endpoint", model: "apiKey.model", key: "API Key", expected: "sk-summary-1111", index: 0, fieldIndex: 0 } +]; + +describe("BYOK displayed, tested and saved credentials", () => { + let directory: string; + let container: HTMLDivElement; + let root: Root; + let server: Awaited>; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + directory = await mkdtemp(join(tmpdir(), "memmy-byok-review-")); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + server = await createServer(directory); + mocks.state = { ...createInitialAppState(), modelConfig: await server.read() }; + mocks.clients = { config: server.client } as AppClients; + }); + afterEach(async () => { + act(() => root.unmount()); + document.body.replaceChildren(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + await rm(directory, { recursive: true, force: true }); + }); + + async function render(page: ReactNode) { await act(async () => root.render(page)); } + async function click(button: HTMLButtonElement) { await act(async () => button.click()); } + function next() { return [...container.querySelectorAll("button")].find((button) => button.textContent === "apiKey.next")!; } + function test(index: number) { return [...container.querySelectorAll("button")].filter((button) => button.textContent?.startsWith("apiKey.test"))[index]!; } + function field(label: string, index = 0) { + return [...container.querySelectorAll("label")].filter((node) => node.textContent === label)[index]!.parentElement!.querySelector("input")!; + } + async function change(input: HTMLInputElement, value: string) { + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); + } + async function testAndSave(scenario: Scenario) { + const count = server.probe.mock.calls.length; + await click(test(scenario.index)); + await vi.waitFor(() => expect(server.probe).toHaveBeenCalledTimes(count + 1)); + await vi.waitFor(() => expect(test(scenario.index).textContent).toBe("apiKey.testSuccess")); + const tested = server.probe.mock.calls.at(-1)![0]; + const writes = server.write.mock.calls.length; + await click(next()); + await vi.waitFor(() => expect(server.write.mock.calls.length).toBeGreaterThan(writes)); + await vi.waitFor(() => expect(mocks.dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: "navigation/changed" }))); + expect(await server.key(scenario.role)).toBe(tested.apiKey); + return tested; + } + + it.each(scenarios)("$role: unchanged masked credential resolves and persists the same real key", async (scenario) => { + await render(scenario.page()); + expect(field(scenario.key, scenario.fieldIndex).value).toBe(""); + expect(field(scenario.key, scenario.fieldIndex).placeholder).toContain(scenario.expected.slice(-4)); + expect((await testAndSave(scenario)).apiKey).toBe(scenario.expected); + }); + + it.each(scenarios)("$role: changed URL blocks old credentials; explicit key tests and saves a separate connection", async (scenario) => { + const original = await server.read(); + const oldId = assignedCatalogEndpointId(createModelWorkspace(original), "byok", scenario.role)!; + const oldProvider = original.catalog!.providers.find((provider) => provider.endpoints.some((endpoint) => endpoint.endpointId === oldId))!.provider; + await render(scenario.page()); + await click(test(scenario.index)); + await vi.waitFor(() => expect(test(scenario.index).textContent).toBe("apiKey.testSuccess")); + await change(field(scenario.endpoint, scenario.fieldIndex), "https://changed.example/v1"); + expect(field(scenario.key, scenario.fieldIndex).placeholder).toBe("sk-..."); + expect(container.textContent).toContain("apiKey.credentialsChanged"); + expect(test(scenario.index).textContent).not.toBe("apiKey.testSuccess"); + const calls = server.probe.mock.calls.length; + await click(test(scenario.index)); + expect(server.probe).toHaveBeenCalledTimes(calls); + await click(next()); + expect(server.write).not.toHaveBeenCalled(); + // The same key may be entered again; no new provider credential is required. + await change(field(scenario.key, scenario.fieldIndex), scenario.expected); + const result = await testAndSave(scenario); + expect(result.apiBase).toBe("https://changed.example/v1"); + expect(result.apiKey).toBe(scenario.expected); + const saved = await server.read(); + expect(assignedCatalogEndpointId(createModelWorkspace(saved), "byok", scenario.role)).not.toBe(oldId); + expect(await server.writer.readEndpointApiKey!(oldProvider, oldId)).toBe(scenario.expected); + expect(saved.catalog!.providers.find((provider) => provider.provider === oldProvider)!.endpoints.find((endpoint) => endpoint.endpointId === oldId)!.apiBase).toBe("https://provider.example/v1"); + }); + + it.each(scenarios.filter((scenario) => scenario.role !== "asr"))("$role: model-only edits retain the key but invalidate the test", async (scenario) => { + const oldId = assignedCatalogEndpointId(createModelWorkspace(await server.read()), "byok", scenario.role); + await render(scenario.page()); + await change(field(scenario.model, scenario.fieldIndex), "changed-model"); + expect(container.textContent).toContain("apiKey.testStale"); + expect(field(scenario.key, scenario.fieldIndex).placeholder).toContain(scenario.expected.slice(-4)); + const tested = await testAndSave(scenario); + expect(tested.apiKey).toBe(scenario.expected); + expect(tested.modelId).toBe("changed-model"); + expect(assignedCatalogEndpointId(createModelWorkspace(await server.read()), "byok", scenario.role)).toBe(oldId); + }); + + it.each(scenarios)("$role: URL whitespace and trailing slash keep the saved credential usable", async (scenario) => { + await render(scenario.page()); + await change(field(scenario.endpoint, scenario.fieldIndex), " https://provider.example/v1/// "); + expect(container.textContent).not.toContain("apiKey.credentialsChanged"); + expect((await testAndSave(scenario)).apiKey).toBe(scenario.expected); + }); + + it.each([0, 1])("role %i: same URL/model and identical masks do not imply reuse; toggling restores the independent key", async (index) => { + await render(); + const checkbox = () => [...container.querySelectorAll('button[role="checkbox"]')][index]!; + expect(checkbox().getAttribute("aria-checked")).toBe("false"); + const scenario = scenarios[index === 0 ? 5 : 4]!; + await click(test(index)); + await vi.waitFor(() => expect(server.probe).toHaveBeenCalledTimes(1)); + expect(server.probe.mock.calls[0]![0].apiKey).toBe(scenario.expected); + await click(checkbox()); + await click(checkbox()); + expect(checkbox().getAttribute("aria-checked")).toBe("false"); + expect(test(index).textContent).not.toBe("apiKey.testSuccess"); + expect((await testAndSave(scenario)).apiKey).toBe(scenario.expected); + }); + // Deduplication is verified against the real file writer, not a catalog stub. + it.each(scenarios)("$role: reentering the same trimmed key reuses the canonical saved endpoint", async (scenario) => { + const before = await server.read(); + const oldId = assignedCatalogEndpointId(createModelWorkspace(before), "byok", scenario.role); + await render(scenario.page()); + await change(field(scenario.key, scenario.fieldIndex), ` ${scenario.expected} `); + expect((await testAndSave(scenario)).apiKey).toBe(scenario.expected); + const saved = await server.read(); + expect(assignedCatalogEndpointId(createModelWorkspace(saved), "byok", scenario.role)).toBe(oldId); + expect(saved.catalog!.providers.flatMap((provider) => provider.endpoints)).toHaveLength( + before.catalog!.providers.flatMap((provider) => provider.endpoints).length + ); + }); + + it.each(scenarios)("$role: replacing a key with an identical mask tests and saves the new key without overwriting the old one", async (scenario) => { + const before = await server.read(); + const oldId = assignedCatalogEndpointId(createModelWorkspace(before), "byok", scenario.role)!; + const provider = before.catalog!.providers.find((item) => item.endpoints.some((endpoint) => endpoint.endpointId === oldId))!.provider; + const replacement = `sk-replacement-${scenario.expected.slice(-4)}`; + await render(scenario.page()); + await change(field(scenario.key, scenario.fieldIndex), replacement); + expect((await testAndSave(scenario)).apiKey).toBe(replacement); + expect(assignedCatalogEndpointId(createModelWorkspace(await server.read()), "byok", scenario.role)).not.toBe(oldId); + expect(await server.writer.readEndpointApiKey!(provider, oldId)).toBe(scenario.expected); + }); + + it.each([0, 1])("role %i: enabling reuse saves the inherited credential instead of the independent credential", async (index) => { + await render(); + await click([...container.querySelectorAll('button[role="checkbox"]')][index]!); + await click(next()); + await vi.waitFor(() => expect(mocks.dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: "navigation/changed" }))); + expect(await server.key(index === 0 ? "memory_summary" : "memory_evolution")).toBe(index === 0 ? "sk-team-1111" : "sk-personal-1111"); + }); + + it.each([0, 1])("role %i: disabling explicit follow copies the displayed inherited key and its actual source", async (index) => { + server = await createServer(join(directory, "follow"), false); + mocks.state = { ...createInitialAppState(), modelConfig: await server.read() }; + mocks.clients = { config: server.client } as AppClients; + await render(); + const checkbox = [...container.querySelectorAll('button[role="checkbox"]')][index]!; + expect(checkbox.getAttribute("aria-checked")).toBe("true"); + await click(checkbox); + const scenario = { ...scenarios[index === 0 ? 5 : 4]!, index: 0, fieldIndex: 0 }; + expect(field("API Key").placeholder).toContain("1111"); + expect((await testAndSave(scenario)).apiKey).toBe("sk-personal-1111"); + }); + + it.each(scenarios.filter((scenario) => ["agent", "image_generation", "memory_evolution", "memory_summary"].includes(scenario.role)))( + "$role: changing provider and protocol requires an explicit key before testing and saving", async (scenario) => { + await render(scenario.page()); + const comboIndex = scenario.fieldIndex ?? 0; + await click([...container.querySelectorAll('button[role="combobox"]')][comboIndex]!); + // Select an actual different provider through the rendered control. + const option = [...container.querySelectorAll('button[role="option"]')].find((item) => item.getAttribute("aria-selected") === "false")!; + await click(option); + await change(field(scenario.model, scenario.fieldIndex), "changed-provider-model"); + // Same URL does not make credentials interchangeable across providers/protocols. + await change(field(scenario.endpoint, scenario.fieldIndex), "https://provider.example/v1"); + expect(field(scenario.key, scenario.fieldIndex).placeholder).toBe("sk-..."); + expect(container.textContent).toContain("apiKey.credentialsChanged"); + await click(test(scenario.index)); + expect(server.probe).not.toHaveBeenCalled(); + await click(next()); + expect(server.write).not.toHaveBeenCalled(); + await change(field(scenario.key, scenario.fieldIndex), scenario.expected); + expect((await testAndSave(scenario)).apiKey).toBe(scenario.expected); + } + ); + + it("an explicitly fixed incomplete role is not silently changed to follow", async () => { + mocks.state!.modelConfig.memmyMemory!.evolution = { + mode: "fixed", provider: "openai", endpoint: "https://provider.example/v1", + model: "", apiKey: "", apiKeyMasked: "", configured: false + }; + await render(); + expect([...container.querySelectorAll('button[role="checkbox"]')][1]!.getAttribute("aria-checked")).toBe("false"); + expect(next().disabled).toBe(true); + }); + + it("a rejected independent key cannot fall back to the valid primary key or be saved", async () => { + await render(); + server.probe.mockResolvedValueOnce({ ok: false, message: "rejected team key", checkedAt: "2026-10-16T00:00:00.000Z" }); + await click(test(1)); + await vi.waitFor(() => expect(container.textContent).toContain("rejected team key")); + expect(server.probe.mock.calls[0]![0].apiKey).toBe("sk-team-1111"); + expect(next().disabled).toBe(true); + expect(server.write).not.toHaveBeenCalled(); + }); + + it("returning after Next hydrates the saved mask and tests the same persisted key", async () => { + await render(); + await change(field("apiKey.key"), "sk-new-onboarding-1111"); + await testAndSave(scenarios[0]!); + await render(null); + mocks.state = { ...createInitialAppState(), modelConfig: await server.read() }; + mocks.dispatch.mockClear(); + await render(); + expect(field("apiKey.key").value).toBe(""); + expect(field("apiKey.key").placeholder).toContain("1111"); + expect((await testAndSave(scenarios[0]!)).apiKey).toBe("sk-new-onboarding-1111"); + }); + + it("an old in-flight success cannot validate a changed connection", async () => { + let finish!: (result: Awaited>) => void; + server.probe.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + await render(); + await click(test(0)); + await vi.waitFor(() => expect(server.probe).toHaveBeenCalledTimes(1)); + await change(field("apiKey.endpoint"), "https://changed.example/v1"); + await act(async () => finish({ ok: true, message: "ok", checkedAt: "2026-10-16T00:00:00.000Z" })); + expect(container.textContent).toContain("apiKey.credentialsChanged"); + expect(test(0).textContent).not.toBe("apiKey.testSuccess"); + expect(next().disabled).toBe(true); + expect(server.write).not.toHaveBeenCalled(); + }); + + it("retry after assignment-write failure reuses the newly saved key without duplicate connections", async () => { + await render(); + await change(field("apiKey.key"), "sk-retry-1111"); + await click(test(0)); + await vi.waitFor(() => expect(test(0).textContent).toBe("apiKey.testSuccess")); + const persist = server.write.getMockImplementation()!; + server.write.mockImplementationOnce(persist).mockRejectedValueOnce(new Error("assignment-write-failed")); + await click(next()); + await vi.waitFor(() => expect(container.textContent).toContain("assignment-write-failed")); + expect(mocks.dispatch).not.toHaveBeenCalled(); + const endpointsAfterCreate = (await server.read()).catalog!.providers.flatMap((provider) => provider.endpoints).length; + await click(next()); + await vi.waitFor(() => expect(mocks.dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: "navigation/changed" }))); + expect(await server.key("agent")).toBe("sk-retry-1111"); + expect((await server.read()).catalog!.providers.flatMap((provider) => provider.endpoints)).toHaveLength(endpointsAfterCreate); + }); + +}); 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 2011fb782..4c2f5123e 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 @@ -299,31 +299,6 @@ describe("BYOK setup save feedback", () => { expect(mocks.dispatch).toHaveBeenCalledWith(appActions.navigate("/api-key-models")); }); - it("reuses existing first-step endpoints when saved keys are entered again", async () => { - const server = createCatalogServer(configuredCatalog(false)); - const initialAgentEndpointId = assignedCatalogEndpointId(createModelWorkspace(server.catalog()), "byok", "agent"); - const initialEmbeddingEndpointId = assignedCatalogEndpointId(createModelWorkspace(server.catalog()), "byok", "embedding"); - mocks.state = { ...createInitialAppState(), modelConfig: maskedSavedModelConfig(server.catalog()) }; - mocks.clients = createClients(server.saveModelCatalog, { - getModelConfig: vi.fn(async () => maskedSavedModelConfig(server.catalog())) - }); - await render(); - - await changeField("apiKey.key", "sk-primary"); - await click(testButton()); - await changeField("apiKey.embeddingKey", "sk-embedding"); - await click(testButton(1)); - await vi.waitFor(() => expect(button("apiKey.next").disabled).toBe(false)); - await click(button("apiKey.next")); - - const savedCatalog = server.catalog(); - expect(endpointCount(savedCatalog)).toBe(2); - expect(assignedCatalogEndpointId(createModelWorkspace(savedCatalog), "byok", "agent")) - .toBe(initialAgentEndpointId); - expect(assignedCatalogEndpointId(createModelWorkspace(savedCatalog), "byok", "embedding")) - .toBe(initialEmbeddingEndpointId); - }); - it("invalidates only the changed first-step credential identity", async () => { const server = createCatalogServer(configuredCatalog(false)); const updateSettings = vi.fn(async (settings: unknown) => settings) @@ -449,31 +424,6 @@ describe("BYOK setup save feedback", () => { expect(mocks.dispatch).toHaveBeenCalledWith(appActions.navigate("/onboarding")); }); - it("reuses existing optional endpoints when saved keys are entered again", async () => { - const server = createCatalogServer(configuredCatalog()); - const initialAsrEndpointId = assignedCatalogEndpointId(createModelWorkspace(server.catalog()), "byok", "asr"); - const initialImageEndpointId = assignedCatalogEndpointId(createModelWorkspace(server.catalog()), "byok", "image_generation"); - mocks.state = { ...createInitialAppState(), modelConfig: maskedSavedModelConfig(server.catalog()) }; - mocks.clients = createClients(server.saveModelCatalog, { - getModelConfig: vi.fn(async () => maskedSavedModelConfig(server.catalog())) - }); - await render(); - - await changeField("apiKey.asrKey", "sk-asr"); - await click(testButton()); - await changeField("apiKey.imageGenKey", "sk-image"); - await click(testButton(1)); - await vi.waitFor(() => expect(button("apiKey.next").disabled).toBe(false)); - await click(button("apiKey.next")); - - const savedCatalog = server.catalog(); - expect(endpointCount(savedCatalog)).toBe(4); - expect(assignedCatalogEndpointId(createModelWorkspace(savedCatalog), "byok", "asr")) - .toBe(initialAsrEndpointId); - expect(assignedCatalogEndpointId(createModelWorkspace(savedCatalog), "byok", "image_generation")) - .toBe(initialImageEndpointId); - }); - it("retests masked optional keys through their saved endpoints", async () => { const catalog = configuredCatalog(); const workspace = createModelWorkspace(catalog); 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..553c3335a 100644 --- a/App/frontend/desktop/src/pages/tests/model-config.test.ts +++ b/App/frontend/desktop/src/pages/tests/model-config.test.ts @@ -338,7 +338,7 @@ describe("model config helpers", () => { apiKeyMasked: "sk-m••••mory" }, skillModel: { - reuse: true, + reuse: false, apiKeyMasked: "sk-m••••main" } }); diff --git a/App/frontend/desktop/src/pages/tests/model-page-source.test.ts b/App/frontend/desktop/src/pages/tests/model-page-source.test.ts index e03408d33..3218c9649 100644 --- a/App/frontend/desktop/src/pages/tests/model-page-source.test.ts +++ b/App/frontend/desktop/src/pages/tests/model-page-source.test.ts @@ -37,11 +37,7 @@ describe("ModelPage source", () => { expect(pageSource).toContain('capabilities: ["memory_evolution"]'); expect(pageSource).toContain('assignCatalogPreset(memory.workspace, "byok", "memory_summary"'); expect(pageSource).toContain('assignCatalogPreset(evolution.workspace, "byok", "memory_evolution"'); - expect(pageSource).toContain('assignedCatalogEndpointId(workspace, "byok", "agent")'); - expect(pageSource).toContain("const assignedMemoryEndpointId = mem.reuse"); - expect(pageSource).toContain("const assignedEvolutionEndpointId = skill.reuse"); - expect(pageSource).toContain("!memoryValues.apiKey.trim() && memoryValues.apiKeyMasked"); - expect(pageSource).toContain("!evolutionValues.apiKey.trim() && evolutionValues.apiKeyMasked"); + expect(pageSource).toContain('assignedCatalogEndpointId(initialWorkspace, "byok", "agent")'); expect(pageSource).toContain("createModelFormValues"); expect(pageSource).toContain("createModelProtocolPatch"); expect(pageSource).toContain("testModelConnection"); @@ -53,7 +49,7 @@ describe("ModelPage source", () => { expect(pageSource).not.toContain('dispatch(appActions.navigate("/onboarding"))'); expect(pageSource).not.toContain('track({ name: "byok_completed"'); expect(pageSource).toContain("DEFAULT_MODEL_IDS[props.cfg.protocol]"); - expect(pageSource).toContain(""); + expect(pageSource).toContain(" { const pageSource = readSource(resolve(pagesDir, "model-page.tsx")); expect(pageSource).toContain('hydrateModelConfigForm(state.modelConfig, "local")'); - expect(pageSource).toContain("const [mem, setMem] = useState(() => initialModelForm.memoryModel)"); - expect(pageSource).toContain("const [skill, setSkill] = useState(() => initialModelForm.skillModel)"); + expect(pageSource).toContain("const [mem, setMem] = useState(() => bindRoleCredential("); + expect(pageSource).toContain("const [skill, setSkill] = useState(() => bindRoleCredential("); expect(pageSource).toContain("apiKeyMasked: state.modelConfig.apiKeyMasked"); expect(pageSource).toContain("configured: state.modelConfig.configured"); }); From 3a0d9a4fad8446129e03c9321ebf8b17e617531a Mon Sep 17 00:00:00 2001 From: whqf1123 <333792770+whqf1123@users.noreply.github.com> Date: Fri, 16 Oct 2026 11:58:31 +0800 Subject: [PATCH 51/56] fix: refine embedding failure isolation and empty chunk handling (MEMMY-488) --- Memory/readme.md | 6 + Memory/src/model/openai-embedding-inputs.ts | 25 ++-- Memory/src/service/worker/job-handlers.ts | 12 ++ Memory/tests/embedding-input-guard.test.ts | 28 +++++ Memory/tests/memmy-488-reproduction.test.ts | 98 ---------------- .../embedding/embedding-processing.test.ts | 107 +++++++++++++++++- 6 files changed, 168 insertions(+), 108 deletions(-) delete mode 100644 Memory/tests/memmy-488-reproduction.test.ts diff --git a/Memory/readme.md b/Memory/readme.md index 8a5279407..9ecedc690 100644 --- a/Memory/readme.md +++ b/Memory/readme.md @@ -89,6 +89,12 @@ for input-related HTTP 400/413/422 failures, including generic parameter errors. Successful siblings are saved; a still-invalid item stops automatic retries. Authentication/configuration, quota, rate-limit and server errors do not trigger this fan-out. This applies to both new jobs and the embedding retry queue. +Provider codes and messages for unsupported operations, insufficient quota/balance, +and rate limits are classified before generic HTTP 400 errors. Rate limits retain +delayed retry behavior; model/account configuration failures require intervention. +Decoded empty chunks are removed from the embedding plan before requests and +aggregation. If an original has no usable chunks, `(empty)` is embedded under the +same input budgets so output vectors remain aligned with the originals. Previously failed records require an explicit retry/reindex; updating the code does not automatically reset their failed state. diff --git a/Memory/src/model/openai-embedding-inputs.ts b/Memory/src/model/openai-embedding-inputs.ts index 1aac54d74..9be342fe1 100644 --- a/Memory/src/model/openai-embedding-inputs.ts +++ b/Memory/src/model/openai-embedding-inputs.ts @@ -50,10 +50,9 @@ export function planOpenAiEmbeddingInputs( ); const totalTokens = inputs.reduce((sum, item) => sum + item.tokens.length, 0); if (inputs.length === texts.length && totalTokens <= OPENAI_EMBEDDING_BATCH_TOKEN_BUDGET && - inputs.every((item) => item.tokens.length <= inputTokenBudget)) return null; + inputs.every((item) => item.tokens.length > 0 && item.tokens.length <= inputTokenBudget)) return null; - const chunks = inputs.flatMap(({ tokens, originalIndex }) => { - if (tokens.length === 0) return [{ originalIndex, tokens, input: useTokenIds ? tokens : "" }]; + const chunkTokensForInput = (tokens: number[], originalIndex: number): OpenAiEmbeddingChunk[] => { const tokenBytes = useTokenIds ? undefined : tokens.map((token) => encoder.decode_single_token_bytes(token)); @@ -69,14 +68,24 @@ export function planOpenAiEmbeddingInputs( } } const chunkTokens = tokens.slice(offset, end); - items.push({ - originalIndex, - tokens: chunkTokens, - input: useTokenIds ? chunkTokens : decodeTokenBytes(tokenBytes!.slice(offset, end)) - }); + const input = useTokenIds ? chunkTokens : decodeTokenBytes(tokenBytes!.slice(offset, end)); + // A standalone BOM can decode to an empty string. Filter in the plan, + // so the request and vector aggregation retain the same chunk order. + if (input.length > 0) items.push({ originalIndex, tokens: chunkTokens, input }); offset = end; } return items; + }; + const chunksByOriginal: OpenAiEmbeddingChunk[][] = texts.map(() => []); + for (const { tokens, originalIndex } of inputs) { + chunksByOriginal[originalIndex]!.push(...chunkTokensForInput(tokens, originalIndex)); + } + const chunks = chunksByOriginal.flatMap((items, originalIndex) => { + if (items.length > 0) return items; + // Keep one output vector per original, respecting even a small configured + // budget when the placeholder itself needs multiple chunks. + const parts = useTokenIds ? ["(empty)"] : splitUtf8Input("(empty)", inputByteBudget); + return parts.flatMap(part => chunkTokensForInput(Array.from(encoder.encode(part, [], [])), originalIndex)); }); return { batches: batchChunks(chunks), diff --git a/Memory/src/service/worker/job-handlers.ts b/Memory/src/service/worker/job-handlers.ts index 121dac284..589344a6e 100644 --- a/Memory/src/service/worker/job-handlers.ts +++ b/Memory/src/service/worker/job-handlers.ts @@ -598,6 +598,18 @@ export function classifyProcessingError(error: unknown): { if (!hasStructuredCode && /\b40309\b/.test(normalized)) { return { code: "40309", retryAction: "open_settings" }; } + // Providers can report shared model/account failures as HTTP 400. Classify + // their reason before generic 4xx handling, which permits item isolation. + if (error instanceof ModelHttpError) { + const providerCode = error.errorCode?.toLowerCase().replace(/[\s_-]/g, ""); + if (providerCode === "operationnotsupported" || providerCode === "insufficientquota" || + /operation.{0,80}(?:does not work|not supported)|insufficient[\s_-]+(?:quota|balance)/.test(normalized)) { + return { code: "model_configuration", retryAction: "open_settings" }; + } + if (providerCode === "ratelimitexceeded" || /rate[\s_-]*limit/.test(normalized)) { + return { code: "transient_provider_error", retryAction: "retry" }; + } + } if (/api.?key|unauthorized|forbidden|\b401\b|\b403\b|\b404\b|model.+not configured|missing.+model|expected json|html instead of json|configured model endpoint/.test(normalized)) { return { code: "model_configuration", retryAction: "open_settings" }; } diff --git a/Memory/tests/embedding-input-guard.test.ts b/Memory/tests/embedding-input-guard.test.ts index 8e1f0453d..941e61aa7 100644 --- a/Memory/tests/embedding-input-guard.test.ts +++ b/Memory/tests/embedding-input-guard.test.ts @@ -4,6 +4,34 @@ import { createEmbedder } from "../src/model/embedder.js"; afterEach(() => vi.unstubAllGlobals()); +it.each([ + { name: "trailing decoded empty chunk", texts: ["a".repeat(12_000) + "\uFEFF", "xyz"], budget: undefined, + expected: ["a".repeat(12_000), "xyz"] }, + { name: "middle decoded empty chunk", texts: ["aaa\uFEFFbbb", "xyz"], budget: 3, + expected: ["aaa", "bbb", "xyz"] }, + { name: "all chunks of one original decode empty", texts: ["\uFEFF\uFEFF", "xyz"], budget: 3, + expected: ["(em", "pty", ")", "xyz"] }, + { name: "empty original", texts: ["", "xyz"], budget: undefined, + expected: ["(empty)", "xyz"] }, + { name: "meaningful whitespace", texts: [" aaa", "xyz"], budget: 3, + expected: [" ", "aaa", "xyz"] } +])("keeps vectors aligned after handling $name", async ({ texts, budget, expected }) => { + const sent: string[] = []; + vi.stubGlobal("fetch", vi.fn(async (_url, init) => { + const { input } = JSON.parse(String(init?.body)) as { input: string[] }; + sent.push(...input); + return new Response(JSON.stringify({ data: input.map(part => ({ + embedding: part === "xyz" ? [0, 1] : [1, 0] + })) }), { status: 200 }); + })); + const embedder = createEmbedder({ ...DEFAULT_MEMMY_CONFIG.embedding, provider: "openai_compatible", + model: "custom-alias", endpoint: "https://example.test/v1", maxInputBytes: budget, cache: false, maxRetries: 0 }, + { usageRecorder: { record() { return "skipped"; } } }); + await expect(embedder.embed(texts)).resolves.toEqual([[1, 0], [0, 1]]); + expect(sent).toEqual(expected); + expect(sent.every(part => part.length > 0 && Buffer.byteLength(part) <= (budget ?? 12_000))).toBe(true); +}); + it.each([ ["ASCII boundary", "a".repeat(27_447)], ["mixed Unicode", "中文🙂 café\n".repeat(5_000)] diff --git a/Memory/tests/memmy-488-reproduction.test.ts b/Memory/tests/memmy-488-reproduction.test.ts deleted file mode 100644 index 0dde1e782..000000000 --- a/Memory/tests/memmy-488-reproduction.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { DEFAULT_MEMMY_CONFIG, type MemoryRow } from '../src/index.js'; -import { createEmbedder } from '../src/model/embedder.js'; -import { Repositories } from '../src/storage/repositories.js'; -import { embeddingTextForMemory } from '../src/service/embedding/embedding-pipeline.js'; -import { createMemoryServiceFixture, createBatchReflectionLlm } from './fixtures/memory-service-fixture.js'; - -const fixture = createMemoryServiceFixture(); -afterEach(() => { fixture.cleanup(); vi.unstubAllGlobals(); }); - -function setup(explicit: boolean) { - const requests: Array<{ sizes: number[]; rejected: boolean }> = []; - vi.stubGlobal('fetch', vi.fn(async (_url: unknown, init: RequestInit) => { - const { input } = JSON.parse(String(init.body)) as { input: string[] }; - const rejected = input.some(text => text.includes('BAD_EMBEDDING_ITEM')); - requests.push({ sizes: input.map(text => text.length), rejected }); - return new Response(JSON.stringify(rejected - ? { code: explicit ? 'context_length_exceeded' : 20015, message: explicit ? 'maximum context length exceeded' : 'The parameter is invalid.' } - : { data: input.map(() => ({ embedding: [1, 0, 0] })) }), - { status: rejected ? 400 : 200, headers: { 'content-type': 'application/json' } }); - })); - const embedder = createEmbedder({ ...DEFAULT_MEMMY_CONFIG.embedding, - provider: 'openai_compatible', model: 'Pro/BAAI/bge-m3', - endpoint: 'https://embedding-reproduction.invalid/v1', apiKey: 'synthetic-test-key', - cache: false, maxRetries: 3 }, { usageRecorder: { record() { return "skipped"; } } }); - return { embedder, requests }; -} -function skill(id: string, content: string): MemoryRow { - const at = new Date().toISOString(); - return { id, timeline: at, userId: 'memmy488-test', memoryType: 'SkillMemory', - status: 'activated', visibility: 'private', memoryKey: id, memoryValue: content, - tags: [], info: {}, properties: { internal_info: { memory_layer: 'Skill', memory_kind: 'skill', - skill: { name: id, status: 'active', invocation_guide: content } } }, - memoryLayer: 'Skill', version: 1, createdAt: at, updatedAt: at }; -} - -describe('MEMMY-488 regression (valid siblings must survive)', () => { - for (const explicit of [true, false]) { - it(`fresh worker batch: ${explicit ? 'explicit length error' : 'screenshot error 20015'}`, async () => { - const { embedder, requests } = setup(explicit); - const { db, service } = fixture.createTestService({ embedder, llm: createBatchReflectionLlm([], 'Synthetic summary.') }); - const session = service.openSession({ namespace: { source: 'codex', profileId: 'memmy488', userId: 'memmy488-test' } }); - const ids = ['GOOD_A', 'GOOD_B', 'GOOD_C', 'BAD_EMBEDDING_ITEM'].map((answer, i) => - service.completeTurn(`turn-${i}`, { sessionId: session.sessionId, query: 'Remember this synthetic test.', answer }).l1MemoryId); - await service.runWorkerOnce(20, { priorityCohortOnly: true }); - await service.runWorkerOnce(20, { priorityCohortOnly: true }); - const repos = new Repositories(db.db); - const states = ids.map(id => ({ id, state: repos.processing.get(id)?.state, error: repos.processing.get(id)?.errorCode })); - expect(states.map(s => s.state)).toEqual(['ready', 'ready', 'ready', explicit ? 'ready_text_only' : 'failed']); - const vectorRows = db.db.prepare('SELECT memory_id, vector_field FROM memory_vector_entries').all(); - expect(vectorRows.length).toBe(3); - const before = requests.length; - await service.runWorkerOnce(20, { priorityCohortOnly: true }); - expect(requests.length).toBe(before); - }); - - it(`legacy retry batch and separate replay: ${explicit ? 'explicit length error' : 'screenshot error 20015'}`, async () => { - const { embedder, requests } = setup(explicit); - const { db, service } = fixture.createTestService({ embedder }); - const repos = new Repositories(db.db); - const memories = ['GOOD_A', 'GOOD_B', 'GOOD_C', 'BAD_EMBEDDING_ITEM'].map((text, i) => skill(`skill-488-${i}`, text)); - const retries = memories.map(memory => { - repos.memories.insert(memory); - return repos.runtime.enqueueEmbeddingRetry({ targetKind: 'skill', targetId: memory.id, vectorField: 'vec', - sourceText: embeddingTextForMemory(memory), embedRole: 'query', now: Date.now() - 1 }); - }); - await service.runWorkerOnce(10); - const states = retries.map(retry => { const row = repos.runtime.getEmbeddingRetry(retry.id)!; return { status: row.status, attempts: row.attempts }; }); - expect(states.map(s => s.status)).toEqual(['succeeded', 'succeeded', 'succeeded', 'failed']); - const vectorRows = db.db.prepare('SELECT memory_id, vector_field FROM memory_vector_entries').all(); - expect(vectorRows.length).toBe(3); - const before = requests.length; - await service.runWorkerOnce(10); - expect(requests.length).toBe(before); - const replay: string[] = []; - for (const memory of memories) { - try { await embedder.embedOne(embeddingTextForMemory(memory), 'query'); replay.push('succeeded'); } - catch { replay.push('failed'); } - } - expect(replay).toEqual(['succeeded', 'succeeded', 'succeeded', 'failed']); - }); - } - - it('long BGE alias text is chunked and preserves the complete input', async () => { - const sent: string[] = []; - vi.stubGlobal('fetch', vi.fn(async (_url: unknown, init: RequestInit) => { - const { input } = JSON.parse(String(init.body)); sent.push(...input); - return new Response(JSON.stringify({ data: input.map(() => ({ embedding: [1, 0, 0] })) }), { status: 200 }); - })); - const embedder = createEmbedder({ ...DEFAULT_MEMMY_CONFIG.embedding, provider: 'openai_compatible', - model: 'Pro/BAAI/bge-m3', endpoint: 'https://embedding-reproduction.invalid/v1', apiKey: 'synthetic-test-key', cache: false }, - { usageRecorder: { record() { return "skipped"; } } }); - const text = '# Synthetic long document\n' + ' memory'.repeat(16000) + '\nEND_MARKER'; - await embedder.embedOne(text); - expect(sent.length).toBeGreaterThan(1); - expect(sent.join('')).toBe(text); - }); -}); diff --git a/Memory/tests/service/embedding/embedding-processing.test.ts b/Memory/tests/service/embedding/embedding-processing.test.ts index c5d7f5339..f603c81c7 100644 --- a/Memory/tests/service/embedding/embedding-processing.test.ts +++ b/Memory/tests/service/embedding/embedding-processing.test.ts @@ -1,5 +1,5 @@ import { join } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_MEMMY_CONFIG, MemoryDb, @@ -16,6 +16,7 @@ import { updateMemoryVectorField } from "../../../src/service/embedding/embedding-pipeline.js"; import { ModelHttpError } from "../../../src/model/http.js"; +import { createEmbedder } from "../../../src/model/embedder.js"; import { Repositories } from "../../../src/storage/repositories.js"; import { addAgentSourceImport, @@ -32,9 +33,105 @@ const { createTestService } = createMemoryServiceFixture(); -afterEach(cleanup); +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); describe("MemoryService / embedding / processing", () => { + for (const queue of ["fresh", "retry"] as const) { + for (const format of ["code", "message"] as const) { + it.each([ + ["OperationNotSupported", "The embeddings operation does not work with the specified model.", false], + ["insufficient_quota", "Insufficient balance.", false], + ["rate_limit_exceeded", "Rate limit exceeded.", true] + ] as const)(`${queue} queue does not fan out shared HTTP 400 %s (${format})`, async (code, message, transient) => { + const fetchMock = vi.fn(async () => new Response(JSON.stringify({ error: { + code: format === "code" ? code : undefined, + message: format === "message" ? message : "Request rejected" + } }), { status: 400 })); + vi.stubGlobal("fetch", fetchMock); + const { db, service } = createTestService({ + embedder: createHttpTestEmbedder(), + llm: createBatchReflectionLlm([], "Synthetic summary.") + }); + const repos = new Repositories(db.db); + if (queue === "fresh") { + const session = service.openSession({ namespace: { source: "codex", profileId: "shared-error", userId: "test" } }); + const ids = ["GOOD_A", "GOOD_B", "GOOD_C"].map((answer, i) => service.completeTurn(`turn-${i}`, { + sessionId: session.sessionId, query: "Remember this.", answer + }).l1MemoryId); + await service.runWorkerOnce(20, { priorityCohortOnly: true }); + await service.runWorkerOnce(20, { priorityCohortOnly: true }); + for (const id of ids) expect(repos.processing.get(id)).toMatchObject({ + state: transient ? "embedding_pending" : "failed", + errorCode: transient ? "transient_provider_error" : "model_configuration", + retryAction: transient ? "retry" : "open_settings" + }); + } else { + const retries = ["GOOD_A", "GOOD_B", "GOOD_C"].map((content, i) => { + const memory = skillMemory(undefined, { id: `shared-${i}`, content }); + repos.memories.insert(memory); + return repos.runtime.enqueueEmbeddingRetry({ targetKind: "skill", targetId: memory.id, + vectorField: "vec", sourceText: embeddingTextForMemory(memory), embedRole: "query", now: Date.now() - 1 }); + }); + await service.runWorkerOnce(10); + for (const retry of retries) { + const stored = repos.runtime.getEmbeddingRetry(retry.id)!; + expect(stored).toMatchObject({ status: transient ? "pending" : "failed", attempts: 1 }); + if (transient) expect(stored.nextAttemptAt).toBeGreaterThan(Date.now()); + } + } + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(db.db.prepare("SELECT count(*) AS n FROM memory_vector_entries").get()).toEqual({ n: 0 }); + }); + } + + it.each([ + ["context_length_exceeded", "maximum context length exceeded"], + ["20015", "The parameter is invalid."] + ])(`${queue} queue preserves valid siblings through the HTTP adapter (%s)`, async (code, message) => { + const fetchMock = vi.fn(async (_url, init) => { + const { input } = JSON.parse(String(init?.body)) as { input: string[] }; + const bad = input.some(text => text.includes("BAD_EMBEDDING_ITEM")); + return new Response(JSON.stringify(bad ? { code, message } : { + data: input.map(() => ({ embedding: [1, 0, 0] })) + }), { status: bad ? 400 : 200 }); + }); + vi.stubGlobal("fetch", fetchMock); + const { db, service } = createTestService({ embedder: createHttpTestEmbedder(), + llm: createBatchReflectionLlm([], "Synthetic summary.") }); + const repos = new Repositories(db.db); + const texts = ["GOOD_A", "GOOD_B", "GOOD_C", "BAD_EMBEDDING_ITEM"]; + if (queue === "fresh") { + const session = service.openSession({ namespace: { source: "codex", profileId: "isolation", userId: "test" } }); + const ids = texts.map((answer, i) => service.completeTurn(`turn-${i}`, { + sessionId: session.sessionId, query: "Remember this.", answer + }).l1MemoryId); + await service.runWorkerOnce(20, { priorityCohortOnly: true }); + await service.runWorkerOnce(20, { priorityCohortOnly: true }); + expect(ids.map(id => repos.processing.get(id)?.state)).toEqual([ + "ready", "ready", "ready", code === "context_length_exceeded" ? "ready_text_only" : "failed" + ]); + } else { + const retries = texts.map((content, i) => { + const memory = skillMemory(undefined, { id: `isolated-${i}`, content }); + repos.memories.insert(memory); + return repos.runtime.enqueueEmbeddingRetry({ targetKind: "skill", targetId: memory.id, + vectorField: "vec", sourceText: embeddingTextForMemory(memory), embedRole: "query", now: Date.now() - 1 }); + }); + await service.runWorkerOnce(10); + expect(retries.map(retry => repos.runtime.getEmbeddingRetry(retry.id)?.status)).toEqual([ + "succeeded", "succeeded", "succeeded", "failed" + ]); + } + expect(db.db.prepare("SELECT count(*) AS n FROM memory_vector_entries").get()).toEqual({ n: 3 }); + expect(fetchMock).toHaveBeenCalledTimes(5); + await service.runWorkerOnce(20, { priorityCohortOnly: true }); + expect(fetchMock).toHaveBeenCalledTimes(5); + }); + } + it("embeds Skill retrieval metadata instead of the full SKILL.md when short metadata exists", () => { const text = embeddingTextForMemory(skillMemory({ retrievalBlurb: "Use for safe SQLite schema migrations.", @@ -741,6 +838,12 @@ function worldModelMemory(): MemoryRow { }; } +function createHttpTestEmbedder(): Embedder { + return createEmbedder({ ...DEFAULT_MEMMY_CONFIG.embedding, provider: "openai_compatible", + model: "custom-alias", endpoint: "https://embedding.test/v1", cache: false, maxRetries: 0 }, + { usageRecorder: { record() { return "skipped"; } } }); +} + function createFlakyEmbedder(): Embedder { let batchCalls = 0; return { From 2184ff42214bf33d6c2e53a5bd3d842ab2255c30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Fri, 16 Oct 2026 14:07:02 +0800 Subject: [PATCH 52/56] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=E8=AE=BE?= =?UTF-8?q?=E7=BD=AE=E9=A1=B5=E5=87=AD=E6=8D=AE=E6=A0=A1=E9=AA=8C=E5=B9=B6?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E8=BF=9E=E7=BB=AD=E5=A4=8D=E7=94=A8=E5=85=B3?= =?UTF-8?q?=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../desktop/src/pages/api-key-page.tsx | 9 +- App/frontend/desktop/src/pages/model-page.tsx | 10 +- .../src/pages/model-workspace-section.tsx | 135 ++++++----- .../pages/tests/api-key-page-source.test.ts | 1 - ...redential-consistency.interaction.test.tsx | 219 +++++++++++++++++- ...del-workspace-section.interaction.test.tsx | 4 + .../src/pages/tests/settings-page.test.tsx | 6 +- 7 files changed, 311 insertions(+), 73 deletions(-) diff --git a/App/frontend/desktop/src/pages/api-key-page.tsx b/App/frontend/desktop/src/pages/api-key-page.tsx index 7d32b640d..646ddd12a 100644 --- a/App/frontend/desktop/src/pages/api-key-page.tsx +++ b/App/frontend/desktop/src/pages/api-key-page.tsx @@ -200,6 +200,10 @@ export function ApiKeyPage() { if (savedCatalogSignatureRef.current !== saveSignature) { const latest = await clients.config.getModelConfig(); let workspace = createModelWorkspace(latest); + // Preserve explicit follow routing when rotating the primary credential. + // Independent role assignments must keep their existing endpoint and key. + const evolutionFollows = latest.memmyMemory?.evolution?.mode === "follow"; + const summaryFollowsPrimary = evolutionFollows && latest.memmyMemory?.summary?.mode === "follow"; const agent = upsertByokPreset(workspace, { provider, ...(modelFormValues.endpointId ? { endpointId: modelFormValues.endpointId } : {}), @@ -208,9 +212,12 @@ export function ApiKeyPage() { ...(apiKey.trim() ? { apiKey: apiKey.trim() } : {}), ...(modelFormValues.apiKeyMasked ? { apiKeyMasked: modelFormValues.apiKeyMasked } : {}), model, - capabilities: ["agent"] + capabilities: ["agent", ...(evolutionFollows ? ["memory_evolution" as const] : []), + ...(summaryFollowsPrimary ? ["memory_summary" as const] : [])] }); workspace = assignCatalogPreset(agent.workspace, "byok", "agent", agent.presetId); + if (evolutionFollows) workspace = assignCatalogPreset(workspace, "byok", "memory_evolution", agent.presetId); + if (summaryFollowsPrimary) workspace = assignCatalogPreset(workspace, "byok", "memory_summary", agent.presetId); const savedEmbeddingIdentity = savedEndpointIdentitiesRef.current.embedding; if (embeddingMode === "custom") { const embedding = upsertByokPreset(workspace, { diff --git a/App/frontend/desktop/src/pages/model-page.tsx b/App/frontend/desktop/src/pages/model-page.tsx index 2d4caa53b..a6e0ec67e 100644 --- a/App/frontend/desktop/src/pages/model-page.tsx +++ b/App/frontend/desktop/src/pages/model-page.tsx @@ -1,6 +1,6 @@ /** Model page module. */ import { bindSavedValidation, chatProtocol, endpointCredentialSignature } from "./model-credentials.js"; -import { useState, type ReactNode } from "react"; +import { useRef, useState, type ReactNode } from "react"; import { Brain, Check, CheckCircle2, ChevronLeft, Cog, Eye, EyeOff, Lightbulb, Loader2, XCircle } from "lucide-react"; import { useApiClients } from "../app/providers.js"; import { useAnalytics } from "../analytics/use-analytics.js"; @@ -109,6 +109,7 @@ export function ModelPage() { )); const [savePending, setSavePending] = useState(false); const [saveFeedback, setSaveFeedback] = useState<{ text: string; tone: "error" | "success" } | null>(null); + const credentialRevision = useRef(0); const skillValues = createModelFormValues(skill, primaryModel); const evolutionModel = modelFormValuesAsPrimary(skillValues); const memoryValues = createModelFormValues(mem, evolutionModel); @@ -116,11 +117,13 @@ export function ModelPage() { /** Handles patch mem. */ function patchMem(patch: Partial) { + if (!patch.validation) credentialRevision.current += 1; setMem((current) => patchModelConfig(current, patch)); } /** Handles patch skill. */ function patchSkill(patch: Partial) { + if (!patch.validation) credentialRevision.current += 1; setSkill((current) => patchModelConfig(current, patch)); } @@ -132,10 +135,13 @@ export function ModelPage() { secretTarget: "memory" | "skill" ) { const values = createModelFormValues(config, inheritedModel); + const revision = credentialRevision.current; testModelConnection({ configClient: clients?.config, values, - setValidation: (validation) => patch({ validation }), + setValidation: (validation) => { + if (credentialRevision.current === revision) patch({ validation }); + }, secretTarget, messages: createTestModelConnectionMessages(t) }); diff --git a/App/frontend/desktop/src/pages/model-workspace-section.tsx b/App/frontend/desktop/src/pages/model-workspace-section.tsx index f6a81a738..964245ee2 100644 --- a/App/frontend/desktop/src/pages/model-workspace-section.tsx +++ b/App/frontend/desktop/src/pages/model-workspace-section.tsx @@ -4,7 +4,7 @@ import { MODEL_NAME_MAX_LENGTH, type ModelEndpointProtocol } from "@memmy/local-api-contracts"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react"; import type { ConfigClient, ModelProviderConfig } from "../api/config-client.js"; import { Button } from "../components/button.js"; import { ConfirmDialog } from "../components/confirm-dialog.js"; @@ -52,6 +52,8 @@ import { type StoredConnectionTestStatus } from "./model-workspace-connection-test-state.js"; +import { endpointCredentialSignature, resolveModelCredentials } from "./model-credentials.js"; + type TestStatus = "idle" | "testing" | "success" | "error"; export type ModelKind = "text" | "embedding" | "asr" | "image"; @@ -113,8 +115,17 @@ export function ModelWorkspaceSection(props: ModelWorkspaceSectionProps) { const [workspace, setWorkspace] = useState(() => createModelWorkspace(props.seedConfig)); const [modelsExpanded, setModelsExpanded] = useState(true); const [taskPickerOpen, setTaskPickerOpen] = useState(false); - const [editor, setEditor] = useState(null); + const [editor, setEditorState] = useState(null); + const editorRevision = useRef(0); + const editorRequest = useRef(0); const [editorTest, setEditorTest] = useState({ status: "idle", message: null }); + // Every edit (including closing/reopening) invalidates pending responses, even + // when the user later restores the same field values. + const setEditor: Dispatch> = useCallback((update) => { + editorRevision.current += 1; + setEditorState(update); + setEditorTest({ status: "idle", message: null }); + }, []); const [showEditorApiKey, setShowEditorApiKey] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [formError, setFormError] = useState(null); @@ -272,9 +283,6 @@ export function ModelWorkspaceSection(props: ModelWorkspaceSectionProps) { const provider = protocolFromConnection(connection.provider); const savedTest = connectionTestState(connection, testStates); setFormError(null); - setEditorTest(savedTest - ? { status: savedTest, message: connectionTestMessage(savedTest, t) } - : { status: "idle", message: null }); setShowEditorApiKey(false); setEditor({ connectionId: connection.id, @@ -291,6 +299,9 @@ export function ModelWorkspaceSection(props: ModelWorkspaceSectionProps) { addingModel: false, editingModelIndex: null }); + setEditorTest(savedTest + ? { status: savedTest, message: connectionTestMessage(savedTest, t) } + : { status: "idle", message: null }); } function resolveEditorModels() { @@ -333,24 +344,15 @@ export function ModelWorkspaceSection(props: ModelWorkspaceSectionProps) { setFormError(resolved.error); return; } - const existing = editor.connectionId - ? space.connections.find((connection) => connection.id === editor.connectionId) - : undefined; - const providerChanged = Boolean( - existing && protocolFromConnection(existing.provider) !== editor.provider - ); - const capabilities = resolved.models.flatMap((model) => model.capabilities); + const credentials = editorCredentials; + if (!canSaveConnection || !credentials) return; const result = upsertModelConnection(workspace, props.mode, { id: editor.connectionId ?? undefined, - provider: editor.provider, - endpoint: editor.endpoint, - protocol: editorProtocolForCapabilities( - editor.provider, - capabilities, - providerChanged ? undefined : existing?.protocol - ), - apiKey: editor.apiKey || undefined, - apiKeyMasked: providerChanged ? undefined : existing?.apiKeyMasked, + provider: credentials.provider, + endpoint: credentials.endpoint, + protocol: credentials.protocol, + apiKey: credentials.apiKey || undefined, + apiKeyMasked: credentials.apiKeyMasked || undefined, models: resolved.models.map((model) => model.name), modelEntries: resolved.models.map((model) => ({ ...(model.presetId ? { presetId: model.presetId } : {}), @@ -551,39 +553,31 @@ export function ModelWorkspaceSection(props: ModelWorkspaceSectionProps) { setEditorTest({ status: "error", message: t("settings.modelWorkspace.testNoModel") }); return; } - const existing = editor.connectionId - ? space.connections.find((connection) => connection.id === editor.connectionId) - : undefined; - const providerChanged = Boolean( - existing && protocolFromConnection(existing.provider) !== editor.provider - ); - if (!editor.apiKey.trim() && (!existing?.apiKeyMasked || providerChanged)) { - setEditorTest({ status: "error", message: t("settings.modelWorkspace.testKeyRequired") }); + const credentials = editorCredentials; + if (!credentials || (!credentials.apiKey && !credentials.hasExistingApiKey)) { + setEditorTest({ status: "error", message: t(credentials?.requiresApiKey + ? "apiKey.credentialsChanged" : "settings.modelWorkspace.testKeyRequired") }); return; } + const revision = editorRevision.current; + const request = ++editorRequest.current; + const isCurrent = () => revision === editorRevision.current && request === editorRequest.current; setEditorTest({ status: "testing", message: t("settings.modelWorkspace.testing") }); try { const result = props.configClient ? await props.configClient.testModelConfig({ - provider: fromProtocol(editor.provider), - endpointId: existing?.endpointId ?? editor.connectionId ?? "connection-test-new", - protocol: editorProtocolForCapabilities( - editor.provider, - selectedModel.capabilities, - providerChanged ? undefined : existing?.protocol - ), - endpoint: editor.endpoint, - model: selectedModel.name, - apiKey: editor.apiKey, - apiKeyMasked: providerChanged ? "" : existing?.apiKeyMasked ?? "", - configured: true - }, testCapability(selectedModel.capabilities[0]!), testSecretTarget(selectedModel.capabilities[0]!)) + ...credentials, + apiKeyMasked: credentials.apiKeyMasked ?? "", + configured: true + }, testCapability(selectedModel.capabilities[0]!), testSecretTarget(selectedModel.capabilities[0]!)) : await simulateConnectionTest(); + if (!isCurrent()) return; setEditorTest({ status: result.ok ? "success" : "error", message: result.ok ? t("settings.modelWorkspace.testSuccess") : t("settings.modelWorkspace.testFailed") }); } catch { + if (!isCurrent()) return; setEditorTest({ status: "error", message: t("settings.modelWorkspace.testFailed") }); } } @@ -683,21 +677,36 @@ export function ModelWorkspaceSection(props: ModelWorkspaceSectionProps) { const editorExistingConnection = editor?.connectionId ? space.connections.find((connection) => connection.id === editor.connectionId) : undefined; - const editorOriginalProvider = editorExistingConnection - ? protocolFromConnection(editorExistingConnection.provider) - : null; - const editorProviderChanged = Boolean( - editor && editorOriginalProvider && editor.provider !== editorOriginalProvider - ); - const editorHasUsableKey = Boolean( - editor?.apiKey.trim() - || (editorExistingConnection?.apiKeyMasked && !editorProviderChanged) - ); + const editorModels = resolveEditorModels().models; + const editorSelectedModel = editorModels.find((model) => model.capabilities.includes("chat")) ?? editorModels[0]; + const editorProviderChanged = Boolean(editorExistingConnection && editor + && protocolFromConnection(editorExistingConnection.provider) !== editor.provider); + const editorCredentials = editor ? resolveModelCredentials({ + provider: fromProtocol(editor.provider), + protocol: editorProtocolForCapabilities(editor.provider, + editorModels.flatMap((model) => model.capabilities), + editorProviderChanged ? undefined : editorExistingConnection?.protocol), + endpoint: editor.endpoint, + model: editorSelectedModel?.name ?? "", + apiKey: editor.apiKey, + apiKeyMasked: editorExistingConnection?.apiKeyMasked ?? "" + }, editorExistingConnection ? { + endpointId: editorExistingConnection.endpointId, + credentialSignature: endpointCredentialSignature(editorExistingConnection.provider, + editorExistingConnection.protocol, editorExistingConnection.endpoint, "", editorExistingConnection.apiKeyMasked) + } : null) : null; + const editorHasUsableKey = Boolean(editorCredentials?.apiKey || editorCredentials?.hasExistingApiKey); + const editorDetailsChanged = Boolean(editorExistingConnection && editorCredentials && ( + editorCredentials.apiKey || !editorCredentials.hasExistingApiKey + || JSON.stringify(editorModels.map((model) => [model.name, [...model.capabilities].sort()])) !== + JSON.stringify(editorExistingConnection.modelEntries.map((model) => [model.model, + normalizeEditorCapabilities(model.capabilities.map(fromCatalogCapability)).sort()])) + )); const canSaveConnection = Boolean( - editor - && editor.endpoint.trim() - && editorHasUsableKey - && (editor.models.length > 0 || (editor.addingModel && editor.modelDraft.trim())) + editor && editorCredentials?.endpoint && editorHasUsableKey + && editorModels.length && !resolveEditorModels().error + && editorTest.status !== "error" + && (!editorDetailsChanged || editorTest.status === "success") ); const selectedTaskModelsText = taskCandidates.length > 0 ? taskCandidates.map((candidate) => candidate.model).join("、") @@ -1096,7 +1105,7 @@ export function ModelWorkspaceSection(props: ModelWorkspaceSectionProps) { placeholder={DEFAULT_ENDPOINTS[editor.provider]} /> current ? { ...current, apiKey: value } : current); setEditorTest({ status: "idle", message: null }); }} - maskedValue={editor.connectionId && !editorProviderChanged - ? editorExistingConnection?.apiKeyMasked - : undefined} - placeholder={editor.connectionId && !editorProviderChanged + maskedValue={editorCredentials?.apiKeyMasked} + placeholder={editorCredentials?.hasExistingApiKey ? t("settings.modelWorkspace.replaceKeyPlaceholder") : "sk-..."} showPassword={showEditorApiKey} onTogglePassword={() => setShowEditorApiKey((show) => !show)} /> + {editorCredentials?.requiresApiKey && ( +

{t("apiKey.credentialsChanged")}

+ )} + {editorDetailsChanged && editorHasUsableKey && editorTest.status === "idle" && ( +

{t("apiKey.testStale")}

+ )}
diff --git a/App/frontend/desktop/src/pages/tests/api-key-page-source.test.ts b/App/frontend/desktop/src/pages/tests/api-key-page-source.test.ts index 13adc501b..52eab39bc 100644 --- a/App/frontend/desktop/src/pages/tests/api-key-page-source.test.ts +++ b/App/frontend/desktop/src/pages/tests/api-key-page-source.test.ts @@ -46,7 +46,6 @@ describe("ApiKeyPage source", () => { expect(source).not.toContain('t("apiKey.dailyLimit")'); expect(source).toContain('dispatch(appActions.navigate("/api-key-models"))'); expect(source).toContain("clients.config.getModelConfig()"); - expect(source).toContain('capabilities: ["agent"]'); expect(source).toContain('protocol: "openai-embeddings"'); expect(source).toContain('capabilities: ["embedding"]'); expect(source).toContain('assignCatalogPreset(agent.workspace, "byok", "agent"'); diff --git a/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx index 39619a7ad..ec8264535 100644 --- a/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx @@ -15,6 +15,7 @@ import { createInitialAppState, type AppState } from "../../state/app-reducer.js import { assignedCatalogEndpointId, assignCatalogPreset, createModelWorkspace, modelConfigInput, upsertByokPreset } from "../../state/model-workspace.js"; import { ApiKeyPage } from "../api-key-page.js"; import { ApiKeyOptionalPage } from "../api-key-optional-page.js"; +import { ModelWorkspaceSection } from "../model-workspace-section.js"; import { ModelPage } from "../model-page.js"; const mocks = vi.hoisted(() => ({ clients: null as AppClients | null, state: null as AppState | null, dispatch: vi.fn() })); @@ -28,9 +29,10 @@ vi.mock("../../i18n/use-translation.js", () => ({ useTranslation: () => ({ t: (k // Only transport and the external model provider are replaced; no real keys or network calls. async function createServer(directory: string, independent = true) { const writer = createMemmyConfigWriter({ configPath: join(directory, "config.yaml") }); - const probe = vi.fn(async (_input: ModelConfigTestInput & { apiKey: string }) => ({ - ok: true, message: "ok", checkedAt: "2026-10-16T00:00:00.000Z" - })); + const probe = vi.fn(async (input: ModelConfigTestInput & { apiKey: string }) => { + void input; + return { ok: true, message: "ok", checkedAt: "2026-10-16T00:00:00.000Z" }; + }); const service = createAppConfigService({ bootstrapRepository: {} as CreateAppConfigServiceOptions["bootstrapRepository"], memmyConfigWriter: writer, @@ -73,8 +75,9 @@ async function createServer(directory: string, independent = true) { }).workspace; await client.saveModelCatalog(modelConfigInput(workspace)); write.mockClear(); + const saveCatalog = vi.spyOn(client, "saveModelCatalog"); return { - client, probe, write, writer, + client, probe, write, writer, saveCatalog, read: () => client.getModelConfig(), async key(capability: ModelCapability) { const config = await client.getModelConfig(); @@ -114,6 +117,9 @@ describe("BYOK displayed, tested and saved credentials", () => { mocks.clients = { config: server.client } as AppClients; }); afterEach(async () => { + await act(async () => { + await Promise.allSettled(server.saveCatalog.mock.results.map((result) => result.value)); + }); act(() => root.unmount()); document.body.replaceChildren(); vi.unstubAllGlobals(); @@ -348,4 +354,209 @@ describe("BYOK displayed, tested and saved credentials", () => { expect((await server.read()).catalog!.providers.flatMap((provider) => provider.endpoints)).toHaveLength(endpointsAfterCreate); }); + async function openSettings(role: ModelCapability) { + const config = await server.read(); + const workspace = createModelWorkspace(config); + const id = assignedCatalogEndpointId(workspace, "byok", role); + const index = workspace.spaces.byok.connections.findIndex((connection) => connection.endpointId === id); + await render(); + await click(container.querySelectorAll('[aria-label="settings.modelWorkspace.editConnection"]')[index]!); + } + function settingsSave() { return document.querySelector('[aria-label="common.save"]')!; } + function settingsTest() { return [...document.querySelectorAll("button")].find((button) => + button.textContent === "settings.modelWorkspace.test" || button.textContent === "apiKey.testSuccess" || button.textContent === "apiKey.testRetry")!; } + function modalField(label: string) { + return [...document.querySelectorAll("label")].find((node) => node.textContent === label)!.parentElement!.querySelector("input")!; + } + + it.each(scenarios.slice(0, 4))("settings $role: URL edit blocks the saved key; retest and save preserve other connections", async (scenario) => { + const before = await server.read(); + const oldId = assignedCatalogEndpointId(createModelWorkspace(before), "byok", scenario.role)!; + const others = before.catalog!.providers.flatMap((p) => p.endpoints).filter((e) => e.endpointId !== oldId); + await openSettings(scenario.role); + const beforeTest1 = server.probe.mock.calls.length; + await click(settingsTest()); + await vi.waitFor(() => expect(server.probe.mock.calls.length).toBeGreaterThan(beforeTest1)); + await vi.waitFor(() => expect(document.body.textContent).toContain("settings.modelWorkspace.testSuccess")); + expect(server.probe.mock.calls.at(-1)![0].apiKey).toBe(scenario.expected); + await change(modalField("apiKey.endpoint"), "https://changed.example/v2"); + expect(document.body.textContent).toContain("apiKey.credentialsChanged"); + expect(modalField("apiKey.key").placeholder).toBe("sk-..."); + expect(settingsSave().disabled).toBe(true); + const calls = server.probe.mock.calls.length; + await click(settingsTest()); + await click(settingsSave()); + expect(server.probe).toHaveBeenCalledTimes(calls); + expect(server.write).not.toHaveBeenCalled(); + await change(modalField("apiKey.key"), ` ${scenario.expected} `); + expect(settingsSave().disabled).toBe(true); + const beforeTest2 = server.probe.mock.calls.length; + await click(settingsTest()); + await vi.waitFor(() => expect(server.probe.mock.calls.length).toBeGreaterThan(beforeTest2)); + await vi.waitFor(() => expect(document.body.textContent).toContain("settings.modelWorkspace.testSuccess")); + expect(server.probe.mock.calls.at(-1)![0]).toMatchObject({ apiKey: scenario.expected, apiBase: "https://changed.example/v2" }); + expect(settingsSave().disabled).toBe(false); + await click(settingsSave()); + await vi.waitFor(() => expect(server.write).toHaveBeenCalled()); + await vi.waitFor(async () => { + const config = await server.read(); + expect(config.catalog!.providers.flatMap((p) => p.endpoints).find((e) => e.endpointId === oldId)?.apiBase).toBe("https://changed.example/v2"); + }); + expect(await server.key(scenario.role)).toBe(scenario.expected); + const saved = await server.read(); + expect(saved.catalog!.providers.flatMap((p) => p.endpoints).filter((e) => e.endpointId !== oldId)).toEqual(others); + await render(null); + await openSettings(scenario.role); + expect(modalField("settings.modelWorkspace.replaceKey").value).toBe(""); + const beforeTest3 = server.probe.mock.calls.length; + await click(settingsTest()); + await vi.waitFor(() => expect(server.probe.mock.calls.length).toBeGreaterThan(beforeTest3)); + await vi.waitFor(() => expect(document.body.textContent).toContain("settings.modelWorkspace.testSuccess")); + expect(server.probe.mock.calls.at(-1)![0]).toMatchObject({ apiKey: scenario.expected, apiBase: "https://changed.example/v2" }); + }); + + it("settings: normalized URL retains the key; a model-only edit requires retesting", async () => { + await openSettings("agent"); + await click(settingsTest()); + await change(modalField("apiKey.endpoint"), " https://provider.example/v1/// "); + expect(modalField("settings.modelWorkspace.replaceKey").placeholder).toContain("1111"); + await click(document.querySelector('[aria-label="settings.modelWorkspace.editModel"]')!); + await change(modalField("apiKey.model"), "changed-model"); + expect(settingsSave().disabled).toBe(true); + expect(document.body.textContent).toContain("apiKey.testStale"); + const beforeTest4 = server.probe.mock.calls.length; + await click(settingsTest()); + await vi.waitFor(() => expect(server.probe.mock.calls.length).toBeGreaterThan(beforeTest4)); + await vi.waitFor(() => expect(document.body.textContent).toContain("settings.modelWorkspace.testSuccess")); + expect(server.probe.mock.calls.at(-1)![0]).toMatchObject({ apiKey: "sk-personal-1111", modelId: "changed-model", apiBase: "https://provider.example/v1" }); + await click(settingsSave()); + await vi.waitFor(() => expect(server.write).toHaveBeenCalled()); + expect(await server.key("agent")).toBe("sk-personal-1111"); + }); + + it.each(["address", "key", "model", "close"])("settings: a late success after %s changes cannot validate the editor", async (edit) => { + await openSettings("agent"); + let finish!: (result: Awaited>) => void; + server.probe.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + await click(settingsTest()); + await vi.waitFor(() => expect(finish).toBeTypeOf("function")); + if (edit === "address") await change(modalField("apiKey.endpoint"), "https://changed.example/v2"); + if (edit === "key") await change(modalField("settings.modelWorkspace.replaceKey"), "sk-new-1111"); + if (edit === "model") { + await click(document.querySelector('[aria-label="settings.modelWorkspace.editModel"]')!); + await change(modalField("apiKey.model"), "changed-model"); + } + if (edit === "close") { + await click(document.querySelector('[aria-label="common.close"]')!); + await openSettings("agent"); + } + await act(async () => finish({ ok: true, message: "ok", checkedAt: "2026-10-16T00:00:00.000Z" })); + expect(document.body.textContent).not.toContain("settings.modelWorkspace.testSuccess"); + if (edit !== "close") expect(settingsSave().disabled).toBe(true); + expect(server.write).not.toHaveBeenCalled(); + }); + + it.each(["model", "key", "reuse"])("roles: a late success after %s changes cannot validate the current credential", async (edit) => { + let finish!: (result: Awaited>) => void; + server.probe.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + await render(); + await click(test(1)); + await vi.waitFor(() => expect(finish).toBeTypeOf("function")); + if (edit === "model") await change(field("apiKey.model", 1), "changed-model"); + if (edit === "key") await change(field("API Key", 1), "sk-new-team-1111"); + if (edit === "reuse") { + const checkbox = () => container.querySelectorAll('button[role="checkbox"]')[1]!; + await click(checkbox()); + await click(checkbox()); + } + await act(async () => finish({ ok: true, message: "ok", checkedAt: "2026-10-16T00:00:00.000Z" })); + expect(test(1).textContent).not.toBe("apiKey.testSuccess"); + expect(next().disabled).toBe(true); + expect(server.write).not.toHaveBeenCalled(); + }); + + it("follow chain: primary rotation flows through evolution and summary, while a fixed role keeps its own key", async () => { + await render(); + await click(container.querySelectorAll('button[role="checkbox"]')[0]!); + await click(container.querySelectorAll('button[role="checkbox"]')[1]!); + await click(next()); + await vi.waitFor(() => expect(mocks.dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: "navigation/changed" }))); + expect(await server.key("memory_evolution")).toBe("sk-personal-1111"); + expect(await server.key("memory_summary")).toBe("sk-personal-1111"); + await render(null); + mocks.state!.modelConfig = await server.read(); + mocks.dispatch.mockClear(); + await render(); + await change(field("apiKey.key"), "sk-rotated-9999"); + await testAndSave(scenarios[0]!); + await render(null); + mocks.state!.modelConfig = await server.read(); + await render(); + const boxes = () => container.querySelectorAll('button[role="checkbox"]'); + expect([...boxes()].map((b) => b.getAttribute("aria-checked"))).toEqual(["true", "true"]); + await click(boxes()[1]!); // Copy the new primary into an independent evolution configuration. + expect(field("API Key").placeholder).toContain("9999"); + await change(field("API Key"), "sk-chain-team-7777"); + expect(next().disabled).toBe(true); + await testAndSave({ ...scenarios[4]!, index: 0, fieldIndex: 0 }); + expect(await server.key("agent")).toBe("sk-rotated-9999"); + expect(await server.key("memory_evolution")).toBe("sk-chain-team-7777"); + expect(await server.key("memory_summary")).toBe("sk-chain-team-7777"); + await render(null); + mocks.state!.modelConfig = await server.read(); + await render(); + expect([...boxes()].map((b) => b.getAttribute("aria-checked"))).toEqual(["true", "false"]); + await click(boxes()[0]!); + expect(field("API Key").placeholder).toContain("7777"); + expect((await testAndSave(scenarios[5]!)).apiKey).toBe("sk-chain-team-7777"); + expect(await server.key("agent")).toBe("sk-rotated-9999"); + }); + + it("settings: changing provider cannot reuse a masked key", async () => { + await openSettings("agent"); + await click(document.querySelector('.model-connection-modal [role="combobox"]')!); + await click([...document.querySelectorAll('[role="option"]')].find((b) => b.getAttribute("aria-selected") === "false")!); + await change(modalField("apiKey.endpoint"), "https://provider.example/v1"); + expect(modalField("apiKey.key").placeholder).toBe("sk-..."); + await click(settingsTest()); + expect(server.probe).not.toHaveBeenCalled(); + expect(settingsSave().disabled).toBe(true); + }); + + it("shared endpoint: rotating the primary leaves fixed evolution and summary on their old saved key", async () => { + const before = await server.read(); + const oldId = assignedCatalogEndpointId(createModelWorkspace(before), "byok", "agent")!; + let workspace = createModelWorkspace(before); + const shared = upsertByokPreset(workspace, { provider: before.provider, endpointId: oldId, + endpoint: before.endpoint, protocol: "openai-chat-completions", model: "fixed-role-model", + apiKeyMasked: before.apiKeyMasked, capabilities: ["memory_evolution", "memory_summary"] }); + workspace = assignCatalogPreset(shared.workspace, "byok", "memory_evolution", shared.presetId); + workspace = assignCatalogPreset(workspace, "byok", "memory_summary", shared.presetId); + await server.client.saveModelCatalog(modelConfigInput(workspace)); + mocks.state!.modelConfig = await server.read(); + await render(); + await change(field("apiKey.key"), "sk-new-primary-8888"); + await testAndSave(scenarios[0]!); + expect(await server.key("agent")).toBe("sk-new-primary-8888"); + expect(await server.key("memory_evolution")).toBe("sk-personal-1111"); + expect(await server.key("memory_summary")).toBe("sk-personal-1111"); + await render(null); + mocks.state!.modelConfig = await server.read(); + await render(); + await click(test(0)); + await vi.waitFor(() => expect(server.probe.mock.calls.at(-1)![0].apiKey).toBe("sk-personal-1111")); + expect(assignedCatalogEndpointId(createModelWorkspace(await server.read()), "byok", "memory_evolution")).toBe(oldId); + }); + + it("settings: protocol-only change on the same provider and address requires a key", async () => { + await openSettings("agent"); + await click(document.querySelector('[aria-label="settings.modelWorkspace.editModel"]')!); + await click(document.querySelector('.model-capability-select [role="combobox"], button.model-capability-select')!); + await click([...document.querySelectorAll('[role="option"]')].find((b) => b.textContent?.includes("capability.embedding"))!); + expect(modalField("apiKey.key").placeholder).toBe("sk-..."); + await click(settingsTest()); + expect(server.probe).not.toHaveBeenCalled(); + expect(settingsSave().disabled).toBe(true); + }); + }); 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..f6fa13e09 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 @@ -121,6 +121,10 @@ describe("ModelWorkspaceSection BYOK connection deletion", () => { expect(container.querySelector(".model-capability-select")?.textContent).toContain("Embedding"); act(() => container.querySelector('button[aria-label="添加模型"]')!.click()); + expect(container.querySelector('button[aria-label="保存"]')!.disabled).toBe(true); + await act(async () => { + [...container.querySelectorAll("button")].find((button) => button.textContent === "测试")!.click(); + }); act(() => container.querySelector('button[aria-label="保存"]')!.click()); await vi.waitFor(() => expect(configClient.saveModelCatalog).toHaveBeenCalledTimes(1)); 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..afea23194 100644 --- a/App/frontend/desktop/src/pages/tests/settings-page.test.tsx +++ b/App/frontend/desktop/src/pages/tests/settings-page.test.tsx @@ -904,12 +904,10 @@ describe("SettingsPageView", () => { }); it("模型工作区连接编辑展示已保存脱敏 key,且保存不回传脱敏值", () => { - const workspaceSource = readFileSync(fileURLToPath(new URL("../model-workspace-section.tsx", import.meta.url)), "utf8"); const fieldsSource = readFileSync(fileURLToPath(new URL("../api-key-form-fields.tsx", import.meta.url)), "utf8"); - expect(workspaceSource).toContain("maskedValue={editor.connectionId && !editorProviderChanged"); - expect(workspaceSource).toContain("apiKey: editor.apiKey || undefined"); - expect(workspaceSource).toContain("apiKeyMasked:"); + // Credential display/test/save behavior is covered with the real catalog + // writer in byok-credential-consistency.interaction.test.tsx. expect(fieldsSource).toContain("const placeholder = !props.value.trim() && props.maskedValue ? props.maskedValue : props.placeholder;"); expect(fieldsSource).toContain("placeholder={placeholder}"); expect(fieldsSource).not.toContain("const showSavedSecret = !props.value.trim() && Boolean(props.maskedValue)"); From 356a779f1a73330127cf2bd7f2ae2fc004c958ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Fri, 16 Oct 2026 16:24:17 +0800 Subject: [PATCH 53/56] fix: find task spans by their own ownership links --- Memory/src/service/memory-service.ts | 14 +-- Memory/src/storage/repositories.ts | 16 +++ .../service/evolution/span-big-turn.test.ts | 119 ++++++++++++++++++ 3 files changed, 138 insertions(+), 11 deletions(-) diff --git a/Memory/src/service/memory-service.ts b/Memory/src/service/memory-service.ts index 273557f9a..7e389f70e 100644 --- a/Memory/src/service/memory-service.ts +++ b/Memory/src/service/memory-service.ts @@ -2009,18 +2009,10 @@ export class MemoryService { const deletedMemoryIds: string[] = []; this.repos.transaction(() => { - const spanIds = new Set(); - for (const memoryId of episode.l1MemoryIds) { - const memory = this.repos.memories.get(memoryId); - const trace = memory?.properties.internal_info.trace; - if (!isRecord(trace) || !Array.isArray(trace.span_ids)) continue; - for (const spanId of trace.span_ids) { - if (typeof spanId === "string") spanIds.add(spanId); - } - } + const spanIds = new Set(this.repos.memories.listTaskSpanIds( + episode.userId, episode.id, episode.l1MemoryIds + )); for (const spanId of spanIds) { - const span = this.repos.memories.get(spanId); - if (!span || kindFromMemory(span) !== "span") continue; this.deleteMemory(spanId, request); deletedMemoryIds.push(spanId); } diff --git a/Memory/src/storage/repositories.ts b/Memory/src/storage/repositories.ts index dee1bb81f..c973256cf 100644 --- a/Memory/src/storage/repositories.ts +++ b/Memory/src/storage/repositories.ts @@ -694,6 +694,22 @@ export class MemoryRepository { return row ? this.hydrate(memoryFromSql(row)) : undefined; } + listTaskSpanIds(userId: string, episodeId: string, sourceTraceIds: string[]): string[] { + // Parent traces may already be soft-deleted, and their span_ids only describe + // the latest split. Use the spans' own ownership links for task deletion. + const rows = this.db.prepare( + `SELECT id FROM memories + WHERE user_id = ? AND memory_layer = 'L1' + AND deleted_at IS NULL AND status != 'deleted' + AND json_extract(properties_json, '$.internal_info.memory_kind') = 'span' + AND json_extract(properties_json, '$.internal_info.span.episode_id') = ? + AND json_extract(properties_json, '$.internal_info.span.source_trace_id') + IN (SELECT value FROM json_each(?)) + ORDER BY id` + ).all(userId, episodeId, JSON.stringify(sourceTraceIds)) as Array<{ id: string }>; + return rows.map((row) => row.id); + } + getByKey(memoryLayer: MemoryLayer, key: string): MemoryRow | undefined { const row = this.db .prepare( diff --git a/Memory/tests/service/evolution/span-big-turn.test.ts b/Memory/tests/service/evolution/span-big-turn.test.ts index 7b59a5a28..270669764 100644 --- a/Memory/tests/service/evolution/span-big-turn.test.ts +++ b/Memory/tests/service/evolution/span-big-turn.test.ts @@ -333,6 +333,125 @@ describe("MemoryService / evolution / span big turn", () => { db.close(); }); + it.each([ + { name: "after the parent trace was deleted", deleteParent: true, splitAgain: false }, + { name: "after a second successful split with different ranges", deleteParent: false, splitAgain: true }, + { name: "after both re-splitting and deleting the parent", deleteParent: true, splitAgain: true } + ])("deletes all task spans $name", async ({ deleteParent, splitAgain }) => { + const calls: Array<{ messages: LlmMessage[]; options: LlmCompletionOptions }> = []; + const firstSplit = { + spans: [ + { start: 0, end: 3, spanGoal: "分析构建问题", summary: "检查构建日志" }, + { start: 4, end: 7, spanGoal: "修改构建配置", summary: "修复依赖冲突" }, + { start: 8, end: 10, spanGoal: "验证构建结果", summary: "执行测试验证" } + ] + }; + const secondSplit = { + spans: [ + { start: 0, end: 6, spanGoal: "分析并修改构建配置", summary: "定位并修复问题" }, + { start: 7, end: 10, spanGoal: "重新验证构建结果", summary: "完成测试验证" } + ] + }; + const llm = createSpanBigTurnLlm(calls, [firstSplit, secondSplit, firstSplit]); + const config = { + ...DEFAULT_MEMMY_CONFIG, + algorithm: { + ...DEFAULT_MEMMY_CONFIG.algorithm, + retrieval: { + ...DEFAULT_MEMMY_CONFIG.algorithm.retrieval, + relativeThresholdFloor: 0, + smartSeed: false, + llmFilterEnabled: false + } + } + }; + const { db, service } = createTestService({ config, llm, skillLlm: llm }); + const namespace = { source: "codex", profileId: "cascade", userId: "cascade-review" }; + function createTask(requestId: string) { + const session = service.openSession({ namespace }); + const toolCalls = Array.from({ length: 11 }, (_, index) => ({ + id: `${requestId}-${index}`, name: "task_step", input: { index } + })); + const completed = service.completeTurn(requestId, { + namespace, sessionId: session.sessionId, + query: "分析构建问题、修改配置并验证结果", + answer: "已完成分析、修复和验证。", + toolCalls, + toolResults: toolCalls.map((call) => ({ + toolCallId: call.id, name: call.name, output: { ok: true } + })) + }); + service.closeSession(session.sessionId); + return { ...completed, sessionId: session.sessionId }; + } + async function reward(task: ReturnType) { + await service.feedback({ + namespace, sessionId: task.sessionId, episodeId: task.episodeId, + l1MemoryId: task.l1MemoryId, channel: "explicit", polarity: "positive", + magnitude: 1, rationale: "构建修复正确" + }); + await runWorkerRounds(service, 8); + } + function spanIds(episodeId: string): string[] { + return (db.db.prepare(`SELECT id FROM memories + WHERE json_extract(properties_json, '$.internal_info.memory_kind') = 'span' + AND json_extract(properties_json, '$.internal_info.span.episode_id') = ? + AND deleted_at IS NULL`).all(episodeId) as Array<{ id: string }>).map((row) => row.id); + } + const task = createTask("cascade-target"); + await reward(task); + const originalSpans = spanIds(task.episodeId); + expect(originalSpans).toHaveLength(3); + if (splitAgain) { + await reward(task); + expect(calls.filter((call) => call.options.operation === "span.big_turn.v1")).toHaveLength(2); + const parent = db.db.prepare("SELECT properties_json FROM memories WHERE id = ?") + .get(task.l1MemoryId) as { properties_json: string }; + const latestIds = JSON.parse(parent.properties_json).internal_info.trace.span_ids as string[]; + expect(latestIds).toHaveLength(2); + expect(latestIds.every((id) => !originalSpans.includes(id))).toBe(true); + } + const targetIds = spanIds(task.episodeId); + expect(targetIds).toHaveLength(splitAgain ? 5 : 3); + const control = createTask("cascade-control"); + await reward(control); + const controlIds = spanIds(control.episodeId); + expect(controlIds.length).toBeGreaterThan(0); + const controlBefore = db.db.prepare("SELECT * FROM memories WHERE id IN (SELECT value FROM json_each(?))") + .all(JSON.stringify(controlIds)); + const countVectors = (ids: string[]) => (db.db.prepare( + "SELECT count(*) AS count FROM memory_vector_entries WHERE memory_id IN (SELECT value FROM json_each(?))" + ).get(JSON.stringify(ids)) as { count: number }).count; + expect(countVectors(targetIds)).toBeGreaterThan(0); + const controlVectorCount = countVectors(controlIds); + if (deleteParent) service.deleteMemory(task.l1MemoryId!, { namespace }); + + const deleted = service.deletePanelTask(task.episodeId, { namespace }); + expect(new Set(deleted.deletedMemoryIds)).toEqual(new Set([ + ...targetIds, ...(deleteParent ? [] : [task.l1MemoryId]) + ])); + expect(db.db.prepare("SELECT id FROM episodes WHERE id = ?").get(task.episodeId)).toBeUndefined(); + expect(db.db.prepare("SELECT id FROM raw_turns WHERE id = ?").get(task.rawTurnId)).toBeUndefined(); + for (const id of [...targetIds, task.l1MemoryId!]) { + expect(db.db.prepare("SELECT status, deleted_at FROM memories WHERE id = ?").get(id)) + .toEqual({ status: "deleted", deleted_at: expect.any(String) }); + expect(() => service.getMemory(id, { namespace })).toThrow(/not found/i); + expect(db.db.prepare("SELECT count(*) AS count FROM audit_logs WHERE action = 'delete' AND target_id = ?").get(id)) + .toEqual({ count: 1 }); + } + expect(countVectors(targetIds)).toBe(0); + const remaining = service.panelItems({ namespace, layer: "L1" }).items.map((item) => item.id); + expect(targetIds.every((id) => !remaining.includes(id))).toBe(true); + const recalled = await service.search({ namespace, query: "分析构建问题 修改配置 验证结果", layers: ["L1"] }); + expect(recalled.hits.some((hit) => targetIds.includes(hit.id) || hit.id === task.l1MemoryId)).toBe(false); + expect(db.db.prepare("SELECT * FROM memories WHERE id IN (SELECT value FROM json_each(?))") + .all(JSON.stringify(controlIds))).toEqual(controlBefore); + expect(countVectors(controlIds)).toBe(controlVectorCount); + for (const id of controlIds) expect(() => service.getMemory(id, { namespace })).not.toThrow(); + expect(service.panelTasks({ namespace, page: 1 }).tasks.map((item) => item.id)).toContain(control.episodeId); + db.close(); + }); + it("keeps the parent trace only when the complex turn has one coherent goal", async () => { const calls: Array<{ messages: LlmMessage[]; options: LlmCompletionOptions }> = []; const llm = createSpanBigTurnLlm(calls, { From 5a0951fa17a38445165c539072fc13a81246e32a 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, 17 Oct 2026 16:22:17 +0800 Subject: [PATCH 54/56] =?UTF-8?q?fix:=20=E6=81=A2=E5=A4=8D=E8=AE=B0?= =?UTF-8?q?=E5=BF=86=E6=A8=A1=E5=9E=8B=E7=BC=96=E8=BE=91=E5=90=8E=E7=9A=84?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E9=87=8D=E6=B5=8B=E5=B9=B6=E9=9A=94=E7=A6=BB?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E8=AF=B7=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- App/frontend/desktop/src/pages/model-page.tsx | 40 +++++-- ...redential-consistency.interaction.test.tsx | 100 ++++++++++++++++++ 2 files changed, 129 insertions(+), 11 deletions(-) diff --git a/App/frontend/desktop/src/pages/model-page.tsx b/App/frontend/desktop/src/pages/model-page.tsx index a6e0ec67e..508e2020d 100644 --- a/App/frontend/desktop/src/pages/model-page.tsx +++ b/App/frontend/desktop/src/pages/model-page.tsx @@ -109,7 +109,7 @@ export function ModelPage() { )); const [savePending, setSavePending] = useState(false); const [saveFeedback, setSaveFeedback] = useState<{ text: string; tone: "error" | "success" } | null>(null); - const credentialRevision = useRef(0); + const testRevisions = useRef({ skill: 0, memory: 0 }); const skillValues = createModelFormValues(skill, primaryModel); const evolutionModel = modelFormValuesAsPrimary(skillValues); const memoryValues = createModelFormValues(mem, evolutionModel); @@ -117,14 +117,24 @@ export function ModelPage() { /** Handles patch mem. */ function patchMem(patch: Partial) { - if (!patch.validation) credentialRevision.current += 1; - setMem((current) => patchModelConfig(current, patch)); + const changed = modelConnectionChanged(mem, patch, evolutionModel); + if (changed) testRevisions.current.memory += 1; + setMem((current) => patchModelConfig(current, patch, changed ? t("apiKey.testStale") : null)); } /** Handles patch skill. */ function patchSkill(patch: Partial) { - if (!patch.validation) credentialRevision.current += 1; - setSkill((current) => patchModelConfig(current, patch)); + const changed = modelConnectionChanged(skill, patch, primaryModel); + if (changed) { + testRevisions.current.skill += 1; + const nextEvolution = modelFormValuesAsPrimary(createModelFormValues({ ...skill, ...patch }, primaryModel)); + if (mem.reuse && createModelConfigValidationKey(memoryValues) + !== createModelConfigValidationKey(createModelFormValues(mem, nextEvolution))) { + testRevisions.current.memory += 1; + setMem((current) => patchModelConfig(current, {}, t("apiKey.testStale"))); + } + } + setSkill((current) => patchModelConfig(current, patch, changed ? t("apiKey.testStale") : null)); } /** Handles test model config connection. */ @@ -135,12 +145,14 @@ export function ModelPage() { secretTarget: "memory" | "skill" ) { const values = createModelFormValues(config, inheritedModel); - const revision = credentialRevision.current; + // Each card owns its request. A newer request also invalidates an older one + // when the user edits a value and then returns to the original configuration. + const revision = ++testRevisions.current[secretTarget]; testModelConnection({ configClient: clients?.config, values, setValidation: (validation) => { - if (credentialRevision.current === revision) patch({ validation }); + if (testRevisions.current[secretTarget] === revision) patch({ validation }); }, secretTarget, messages: createTestModelConnectionMessages(t) @@ -269,12 +281,18 @@ function modelPageSaveErrorText( return message || t("settings.modelWorkspace.saveFailed"); } -function patchModelConfig(current: ModelConfig, patch: Partial): ModelConfig { +function modelConnectionChanged(current: ModelConfig, patch: Partial, primary: PrimaryModelValues): boolean { + return (patch.reuse !== undefined && patch.reuse !== current.reuse) + || createModelConfigValidationKey(createModelFormValues(current, primary)) + !== createModelConfigValidationKey(createModelFormValues({ ...current, ...patch }, primary)); +} + +function patchModelConfig(current: ModelConfig, patch: Partial, staleMessage: string | null): ModelConfig { return { ...current, ...patch, - ...(patch.reuse !== undefined - ? { validation: { status: "idle" as const, message: null, testedKey: null } } + ...(staleMessage + ? { validation: { status: "idle" as const, message: staleMessage, testedKey: null } } : {}) }; } @@ -508,7 +526,7 @@ function TestButton(props: TestButtonProps) { function ValidationMessage(props: ValidationMessageProps) { const { t } = useTranslation(); - if (props.validation.status === "idle" && !props.stale && !props.credentialRequired) { + if (props.validation.status === "idle" && !props.validation.message && !props.stale && !props.credentialRequired) { return null; } diff --git a/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx index ec8264535..42079a4d0 100644 --- a/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx @@ -473,6 +473,106 @@ describe("BYOK displayed, tested and saved credentials", () => { expect(test(1).textContent).not.toBe("apiKey.testSuccess"); expect(next().disabled).toBe(true); expect(server.write).not.toHaveBeenCalled(); + await testAndSave(scenarios[4]!); + }); + + function deferProbe() { + let finish!: (result: Awaited>) => void; + server.probe.mockImplementationOnce(() => new Promise((resolve) => { finish = resolve; })); + return async (ok = true) => { + await act(async () => finish({ ok, message: ok ? "ok" : "old request failed", checkedAt: "2026-10-17T00:00:00.000Z" })); + }; + } + + it.each([0, 1].flatMap((index) => ["model", "key"].map((edit) => ({ index, edit }))))( + "role $index: editing $edit during a test unlocks retry and allows saving the new configuration", + async ({ index, edit }) => { + const finishOld = deferProbe(); + await render(); + await click(test(index)); + await vi.waitFor(() => expect(server.probe).toHaveBeenCalledTimes(1)); + expect(test(index).disabled).toBe(true); + await change(field(edit === "model" ? "apiKey.model" : "API Key", index), edit === "model" ? "corrected-model" : "sk-corrected-9999"); + expect(test(index).disabled).toBe(false); + expect(test(index).textContent).toBe("apiKey.test"); + expect(next().disabled).toBe(true); + await finishOld(); + expect(test(index).disabled).toBe(false); + expect(test(index).textContent).toBe("apiKey.test"); + const scenario = scenarios[index === 0 ? 5 : 4]!; + const tested = await testAndSave(scenario); + expect(tested).toMatchObject(edit === "model" ? { modelId: "corrected-model" } : { apiKey: "sk-corrected-9999" }); + } + ); + + it.each([0, 1])("role %i: showing and hiding a key does not invalidate a pending test", async (index) => { + const finish = deferProbe(); + await render(); + await click(test(index)); + await vi.waitFor(() => expect(server.probe).toHaveBeenCalledTimes(1)); + await click(container.querySelectorAll('[aria-label="apiKey.showKey"]')[index]!); + await click(container.querySelector('[aria-label="apiKey.hideKey"]')!); + expect(test(index).disabled).toBe(true); + await finish(); + expect(test(index).textContent).toBe("apiKey.testSuccess"); + expect(next().disabled).toBe(false); + await click(next()); + await vi.waitFor(() => expect(server.write).toHaveBeenCalled()); + }); + + it.each([0, 1])("role %i: editing the other independent card leaves this request valid", async (index) => { + const finish = deferProbe(); + await render(); + await click(test(index)); + await vi.waitFor(() => expect(server.probe).toHaveBeenCalledTimes(1)); + await change(field("apiKey.model", 1 - index), "other-model"); + await finish(); + expect(test(index).textContent).toBe("apiKey.testSuccess"); + expect(next().disabled).toBe(true); + await testAndSave(scenarios[index === 0 ? 4 : 5]!); + expect(await server.key("memory_evolution")).toBe("sk-team-1111"); + expect(await server.key("memory_summary")).toBe("sk-summary-1111"); + }); + + it.each(["old-first", "new-first", "return-to-original"])("roles: overlapping requests (%s) cannot overwrite the latest result", async (order) => { + const finishOld = deferProbe(); + const finishNew = deferProbe(); + await render(); + await click(test(1)); + await vi.waitFor(() => expect(server.probe).toHaveBeenCalledTimes(1)); + await change(field("apiKey.model", 1), "new-model"); + if (order === "return-to-original") await change(field("apiKey.model", 1), "same-model"); + await click(test(1)); + await vi.waitFor(() => expect(server.probe).toHaveBeenCalledTimes(2)); + if (order === "old-first") { + await finishOld(); + expect(test(1).textContent).toBe("apiKey.testing"); + expect(next().disabled).toBe(true); + await finishNew(); + } else { + await finishNew(); + await finishOld(false); + } + expect(test(1).textContent).toBe("apiKey.testSuccess"); + expect(next().disabled).toBe(false); + await click(next()); + await vi.waitFor(() => expect(server.write).toHaveBeenCalled()); + expect(await server.key("memory_evolution")).toBe("sk-team-1111"); + }); + + it("roles: upstream edits invalidate a following summary and recover after retesting", async () => { + const finishOld = deferProbe(); + await render(); + await click(test(0)); + await vi.waitFor(() => expect(server.probe).toHaveBeenCalledTimes(1)); + await click(container.querySelectorAll('button[role="checkbox"]')[0]!); + await change(field("API Key"), "sk-updated-team-9999"); + expect(next().disabled).toBe(true); + await finishOld(); + expect(next().disabled).toBe(true); + await testAndSave({ ...scenarios[4]!, index: 0, fieldIndex: 0 }); + expect(await server.key("memory_evolution")).toBe("sk-updated-team-9999"); + expect(await server.key("memory_summary")).toBe("sk-updated-team-9999"); }); it("follow chain: primary rotation flows through evolution and summary, while a fixed role keeps its own key", async () => { From 1fe1a6f83224c071129ea163bf23ed76ceb282aa 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, 17 Oct 2026 20:31:51 +0800 Subject: [PATCH 55/56] fix: isolate onboarding connection test responses --- .../src/pages/api-key-optional-page.tsx | 47 +++---- .../desktop/src/pages/api-key-page.tsx | 48 +++---- ...redential-consistency.interaction.test.tsx | 129 ++++++++++++++++++ .../src/pages/use-model-connection-test.ts | 48 +++++++ 4 files changed, 214 insertions(+), 58 deletions(-) create mode 100644 App/frontend/desktop/src/pages/use-model-connection-test.ts 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 bb5164e66..c4335c977 100644 --- a/App/frontend/desktop/src/pages/api-key-optional-page.tsx +++ b/App/frontend/desktop/src/pages/api-key-optional-page.tsx @@ -1,3 +1,4 @@ +import { useModelConnectionTest } from "./use-model-connection-test.js"; import { bindSavedValidation, endpointCredentialSignature, resolveModelCredentials, type SavedEndpointIdentity } from "./model-credentials.js"; import { useRef, useState } from "react"; import { ChevronLeft, Image as ImageIcon, Mic } from "lucide-react"; @@ -34,8 +35,7 @@ import { } from "./optional-model-missing-warning-modal.js"; import { canSaveModelConfig, - createModelConfigValidationKey, - type ModelConfigValidationState + createModelConfigValidationKey } from "./model-config-validation.js"; import { ASR_DEFAULT_ENDPOINT, @@ -47,7 +47,6 @@ import { createImageGenModelFormValues, createTestModelConnectionMessages, hydrateModelConfigForm, - testModelConnection, type ImageProtocol } from "./model-config.js"; @@ -81,10 +80,16 @@ export function ApiKeyOptionalPage() { ...createAsrModelFormValues(asrModel, asrEndpoint, asrApiKey, asrApiKeyMasked), protocol: "dashscope-input-audio-chat" }, savedEndpointIdentitiesRef.current.asr); - const [asrValidation, setAsrValidation] = useState(() => bindSavedValidation(initialModelForm.asrValidation, asrFormValues)); + const { validation: asrValidation, isStale: isAsrTestStale, testConnection: testAsrConnection } = useModelConnectionTest({ + configClient: clients?.config, + values: asrFormValues, + initialValidation: () => bindSavedValidation(initialModelForm.asrValidation, asrFormValues), + secretTarget: "asr", + capability: "asr", + messages: createTestModelConnectionMessages(t) + }); const isAsrUsable = canSaveModelConfig(asrFormValues, asrValidation); const asrTestKey = createModelConfigValidationKey(asrFormValues); - const isAsrTestStale = Boolean(asrValidation.testedKey && asrValidation.testedKey !== asrTestKey); const [imageGenProtocol, setImageGenProtocol] = useState(initialModelForm.imageGenProtocol); const [imageGenModel, setImageGenModel] = useState(initialModelForm.imageGenModelId); const [imageGenEndpoint, setImageGenEndpoint] = useState(initialModelForm.imageGenEndpoint); @@ -96,10 +101,16 @@ export function ApiKeyOptionalPage() { ...createImageGenModelFormValues(imageGenProtocol, imageGenModel, imageGenEndpoint, imageGenApiKey, imageGenApiKeyMasked), protocol: imageGenProtocol === "qwen" ? "dashscope-multimodal-generation" : "openai-images" }, savedEndpointIdentitiesRef.current.imageGeneration); - const [imageGenValidation, setImageGenValidation] = useState(() => bindSavedValidation(initialModelForm.imageGenValidation, imageGenFormValues)); + const { validation: imageGenValidation, isStale: isImageGenTestStale, testConnection: testImageGenConnection } = useModelConnectionTest({ + configClient: clients?.config, + values: imageGenFormValues, + initialValidation: () => bindSavedValidation(initialModelForm.imageGenValidation, imageGenFormValues), + secretTarget: "image", + capability: "image", + messages: createTestModelConnectionMessages(t) + }); const isImageGenUsable = canSaveModelConfig(imageGenFormValues, imageGenValidation); const imageGenTestKey = createModelConfigValidationKey(imageGenFormValues); - const isImageGenTestStale = Boolean(imageGenValidation.testedKey && imageGenValidation.testedKey !== imageGenTestKey); const imageGenEndpointProtocol = imageGenProtocol === "qwen" ? "dashscope-multimodal-generation" : "openai-images"; @@ -137,28 +148,6 @@ export function ApiKeyOptionalPage() { setImageGenWarningAcknowledged(false); } - function testAsrConnection() { - testModelConnection({ - configClient: clients?.config, - values: asrFormValues, - setValidation: setAsrValidation, - capability: "asr", - secretTarget: "asr", - messages: createTestModelConnectionMessages(t) - }); - } - - function testImageGenConnection() { - testModelConnection({ - configClient: clients?.config, - values: imageGenFormValues, - setValidation: setImageGenValidation, - capability: "image", - secretTarget: "image", - messages: createTestModelConnectionMessages(t) - }); - } - function closeOptionalModelMissingWarning() { if (optionalModelMissingWarning === "asr" || optionalModelMissingWarning === "both") { setAsrWarningAcknowledged(true); diff --git a/App/frontend/desktop/src/pages/api-key-page.tsx b/App/frontend/desktop/src/pages/api-key-page.tsx index 646ddd12a..c83f17a4b 100644 --- a/App/frontend/desktop/src/pages/api-key-page.tsx +++ b/App/frontend/desktop/src/pages/api-key-page.tsx @@ -1,3 +1,4 @@ +import { useModelConnectionTest } from "./use-model-connection-test.js"; import { bindSavedValidation, endpointCredentialSignature, resolveModelCredentials, type SavedEndpointIdentity, chatProtocol } from "./model-credentials.js"; import { useRef, useState } from "react"; import { Brain, ChevronLeft, Search } from "lucide-react"; @@ -30,14 +31,12 @@ import { import { canSaveOptionalModelConfig, canSaveModelConfig, - createModelConfigValidationKey, - type ModelConfigValidationState + createModelConfigValidationKey } from "./model-config-validation.js"; import { createTestModelConnectionMessages, fromProtocol, - hydrateModelConfigForm, - testModelConnection + hydrateModelConfigForm } from "./model-config.js"; type EmbeddingMode = "local" | "custom"; @@ -109,7 +108,13 @@ export function ApiKeyPage() { apiKeyMasked, hasExistingApiKey: Boolean(apiKeyMasked) }, savedEndpointIdentitiesRef.current.agent); - const [llmValidation, setLlmValidation] = useState(() => bindSavedValidation(initialModelForm.llmValidation, modelFormValues)); + const { validation: llmValidation, isStale: isTestStale, testConnection: testLlmConnection } = useModelConnectionTest({ + configClient: clients?.config, + values: modelFormValues, + initialValidation: () => bindSavedValidation(initialModelForm.llmValidation, modelFormValues), + secretTarget: "primary", + messages: createTestModelConnectionMessages(t) + }); const initialEmbeddingMode: EmbeddingMode = initialWorkspace.catalog.modelAssignments.byok.embedding ? "custom" : "local"; @@ -130,15 +135,21 @@ export function ApiKeyPage() { apiKeyMasked: embeddingConfig.apiKeyMasked, hasExistingApiKey: Boolean(embeddingConfig.apiKeyMasked) }, savedEndpointIdentitiesRef.current.embedding); - const [embeddingValidation, setEmbeddingValidation] = useState(() => bindSavedValidation(initialModelForm.embValidation, embeddingFormValues)); + const { validation: embeddingValidation, isStale: isEmbeddingTestStale, testConnection: testEmbeddingConnection } = useModelConnectionTest({ + configClient: clients?.config, + values: embeddingFormValues, + initialValidation: () => bindSavedValidation(initialModelForm.embValidation, embeddingFormValues), + secretTarget: "embedding", + capability: "embedding", + enabled: embeddingMode === "custom", + messages: createTestModelConnectionMessages(t) + }); const canSave = canSaveModelConfig(modelFormValues, llmValidation) && canSaveOptionalModelConfig(embeddingMode === "custom", embeddingFormValues, embeddingValidation); const [savePending, setSavePending] = useState(false); const [saveError, setSaveError] = useState(null); const testedKey = createModelConfigValidationKey(modelFormValues); - const isTestStale = Boolean(llmValidation.testedKey && llmValidation.testedKey !== testedKey); const embeddingTestKey = createModelConfigValidationKey(embeddingFormValues); - const isEmbeddingTestStale = Boolean(embeddingValidation.testedKey && embeddingValidation.testedKey !== embeddingTestKey); const agentCredentialSignature = endpointCredentialSignature( provider, chatProtocol(provider), @@ -165,27 +176,6 @@ export function ApiKeyPage() { setApiKey(""); } - function testLlmConnection() { - testModelConnection({ - configClient: clients?.config, - values: modelFormValues, - setValidation: setLlmValidation, - secretTarget: "primary", - messages: createTestModelConnectionMessages(t) - }); - } - - function testEmbeddingConnection() { - testModelConnection({ - configClient: clients?.config, - values: embeddingFormValues, - setValidation: setEmbeddingValidation, - capability: "embedding", - secretTarget: "embedding", - messages: createTestModelConnectionMessages(t) - }); - } - function updateEmbeddingConfig(field: keyof EmbeddingCustomConfig, value: string) { setEmbeddingConfig((current) => ({ ...current, [field]: value })); } diff --git a/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx index 42079a4d0..487acada6 100644 --- a/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx @@ -484,6 +484,135 @@ describe("BYOK displayed, tested and saved credentials", () => { }; } + it.each(scenarios.slice(0, 4))("onboarding $role: new success survives an older failure and saves the new key", async (scenario) => { + const finishOld = deferProbe(); + const finishNew = deferProbe(); + await render(scenario.page()); + await click(test(scenario.index)); + await vi.waitFor(() => expect(server.probe).toHaveBeenCalledTimes(1)); + await change(field(scenario.endpoint), "https://changed.example/v1"); + // Exact review reproduction: validate the now-missing credential before re-entry. + await click(test(scenario.index)); + expect(server.probe).toHaveBeenCalledTimes(1); + await change(field(scenario.key), "sk-new-onboarding-9999"); + await click(test(scenario.index)); + await vi.waitFor(() => expect(server.probe).toHaveBeenCalledTimes(2)); + await finishNew(); + expect(test(scenario.index).textContent).toBe("apiKey.testSuccess"); + await finishOld(false); + expect(test(scenario.index).textContent).toBe("apiKey.testSuccess"); + await click(next()); + await vi.waitFor(() => expect(server.write).toHaveBeenCalled()); + expect(await server.key(scenario.role)).toBe("sk-new-onboarding-9999"); + }); + + it.each(scenarios.slice(0, 4).flatMap((scenario) => + ["old-first", "new-first-success", "new-first-rejection", "new-failure", "return-to-original"].map((order) => ({ scenario, order })) + ))("onboarding $scenario.role: overlapping requests $order preserve the latest result", async ({ scenario, order }) => { + let rejectOld!: (error: Error) => void; + let finishOld: (ok?: boolean) => Promise; + if (order === "new-first-rejection") { + server.probe.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectOld = reject; })); + finishOld = async () => { await act(async () => rejectOld(new Error("old transport failure"))); }; + } else finishOld = deferProbe(); + const finishNew = deferProbe(); + await render(scenario.page()); + await click(test(scenario.index)); + await vi.waitFor(() => expect(server.probe).toHaveBeenCalledTimes(1)); + await change(field(scenario.key), "sk-new-onboarding-9999"); + if (order === "return-to-original") await change(field(scenario.key), ""); + expect(test(scenario.index).disabled).toBe(false); + expect(test(scenario.index).textContent).toBe("apiKey.test"); + await click(next()); + expect(server.write).not.toHaveBeenCalled(); + await click(test(scenario.index)); + await vi.waitFor(() => expect(server.probe).toHaveBeenCalledTimes(2)); + if (order === "old-first") { + await finishOld(); + expect(test(scenario.index).textContent).toBe("apiKey.testing"); + expect(test(scenario.index).disabled).toBe(true); + await finishNew(); + } else { + await finishNew(order !== "new-failure"); + await finishOld(); + } + if (order === "new-failure") { + expect(test(scenario.index).textContent).toBe("apiKey.testRetry"); + await click(next()); + expect(server.write).not.toHaveBeenCalled(); + await testAndSave(scenario); + } else { + expect(test(scenario.index).textContent).toBe("apiKey.testSuccess"); + await click(next()); + await vi.waitFor(() => expect(server.write).toHaveBeenCalled()); + } + expect(await server.key(scenario.role)).toBe(order === "return-to-original" ? scenario.expected : "sk-new-onboarding-9999"); + }); + + it.each(scenarios.slice(0, 4).flatMap((scenario) => + (scenario.role === "asr" ? ["key"] : ["key", "model"]).map((edit) => ({ scenario, edit })) + ))("onboarding $scenario.role: $edit edit unlocks retry before the old response", async ({ scenario, edit }) => { + const finishOld = deferProbe(); + await render(scenario.page()); + await click(test(scenario.index)); + await vi.waitFor(() => expect(server.probe).toHaveBeenCalledTimes(1)); + await change(field(edit === "key" ? scenario.key : scenario.model), edit === "key" ? "sk-new-onboarding-9999" : "new-model"); + expect(test(scenario.index).disabled).toBe(false); + await finishOld(); + expect(test(scenario.index).textContent).toBe("apiKey.test"); + await click(next()); + expect(server.write).not.toHaveBeenCalled(); + const tested = await testAndSave(scenario); + expect(tested).toMatchObject(edit === "key" ? { apiKey: "sk-new-onboarding-9999" } : { modelId: "new-model" }); + }); + + it.each(scenarios.slice(0, 4))("onboarding $role: visibility, normalized URL and other-card edits preserve the pending request", async (scenario) => { + const finish = deferProbe(); + await render(scenario.page()); + await click(test(scenario.index)); + await vi.waitFor(() => expect(server.probe).toHaveBeenCalledTimes(1)); + await click(container.querySelectorAll('[aria-label="apiKey.showKey"]')[scenario.index]!); + await click(container.querySelector('[aria-label="apiKey.hideKey"]')!); + await change(field(scenario.endpoint), " https://provider.example/v1/// "); + const other = scenarios[(scenario.role === "agent" || scenario.role === "embedding" ? 0 : 2) + 1 - scenario.index]!; + await change(field(other.key), "sk-independent-8888"); + expect(test(scenario.index).disabled).toBe(true); + await finish(); + expect(test(scenario.index).textContent).toBe("apiKey.testSuccess"); + await testAndSave(other); + expect(await server.key(scenario.role)).toBe(scenario.expected); + }); + + it.each([scenarios[0]!, scenarios[3]!])("onboarding $role: changing protocol during a request invalidates it and permits retry", async (scenario) => { + const finishOld = deferProbe(); + await render(scenario.page()); + await click(test(scenario.index)); + await vi.waitFor(() => expect(server.probe).toHaveBeenCalledTimes(1)); + await click(container.querySelector('button[role="combobox"]')!); + await click([...container.querySelectorAll('button[role="option"]')].find((item) => item.getAttribute("aria-selected") === "false")!); + await change(field(scenario.model), "new-protocol-model"); + expect(test(scenario.index).disabled).toBe(false); + await finishOld(); + expect(test(scenario.index).textContent).not.toBe("apiKey.testSuccess"); + await change(field(scenario.key), "sk-new-onboarding-9999"); + await testAndSave(scenario); + }); + + it("onboarding embedding: disabling and reenabling invalidates the pending request", async () => { + const finish = deferProbe(); + await render(); + await click(test(1)); + await vi.waitFor(() => expect(server.probe).toHaveBeenCalledTimes(1)); + for (let i = 0; i < 2; i++) { + await click(container.querySelectorAll('button[role="combobox"]')[1]!); + await click([...container.querySelectorAll('button[role="option"]')].find((item) => item.getAttribute("aria-selected") === "false")!); + } + await finish(); + expect(test(1).textContent).toBe("apiKey.test"); + expect(next().disabled).toBe(true); + await testAndSave(scenarios[1]!); + }); + it.each([0, 1].flatMap((index) => ["model", "key"].map((edit) => ({ index, edit }))))( "role $index: editing $edit during a test unlocks retry and allows saving the new configuration", async ({ index, edit }) => { diff --git a/App/frontend/desktop/src/pages/use-model-connection-test.ts b/App/frontend/desktop/src/pages/use-model-connection-test.ts new file mode 100644 index 000000000..391f8b27e --- /dev/null +++ b/App/frontend/desktop/src/pages/use-model-connection-test.ts @@ -0,0 +1,48 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { createModelConfigValidationKey, type ModelConfigValidationState } from "./model-config-validation.js"; +import { testModelConnection, type TestModelConnectionInput } from "./model-config.js"; + +type ConnectionTestOptions = Omit & { + initialValidation: () => ModelConfigValidationState; + enabled?: boolean; +}; + +/** One card owns one request generation, independent of presentation-only edits. */ +export function useModelConnectionTest({ initialValidation, enabled = true, ...input }: ConnectionTestOptions) { + const [validation, setValidation] = useState(initialValidation); + const [isStale, setIsStale] = useState(false); + const revision = useRef(0); + const connectionKey = createModelConfigValidationKey(input.values); + const previous = useRef({ connectionKey, enabled }); + + // Invalidate before paint so editing immediately unlocks retry and disables save. + // A generation (rather than only a key comparison) also handles A -> B -> A. + useLayoutEffect(() => { + if (previous.current.connectionKey === connectionKey && previous.current.enabled === enabled) return; + const connectionChanged = previous.current.connectionKey !== connectionKey; + previous.current = { connectionKey, enabled }; + revision.current += 1; + // Merely hiding an already validated optional card preserves its credential. + // A pending request must still be invalidated when the card is disabled. + if (connectionChanged || validation.status === "testing") { + setValidation({ status: "idle", message: null, testedKey: null }); + setIsStale(true); + } + }, [connectionKey, enabled, validation.status]); + + useLayoutEffect(() => () => { revision.current += 1; }, []); + + function testConnection() { + if (!enabled) return; + const requestRevision = ++revision.current; + setIsStale(false); + testModelConnection({ + ...input, + setValidation: (next) => { + if (revision.current === requestRevision) setValidation(next); + } + }); + } + + return { validation, isStale, testConnection }; +} From e00425f6432565959358be434084379389fa1b94 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, 17 Oct 2026 22:03:10 +0800 Subject: [PATCH 56/56] test: cover BYOK requests completing after page unmount --- ...redential-consistency.interaction.test.tsx | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx index 487acada6..101c32995 100644 --- a/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx +++ b/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx @@ -484,6 +484,62 @@ describe("BYOK displayed, tested and saved credentials", () => { }; } + it.each(scenarios.flatMap((scenario) => + ["success", "failure", "rejection"].flatMap((outcome) => + ["while-away", "new-pending", "new-succeeded"].map((timing) => ({ scenario, outcome, timing })) + ) + ))("$scenario.role: leaving during a request isolates its $outcome ($timing) from reentry and save", async ({ scenario, outcome, timing }) => { + let finishOld: () => Promise; + if (outcome === "rejection") { + let rejectOld!: (error: Error) => void; + server.probe.mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectOld = reject; })); + finishOld = async () => { await act(async () => rejectOld(new Error("request from unmounted page rejected"))); }; + } else { + const finish = deferProbe(); + finishOld = () => finish(outcome === "success"); + } + const finishNew = deferProbe(); + await render(scenario.page()); + await change(field(scenario.key, scenario.fieldIndex), "sk-before-leaving-7777"); + await click(test(scenario.index)); + await vi.waitFor(() => expect(server.probe).toHaveBeenCalledTimes(1)); + expect(test(scenario.index).textContent).toBe("apiKey.testing"); + + // Route departure unmounts the page, while the real client/server promise stays pending. + await render(null); + expect(container.childElementCount).toBe(0); + if (timing === "while-away") await finishOld(); + expect(server.write).not.toHaveBeenCalled(); + expect(mocks.dispatch).not.toHaveBeenCalled(); + await render(scenario.page()); + expect(test(scenario.index).disabled).toBe(false); + expect(test(scenario.index).textContent).toBe("apiKey.test"); + expect(field(scenario.key, scenario.fieldIndex).value).toBe(""); + expect(field(scenario.key, scenario.fieldIndex).placeholder).toContain(scenario.expected.slice(-4)); + + await change(field(scenario.key, scenario.fieldIndex), "sk-after-reentry-9999"); + await click(next()); + expect(server.write).not.toHaveBeenCalled(); + await click(test(scenario.index)); + await vi.waitFor(() => expect(server.probe).toHaveBeenCalledTimes(2)); + expect(server.probe.mock.calls[0]![0].apiKey).toBe("sk-before-leaving-7777"); + expect(server.probe.mock.calls[1]![0].apiKey).toBe("sk-after-reentry-9999"); + if (timing === "new-pending") await finishOld(); + expect(test(scenario.index).textContent).toBe("apiKey.testing"); + expect(test(scenario.index).disabled).toBe(true); + await click(next()); + expect(server.write).not.toHaveBeenCalled(); + await finishNew(); + if (timing === "new-succeeded") await finishOld(); + expect(test(scenario.index).textContent).toBe("apiKey.testSuccess"); + expect(next().disabled).toBe(false); + expect(server.write).not.toHaveBeenCalled(); + await click(next()); + await vi.waitFor(() => expect(server.write).toHaveBeenCalled()); + await vi.waitFor(() => expect(mocks.dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: "navigation/changed" }))); + expect(await server.key(scenario.role)).toBe("sk-after-reentry-9999"); + }); + it.each(scenarios.slice(0, 4))("onboarding $role: new success survives an older failure and saves the new key", async (scenario) => { const finishOld = deferProbe(); const finishNew = deferProbe();