diff --git a/.github/workflows/runtime-artifacts.yml b/.github/workflows/runtime-artifacts.yml new file mode 100644 index 000000000..7854d5629 --- /dev/null +++ b/.github/workflows/runtime-artifacts.yml @@ -0,0 +1,101 @@ +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_memory_runtime_*] +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 + - 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 }}" "${{ inputs.runtime || 'memory' }}" + - 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 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 + path: runtime-output/* + if-no-files-found: error + compression-level: 0 + retention-days: 14 diff --git a/.gitignore b/.gitignore index fec3c3dd1..b390d647b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +App/memmy-agent/npm-packages/ release/ App/shell/desktop/release/ Mac软件打包/ 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>( 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/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/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/infrastructure/memmy-config/index.ts b/App/backend/src/infrastructure/memmy-config/index.ts index 050475886..7abfc6407 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() @@ -571,6 +585,11 @@ export async function writeAccountModelProjectionToMemmyConfig( appConfig, LEGACY_ACCOUNT_BYOK_LOCAL_SELECTION_BASELINE ); + // 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/model-config-catalog.ts b/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts index c1d25dc37..f2518ab4f 100644 --- a/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts +++ b/App/backend/src/infrastructure/memmy-config/model-config-catalog.ts @@ -72,15 +72,19 @@ export async function writeModelConfigCatalog( input: ModelConfigInput ): Promise { 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/account-model-projection.test.ts b/App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts index 15e43fde3..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; @@ -97,6 +121,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/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/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/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/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/runtime-config-sync-service.ts b/App/backend/src/services/runtime-config-sync-service.ts index a5a80473d..d58357bb5 100644 --- a/App/backend/src/services/runtime-config-sync-service.ts +++ b/App/backend/src/services/runtime-config-sync-service.ts @@ -6,9 +6,11 @@ import { } from "../infrastructure/app-state-store/index.js"; import { clearAccountModelProjectionFromMemmyConfig, + readRuntimeMemmyConfigUserMode, readRuntimeMemmyConfigState, writeAccountModelProjectionToMemmyConfig, - type RuntimeMemmyConfigState + type RuntimeMemmyConfigState, + type RuntimeProjectionResult } from "../infrastructure/memmy-config/index.js"; export interface RuntimeConfigMigrationConsistency { @@ -52,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. @@ -90,6 +98,8 @@ export async function syncRuntimeConfigWithAppState( } return accountChannelMismatchResult(options.appStateStore, wroteConfig); } + const restored = await restoreMissingActiveAccountProjection(options, state); + state = restored.state; switch (state.status) { case "valid_byok": { const clearedDormantProjection = await clearUntrustedAccountProjection(options, state.accountProjection); @@ -99,7 +109,7 @@ export async function syncRuntimeConfigWithAppState( : hydrated; } case "valid_account": - return hydrateAccountRuntimeConfig(options, state); + return hydrateAccountRuntimeConfig(options, state, restored.projection); case "missing": case "empty": return { @@ -135,6 +145,42 @@ export async function syncRuntimeConfigWithAppState( } } +async function restoreMissingActiveAccountProjection( + options: SyncRuntimeConfigWithAppStateOptions, + state: RuntimeMemmyConfigState +): Promise<{ + state: RuntimeMemmyConfigState; + projection?: RestoredAccountProjection; +}> { + 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 }; + const projection = await writeAccountModelProjectionToMemmyConfig({ + cloudUuid, + userId: session.profile.userId, + preserveAccountByokSelection: true + }, options.memmyConfigPath); + return { + state: await readRuntimeMemmyConfigState(options.memmyConfigPath), + projection: { cloudUuid, userId: session.profile.userId, result: projection } + }; +} + /** Handles sync runtime config for startup. */ export async function syncRuntimeConfigForStartup( options: SyncRuntimeConfigForStartupOptions @@ -247,7 +293,8 @@ function hydrateByokRuntimeConfig( async function hydrateAccountRuntimeConfig( options: SyncRuntimeConfigWithAppStateOptions, - state: Extract + state: Extract, + restoredProjection?: RestoredAccountProjection ): Promise { const { appStateStore } = options; const activated = appStateStore.repositories.accountSession.activateByCloudUuid( @@ -271,15 +318,21 @@ async function hydrateAccountRuntimeConfig( source: "none", mode: appStateStore.repositories.bootstrap.getAppSettings().userMode, hydratedAppState: false, - wroteConfig: projection.changed, + wroteConfig: projection.changed || restoredProjection?.result.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); + // 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, + preserveAccountByokSelection: true + }, + options.memmyConfigPath + ); appStateStore.repositories.bootstrap.updateAppSettings({ userMode: "account" }); return { source: "runtime_config", @@ -287,10 +340,12 @@ async function hydrateAccountRuntimeConfig( provider: "memmy_account", model: "agent_chat", hydratedAppState: true, - wroteConfig: projection.changed, - reason: projection.changed - ? "refreshed_account_projection_and_hydrated_account" - : "hydrated_account_from_runtime_config" + 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" + : "hydrated_account_from_runtime_config" }; } 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 69515fb4a..2289871ce 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/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 2bc3c5828..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 @@ -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 }); @@ -51,6 +53,147 @@ 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); + + 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" + })).resolves.toMatchObject({ + source: "runtime_config", + mode: "account", + provider: "memmy_account", + model: "agent_chat", + hydratedAppState: true, + 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"); + 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$/) + ); + + 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 () => { + 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({ @@ -469,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); 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/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/config-client.test.ts b/App/frontend/desktop/src/api/tests/config-client.test.ts index eae3aa81a..087050f8c 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,122 @@ 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.each(["missing alias", "endpoint changed after assignment"] as const)( + "%s: 相同地址和密钥掩码不能代替后端确认的连接身份", + async (scenario) => { + const bodies: ModelConfigInput[] = []; + vi.stubGlobal("fetch", vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + bodies.push(JSON.parse(String(init?.body)) as ModelConfigInput); + const response = catalog(`revision-${bodies.length + 1}`); + const provider = response.providers[0]!; + const endpoint = provider.endpoints[0]!; + endpoint.apiKeyMasked = "sk-••••1234"; + provider.endpoints.push({ ...endpoint, endpointId: "other-key" }); + if (scenario === "endpoint changed after assignment" && bodies.length === 1) { + response.endpointAliases = { openai: { "chat-copy": "chat" } }; + } + provider.models.push({ + ...provider.models[0]!, + presetId: "server-new-model", + endpointId: bodies.length === 1 ? "chat" : "other-key", + model: "gpt-new" + }); + return jsonResponse(response); + })); + const input = inputFromCatalog(catalog("revision-1")); + const clientId = `${CLIENT_PRESET_ID_PREFIX}identity-check`; + input.providers[0]!.endpoints.push({ + ...input.providers[0]!.endpoints[0]!, + endpointId: "chat-copy", + apiKey: "sk-different-secret1234" + }); + input.providers[0]!.models.push({ + presetId: clientId, + endpointId: "chat-copy", + model: "gpt-new", + source: "byok", + capabilities: ["agent"] + }); + input.modelAssignments.byok.agent = { candidates: [clientId], default: clientId }; + + await expect(createHttpConfigClient(config).saveModelCatalog(input)).rejects.toThrow( + scenario === "missing alias" ? "Unable to resolve server preset" : "Created model preset changed before assignment" + ); + expect(bodies).toHaveLength(scenario === "missing alias" ? 1 : 2); + if (scenario === "endpoint changed after assignment") { + expect(bodies[1]!.modelAssignments.byok.agent.default).toBe("server-new-model"); + expect(bodies[1]!.providers[0]!.models.find((model) => model.presetId === "server-new-model")?.endpointId).toBe("chat"); + } + } + ); + it("首次成功 GET 后只清理旧 workspace cache", async () => { const removeItem = vi.fn(); vi.stubGlobal("window", { localStorage: { removeItem } }); 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/components/social-login-buttons.tsx b/App/frontend/desktop/src/components/social-login-buttons.tsx index 078f7074f..5b3ce1acc 100644 --- a/App/frontend/desktop/src/components/social-login-buttons.tsx +++ b/App/frontend/desktop/src/components/social-login-buttons.tsx @@ -3,13 +3,12 @@ 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; disabled?: boolean; + showDivider?: boolean; feedback?: { text: string; tone: "error" | "success" } | null; onLogin: (provider: SocialLoginProvider) => void; } @@ -19,15 +18,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 +50,7 @@ export function SocialLoginButtons(props: SocialLoginButtonsProps) { } function SocialLoginButton(props: { + provider: SocialLoginProvider; label: string; icon: ReactNode; disabled?: boolean; @@ -58,7 +61,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/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(); }); }); diff --git a/App/frontend/desktop/src/i18n/messages.ts b/App/frontend/desktop/src/i18n/messages.ts index 9e79ffc53..4d0446812 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": "登录已超时,请重试", @@ -327,6 +328,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", @@ -545,6 +547,7 @@ export const zhCNMessages = { "home.media.addPhotoFile": "添加图片和文件", "home.voiceInput": "语音输入", "home.asrEmptyAudio": "没有录到有效音频,请稍微说久一点再发送", + "home.asrNotConfigured": "未配置 ASR 模型,请先配置后再使用语音输入", "home.asrFailedWithMessage": "语音识别失败:{message}", "home.asrFailed": "语音识别失败,请重试", "home.suggestion.one": "整理最近的 Agent 任务", @@ -1955,6 +1958,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.", @@ -2067,6 +2071,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", @@ -2285,6 +2290,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/agent-command-palette.tsx b/App/frontend/desktop/src/pages/agent-command-palette.tsx index d73271218..56001db96 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,8 +18,24 @@ 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; +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; @@ -132,6 +149,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; } @@ -145,7 +169,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; @@ -156,6 +180,8 @@ export function AgentCommandPalette(props: AgentCommandPaletteProps) { type="button" 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"}`} > @@ -178,32 +204,52 @@ 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( 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( commands: SlashCommandPaletteItem[], query: string, - recentCommands: string[] + recentCommands: string[], + modelPresets: ReadonlyArray<{ name: string; available: boolean }> = [] ): SlashCommandPaletteItem[] { - const normalizedQuery = query.toLowerCase(); - const matched = normalizedQuery - ? commands.filter((command) => commandMatchesQuery(command, normalizedQuery)) - : sortByRecent(commands, recentCommands); - return matched.slice(0, MAX_VISIBLE_SLASH_COMMANDS); + const normalizedQuery = query.toLowerCase().replace(/[\t ]+/g, " "); + 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, ""]); + } + } + } + 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)); + + // 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[] { @@ -235,8 +281,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/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/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 = createAsrModelFormValues(asrModel, asrEndpoint, asrApiKey, asrApiKeyMasked); + const asrFormValues = resolveModelCredentials({ + ...createAsrModelFormValues(asrModel, asrEndpoint, asrApiKey, asrApiKeyMasked), + protocol: "dashscope-input-audio-chat" + }, savedEndpointIdentitiesRef.current.asr); + 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); 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 = createImageGenModelFormValues(imageGenProtocol, imageGenModel, imageGenEndpoint, imageGenApiKey, imageGenApiKeyMasked); + const imageGenFormValues = resolveModelCredentials({ + ...createImageGenModelFormValues(imageGenProtocol, imageGenModel, imageGenEndpoint, imageGenApiKey, imageGenApiKeyMasked), + protocol: imageGenProtocol === "qwen" ? "dashscope-multimodal-generation" : "openai-images" + }, savedEndpointIdentitiesRef.current.imageGeneration); + 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"; @@ -108,17 +133,7 @@ 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 } } - : {}), - ...(initialImageEndpointId - ? { imageGeneration: { endpointId: initialImageEndpointId, credentialSignature: imageGenCredentialSignature } } - : {}) - }); + const [optionalModelMissingWarning, setOptionalModelMissingWarning] = useState(null); const [modePersistencePending, setModePersistencePending] = useState(false); const [nextValidationError, setNextValidationError] = useState<"noInput" | "testRequired" | null>(null); @@ -133,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); @@ -180,34 +173,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"] }); @@ -279,8 +264,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"); @@ -347,12 +332,12 @@ export function ApiKeyOptionalPage() { setAsrWarningAcknowledged(false); }} showPassword={showAsrApiKey} - maskedValue={asrApiKeyMasked} + maskedValue={asrFormValues.apiKeyMasked} onTogglePassword={() => setShowAsrApiKey((value) => !value)} />
- - + +
@@ -398,12 +383,12 @@ export function ApiKeyOptionalPage() { setImageGenWarningAcknowledged(false); }} showPassword={showImageGenApiKey} - maskedValue={imageGenApiKeyMasked} + maskedValue={imageGenFormValues.apiKeyMasked} onTogglePassword={() => setShowImageGenApiKey((value) => !value)} />
- - + +
@@ -453,22 +438,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 40f6fb6ed..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,5 @@ +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"; import { useAnalytics } from "../analytics/use-analytics.js"; @@ -29,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"; @@ -55,10 +55,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,25 +79,42 @@ export function ApiKeyPage() { const { clients } = useApiClients(); const { t } = useTranslation(); const { track } = useAnalytics(); - const initialModelForm = hydrateModelConfigForm(state.modelConfig, "local"); + 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, endpoint, model, apiKey, apiKeyMasked, hasExistingApiKey: Boolean(apiKeyMasked) - }; - const [llmValidation, setLlmValidation] = useState(initialModelForm.llmValidation); - const initialWorkspace = createModelWorkspace(state.modelConfig); + }, savedEndpointIdentitiesRef.current.agent); + 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"; @@ -112,23 +126,30 @@ export function ApiKeyPage() { apiKeyMasked: initialModelForm.embApiKeyMasked }); const [showEmbeddingApiKey, setShowEmbeddingApiKey] = useState(false); - const embeddingFormValues = { + const embeddingFormValues = resolveModelCredentials({ + protocol: "openai-embeddings", provider: "openai", 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 { 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), @@ -145,16 +166,7 @@ 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 } } - : {}), - ...(initialEmbeddingEndpointId - ? { embedding: { endpointId: initialEmbeddingEndpointId, credentialSignature: embeddingCredentialSignature } } - : {}) - }); + function changeProvider(nextProvider: string) { const next = providerOptions.find((option) => option.value === nextProvider) ?? defaultProvider; @@ -162,28 +174,6 @@ export function ApiKeyPage() { setEndpoint(next.endpoint); setModel(""); setApiKey(""); - setApiKeyMasked(""); - } - - 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) { @@ -200,33 +190,33 @@ 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; + // 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, - ...(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"] + 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 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"] }); @@ -315,14 +305,14 @@ export function ApiKeyPage() { placeholder="sk-..." value={apiKey} onChange={setApiKey} - maskedValue={apiKeyMasked} + maskedValue={modelFormValues.apiKeyMasked} showPassword={showApiKey} onTogglePassword={() => setShowApiKey((value) => !value)} />
- - + +
@@ -364,13 +354,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} @@ -391,22 +381,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, @@ -419,9 +393,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/home-page.tsx b/App/frontend/desktop/src/pages/home-page.tsx index d823d2eae..42ead9f59 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, @@ -187,6 +188,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", @@ -1539,11 +1541,11 @@ 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; - 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" @@ -1587,10 +1589,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 +2122,6 @@ export function HomePage() { function updateComposerInput(value: string) { setCurrentComposerDraft(buildComposerCommandDraft(selectedComposerCommand, value)); setSlashMenuDismissed(false); - setSelectedCommandIndex(0); if ( slashQueryFromInput(value) != null && clients?.memmyAgent && @@ -3457,6 +3456,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 +3595,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/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/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 ? ( {skill.usageGuide && } - +
+
{t("memory.skills.body")}
+ +
{hasDecisionGuidance && (
diff --git a/App/frontend/desktop/src/pages/memory/tests/memories-sub-page.test.tsx b/App/frontend/desktop/src/pages/memory/tests/memories-sub-page.test.tsx index 598c33043..e4d6898b2 100644 --- a/App/frontend/desktop/src/pages/memory/tests/memories-sub-page.test.tsx +++ b/App/frontend/desktop/src/pages/memory/tests/memories-sub-page.test.tsx @@ -579,19 +579,29 @@ describe("MemoriesSubPage", () => { 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/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, modelId: initialModelForm.modelId, 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 testRevisions = useRef({ skill: 0, memory: 0 }); const skillValues = createModelFormValues(skill, primaryModel); const evolutionModel = modelFormValuesAsPrimary(skillValues); const memoryValues = createModelFormValues(mem, evolutionModel); @@ -103,12 +117,24 @@ export function ModelPage() { /** Handles patch mem. */ function patchMem(patch: Partial) { - setMem((current) => ({ ...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) { - setSkill((current) => ({ ...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. */ @@ -119,10 +145,15 @@ export function ModelPage() { secretTarget: "memory" | "skill" ) { const values = createModelFormValues(config, inheritedModel); + // 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) => patch({ validation }), + setValidation: (validation) => { + if (testRevisions.current[secretTarget] === revision) patch({ validation }); + }, secretTarget, messages: createTestModelConnectionMessages(t) }); @@ -142,15 +173,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() } : {}), @@ -159,15 +184,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() } : {}), @@ -262,10 +281,32 @@ 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 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, + ...(staleMessage + ? { validation: { status: "idle" as const, message: staleMessage, 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; } /** @@ -290,6 +331,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; @@ -370,12 +412,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 })} />
- - + +
)} @@ -484,12 +526,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.validation.message && !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/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/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..8f9df00de --- /dev/null +++ b/App/frontend/desktop/src/pages/tests/agent-command-palette.interaction.test.tsx @@ -0,0 +1,53 @@ +// @vitest-environment happy-dom + +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { expect, it, vi } from "vitest"; +import { AgentCommandPalette, defaultSlashCommandIndex, 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(commands.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]); + + const goalMatches = filterSlashCommands(commands.filter((command) => !command.command.includes(" ")), "g", []); + scrollIntoView.mockClear(); + act(() => root.render()); + const goalOptions = container.querySelectorAll('[role="option"]'); + const closest = goalOptions[goalOptions.length - 1]!; + 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 { + 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..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 @@ -4,6 +4,7 @@ import { enUSMessages, zhCNMessages } from "../../i18n/messages.js"; import { AgentCommandPalette, buildVisibleSlashCommands, + defaultSlashCommandIndex, filterSlashCommands, localizeSlashCommands, readRecentSlashCommands, @@ -25,14 +26,101 @@ 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(); }); - it("filters by command metadata and caps visible results", () => { + 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, ...commands, ...commands], "", [])).toHaveLength(8); + expect(filterSlashCommands(commands, "MoD", []).map((command) => command.command)).toEqual(["/model list", "/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, "", []).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 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", "budget none", "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).sort((a, b) => b.length - a.length)); + for (const suffix of suffixes) { + expect(match(prefix + suffix).at(-1)?.command).toBe(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 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 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"]); + 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 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([]); }); it("orders empty-query results by recent commands without storing /stop", () => { @@ -49,11 +137,33 @@ 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("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", ["/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); + } + } }); it("localizes builtin command titles and descriptions for Chinese UI", () => { @@ -101,7 +211,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/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")]); + }); +}); 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..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 @@ -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).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"'); @@ -80,12 +79,7 @@ describe("ApiKeyPage source", () => { 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 +89,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 +104,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 +134,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..101c32995 --- /dev/null +++ b/App/frontend/desktop/src/pages/tests/byok-credential-consistency.interaction.test.tsx @@ -0,0 +1,847 @@ +// @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 { 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() })); +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 }) => { + void input; + return { 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(); + const saveCatalog = vi.spyOn(client, "saveModelCatalog"); + return { + client, probe, write, writer, saveCatalog, + 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 () => { + await act(async () => { + await Promise.allSettled(server.saveCatalog.mock.results.map((result) => result.value)); + }); + 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); + }); + + 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(); + 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(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(); + 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 }) => { + 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 () => { + 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/byok-setup-save-feedback.interaction.test.tsx b/App/frontend/desktop/src/pages/tests/byok-setup-save-feedback.interaction.test.tsx index f6cd72a99..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 @@ -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; @@ -263,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) @@ -413,29 +424,67 @@ 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())) - }); + 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 changeField("apiKey.asrKey", "sk-asr"); await click(testButton()); - await changeField("apiKey.imageGenKey", "sk-image"); + await vi.waitFor(() => expect(testModelConfig).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ endpointId: asrEndpointId, apiKey: "" }), + "asr", + "asr" + )); + await click(testButton(1)); - await vi.waitFor(() => expect(button("apiKey.next").disabled).toBe(false)); - await click(button("apiKey.next")); + 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 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); + 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 () => { 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..b514006b2 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, @@ -270,7 +271,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 && ("); @@ -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(); 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"); }); 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)"); 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 }; +} 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..8dc89b35a 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)); @@ -5781,6 +5791,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; @@ -11070,6 +11088,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); diff --git a/App/memmy-agent/package.json b/App/memmy-agent/package.json index 509cdfea9..3e462ec13 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-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 new file mode 100644 index 000000000..6955161aa --- /dev/null +++ b/App/memmy-agent/scripts/build-npm-package.mjs @@ -0,0 +1,99 @@ +import { chmod, 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"; + +const scriptRoot = dirname(fileURLToPath(import.meta.url)); +const agentRoot = resolve(scriptRoot, ".."); +const repoRoot = resolve(agentRoot, "../.."); +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"; + +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 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")); + +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) { + if (command === "npm") ({ command, args } = npmInvocation(args)); + 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/App/memmy-agent/scripts/build-runtime.mjs b/App/memmy-agent/scripts/build-runtime.mjs new file mode 100644 index 000000000..0a1d934c6 --- /dev/null +++ b/App/memmy-agent/scripts/build-runtime.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import { npmInvocation } from "../../../scripts/internal/shared/npm-invocation.mjs"; +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."); + +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 }); + // 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")); + 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 asset = await writeAgentRuntimeRelease(archive, manifest.version, target); + console.log(archive); + console.log(`SHA-256: ${asset.sha256}\nChecksum file: ${archive}.sha256`); +} finally { + // 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) { + 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/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/App/memmy-agent/src/config/schema.ts b/App/memmy-agent/src/config/schema.ts index 9820534b8..293e33690 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, }; } @@ -1168,6 +1165,7 @@ export class FileMemoryConfig extends Base { export class Config extends Base { app: Dict; + agentHooks: string[]; agents: AgentsConfig; providers: ProvidersConfig; channels: ChannelsConfig; @@ -1208,6 +1206,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 +1455,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..9b5897709 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"; @@ -119,6 +120,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 +736,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 +1138,7 @@ export class AgentLoop { } async initializeRuntimeTools(): Promise { + await this.ensureExternalHooksLoaded(); await initializeDesktopScreenCapture(); await this.connectMcp(); await this.browserSessionManager.initialize(); @@ -1145,9 +1151,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 +1342,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 +1361,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 +1375,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" }, }), ); @@ -3866,6 +3907,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 +3930,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 +3938,7 @@ export class AgentLoop { spec: { hostProjectId: ctx.sessionProjectId, workspace: sessionWorkspace, turnId: ctx.turnId, abortSignal: ctx.abortSignal }, metadata: { lifecycle: "system_prompt" }, })); + requestTiming("agent.prompt_hooks_end"); const compactionOptions: { replayMaxMessages: number | null; notifyOnLockWait?: boolean; @@ -3920,11 +3964,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 +4082,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; @@ -5479,7 +5527,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 bfe6ef224..b9ae85fb4 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"; @@ -11,7 +12,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, @@ -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); @@ -808,10 +810,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 +823,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 +1498,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 +1516,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 +1556,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 +1616,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 +1735,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 +1789,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 +1820,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 +1840,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 +1883,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 +1941,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 +1948,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/src/core/agent-runtime/tools/apply-patch.ts b/App/memmy-agent/src/core/agent-runtime/tools/apply-patch.ts index cea810679..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 @@ -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,17 @@ 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("/")); + private resolveNormalizedPath(normalized: string, requestedPath = normalized): string { + 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 +357,24 @@ Paths must be workspace-relative. Use at most 20 file operations in one patch.`, 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("/"); + 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; @@ -377,9 +398,9 @@ Paths must be workspace-relative. Use at most 20 file operations in one patch.`, 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)); } } @@ -610,7 +631,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/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/src/core/agent-runtime/tools/patch-envelope.ts b/App/memmy-agent/src/core/agent-runtime/tools/patch-envelope.ts index a2b65f5ae..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 @@ -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 (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/src/entrypoints/cli/commands.ts b/App/memmy-agent/src/entrypoints/cli/commands.ts index 87c72cd63..98ec75e89 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) @@ -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); } @@ -923,7 +927,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 0ff0484a9..325100c0e 100644 --- a/App/memmy-agent/src/entrypoints/openai-like-api/server.ts +++ b/App/memmy-agent/src/entrypoints/openai-like-api/server.ts @@ -1,9 +1,11 @@ +import { requestTiming, withRequestTiming } from "../../utils/request-timing.js"; import fs from "node:fs"; import path from "node:path"; 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"; @@ -22,8 +24,8 @@ export class RequestBodyTooLarge extends Error { type ApiContext = { agentLoop: any; modelName: string; - requestTimeout: number; sessionLocks: Map>; + activeRequests: Map }>; }; type ChatCompletionUsage = { @@ -107,6 +109,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 { @@ -175,33 +188,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); } } @@ -212,9 +212,11 @@ async function callAgent(ctx: ApiContext, args: Record): Promise): Promise): 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", - requestTimeout: 120, sessionLocks: new Map>(), + activeRequests: new Map(), }; const contentType = request.headers.get("content-type") ?? ""; let text = ""; @@ -249,6 +258,7 @@ export async function handleChatCompletions(request: Request, ctx?: ApiContext): let sessionId: string | null | undefined; let requestedModel: string | null | undefined; let stream = false; + let requestId: string = crypto.randomUUID(); try { if (contentType.startsWith("multipart/")) { @@ -264,6 +274,12 @@ export async function handleChatCompletions(request: Request, ctx?: ApiContext): 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); @@ -275,7 +291,18 @@ export async function handleChatCompletions(request: Request, ctx?: ApiContext): } 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, @@ -283,6 +310,9 @@ export async function handleChatCompletions(request: Request, ctx?: ApiContext): channel: "api", chat_id: API_CHAT_ID, chatId: API_CHAT_ID, + // An explicit API model selects this server's configured default, overriding a stale session binding. + ...(requestedModel && typeof context.agentLoop?.modelPreset === "string" + ? { metadata: { model_preset: context.agentLoop.modelPreset } } : {}), }; if (stream) { @@ -290,12 +320,12 @@ export async function handleChatCompletions(request: Request, ctx?: ApiContext): 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; } @@ -304,7 +334,7 @@ export async function handleChatCompletions(request: Request, ctx?: ApiContext): if (closed) return; closed = true; try { - controller.close(); + streamController.close(); } catch { // The client may have already disconnected. } @@ -312,35 +342,71 @@ export async function handleChatCompletions(request: Request, ctx?: ApiContext): 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)); + 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 () => { - const response = await withTimeout( - Promise.resolve( - callAgent(context, { - ...baseArgs, - onStream, - onStreamEnd, - }), - ), - context.requestTimeout, - ); + 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, { + ...baseArgs, + onProgress, + onStream, + onStreamEnd, + }); + controller.signal.throwIfAborted(); if (!emitted) { const finalText = responseText(response); if (finalText.trim()) write(sseChunk(finalText, context.modelName, chunkId)); } }); - } catch { + } catch (error) { + requestTiming("api.error"); failed = true; + const selectionUnavailable = error instanceof Error && error.message === "model_selection_unavailable"; + write(`data: ${JSON.stringify({ error: { + 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) { write(sseChunk("", context.modelName, chunkId, "stop")); write(SSE_DONE); } + requestTiming("api.finished"); close(); + finishRequest(); } })(); }, @@ -360,11 +426,14 @@ export async function handleChatCompletions(request: Request, ctx?: ApiContext): try { const reply = await withSessionLock(context, sessionKey, async () => { - const first = await withTimeout(Promise.resolve(callAgent(context, baseArgs)), context.requestTimeout); + 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 withTimeout(Promise.resolve(callAgent(context, baseArgs)), context.requestTimeout); + 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; @@ -373,10 +442,65 @@ export async function handleChatCompletions(request: Request, ctx?: ApiContext): }); 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); + 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> { + 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 { @@ -395,14 +519,15 @@ 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>(), + activeRequests: new Map(), }; return { context: ctx, @@ -410,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/src/providers/openai-compat-provider.ts b/App/memmy-agent/src/providers/openai-compat-provider.ts index 7b9b72d9b..9304ec8fe 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 { @@ -51,7 +52,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; @@ -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/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/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/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) => { 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..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 @@ -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"); }); @@ -125,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( @@ -182,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/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); + }); +}); 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..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 @@ -1,3 +1,4 @@ +import path from "node:path"; import { describe, expect, it } from "vitest"; import { PatchError, @@ -121,14 +122,26 @@ 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", "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", () => { 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"); diff --git a/App/memmy-agent/tests/entrypoints/cli/commands.test.ts b/App/memmy-agent/tests/entrypoints/cli/commands.test.ts index 361b34048..580a5d4f8 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); @@ -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 19a84b06b..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 @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { SSE_DONE, sseChunk, createApp } from "../../../src/entrypoints/openai-like-api/server.js"; +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 { 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( { @@ -258,5 +293,120 @@ describe("SSE chat completions", () => { 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]"); }); }); + +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(); + } + }); +} + +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 c81b232d8..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 @@ -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"); @@ -102,8 +122,8 @@ describe("OpenAI-compatible API request validation", () => { { agentLoop: { processDirect: async () => "unused" }, modelName: "test-model", - requestTimeout: 10, sessionLocks: new Map(), + activeRequests: new Map(), }, ); const body = (await response.json()) as any; 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); }); 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"); +}); diff --git a/App/shell/desktop/src/main/runtime-services.ts b/App/shell/desktop/src/main/runtime-services.ts index a520c1af2..d2e84d27f 100644 --- a/App/shell/desktop/src/main/runtime-services.ts +++ b/App/shell/desktop/src/main/runtime-services.ts @@ -1896,8 +1896,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-memory-upgrade.test.ts b/App/shell/desktop/tests/runtime-memory-upgrade.test.ts index dc7d28e53..f48942d47 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,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: process.env.FIXTURE_HEALTHY !== 'false', 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();", "});", @@ -117,6 +123,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 +324,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"); 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"); 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/Memory/readme.md b/Memory/readme.md index 624f19346..9ecedc690 100644 --- a/Memory/readme.md +++ b/Memory/readme.md @@ -72,11 +72,31 @@ 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. +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. 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/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 { 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/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/cli/scripts/build-runtime.mjs b/Memory/src/cli/scripts/build-runtime.mjs index 9e1dfba5a..81e800286 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 { memoryRuntimeBuildIdentity } from "../../../../scripts/internal/shared/memory-runtime-build-identity.mjs"; import { createHash } from "node:crypto"; import { createReadStream, existsSync } from "node:fs"; @@ -8,6 +9,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)); @@ -68,9 +70,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 = { ...buildIdentity, name: assetName, sha256: await sha256File(assetPath), size: (await stat(assetPath)).size }; await updateReleaseManifest(outputRoot, version, target, descriptor); process.stdout.write(`${assetPath}\n`); @@ -116,7 +124,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/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..9be342fe1 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,37 +38,54 @@ 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 > 0 && item.tokens.length <= inputTokenBudget)) return null; - const chunks = encoded.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)); 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({ - 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), @@ -96,10 +121,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 +159,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/memory-service.ts b/Memory/src/service/memory-service.ts index bb44dab52..7e389f70e 100644 --- a/Memory/src/service/memory-service.ts +++ b/Memory/src/service/memory-service.ts @@ -2009,6 +2009,13 @@ export class MemoryService { const deletedMemoryIds: string[] = []; this.repos.transaction(() => { + const spanIds = new Set(this.repos.memories.listTaskSpanIds( + episode.userId, episode.id, episode.l1MemoryIds + )); + for (const spanId of spanIds) { + 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/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 181d12d5d..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, @@ -1261,8 +1262,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 +1311,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 +1354,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 +1592,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}`) && @@ -2927,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), diff --git a/Memory/src/service/worker/job-handlers.ts b/Memory/src/service/worker/job-handlers.ts index 8eb2d8bc2..589344a6e 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" }; } @@ -594,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/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; + return rows.map((row) => row.id); + } + getByKey(memoryLayer: MemoryLayer, key: string): MemoryRow | undefined { const row = this.db .prepare( @@ -1678,7 +1694,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 +1713,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 +1735,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}) @@ -1847,17 +1869,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 +1894,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 +1931,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/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(); + } }); }); diff --git a/Memory/tests/config.test.ts b/Memory/tests/config.test.ts index be064876e..28315065f 100644 --- a/Memory/tests/config.test.ts +++ b/Memory/tests/config.test.ts @@ -147,12 +147,16 @@ describe("memmy memory config", () => { 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/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/embedding-input-guard.test.ts b/Memory/tests/embedding-input-guard.test.ts new file mode 100644 index 000000000..941e61aa7 --- /dev/null +++ b/Memory/tests/embedding-input-guard.test.ts @@ -0,0 +1,78 @@ +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([ + { 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)] +])("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/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/embedding/embedding-processing.test.ts b/Memory/tests/service/embedding/embedding-processing.test.ts index a0cdb8266..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.", @@ -203,12 +300,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 +339,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 +440,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 +487,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({ @@ -715,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 { @@ -747,8 +876,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, diff --git a/Memory/tests/service/evolution/span-big-turn.test.ts b/Memory/tests/service/evolution/span-big-turn.test.ts index 64110bc41..270669764 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,134 @@ 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(); + }); + + 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(); }); 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({ diff --git a/Memory/tests/service/session/source-turn-capture.test.ts b/Memory/tests/service/session/source-turn-capture.test.ts index 88f279f80..c0cacdb32 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", () => { @@ -1009,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"); 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..fba044273 --- /dev/null +++ b/docs/cn/reference/slash-commands.mdx @@ -0,0 +1,69 @@ +--- +title: 斜杠命令一览 +icon: Terminal +--- + +本文整理聊天输入框中的全部 14 个斜杠命令,包括 13 个 Agent 内置命令和桌面专用的 `/last-compaction`,不包含终端里的 `memmy` CLI 子命令。 + +## 输入与提示 + +- 输入 `/` 后,面板默认显示最多 5 行,其余命令可滚动查看。较小窗口下可见行数会进一步减少。 +- 继续输入名称可按命令名前缀筛选,不匹配说明文字。例如 `/s` 匹配 `/stop` 和 `/status`,`/stop` 只匹配 `/stop`。筛选不会截断结果。 +- 主命令、完整子命令和可用模型预设处于同一候选列表,统一按完整输入做前缀匹配,不限制层级。例如 `/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 是自由参数,不会被替换成子命令。填写完成后发送执行。 +- 使用上下方向键选择,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`。 diff --git a/package-lock.json b/package-lock.json index 462fd745d..fb8d57ee7 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 b867317f5..383e21434 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", @@ -55,6 +55,10 @@ "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", "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", @@ -87,6 +91,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/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/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/collect-runtime-artifacts.mjs b/scripts/internal/shared/collect-runtime-artifacts.mjs new file mode 100644 index 000000000..390ca422d --- /dev/null +++ b/scripts/internal/shared/collect-runtime-artifacts.mjs @@ -0,0 +1,43 @@ +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 [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 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 = {}; + 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/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/scripts/internal/shared/verify-runtime-artifacts.mjs b/scripts/internal/shared/verify-runtime-artifacts.mjs new file mode 100644 index 000000000..a5bbd37b7 --- /dev/null +++ b/scripts/internal/shared/verify-runtime-artifacts.mjs @@ -0,0 +1,84 @@ +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 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; +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 { + 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"); + 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"); + } + } + 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:"); + 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 ${selection === "both" ? `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/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; + } +} diff --git a/tests/runtime-artifacts.test.mjs b/tests/runtime-artifacts.test.mjs new file mode 100644 index 000000000..c345d8b57 --- /dev/null +++ b/tests/runtime-artifacts.test.mjs @@ -0,0 +1,130 @@ +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"; +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"); +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") } } }; + 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")]; + 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); + 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, 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"); +}); + +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); +}); 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] }); +});