Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/cheaperjamren_leemo.json
T
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
42 KiB
JSON

{"repo": "cheaperjamren/leemo", "files": {"tests/bridge/contract.test.ts": {"9170761f97ab6d8a7aee1187381ba21a5258d069": {"t": 1786068365.807541, "n": 16089, "h": "2ded3e53205de886", "c": "import { describe, it, expect } from \"vitest\";\nimport {\n BRIDGE_CHANNELS,\n KNOWN_PROVIDER_KINDS,\n type ProviderSpec,\n type ProviderAuthMode,\n type ProviderKind,\n type ProviderCapabilities,\n type PermissionMode,\n type PermissionPolicy,\n type UsageSummary,\n type UsageSummaryQuery,\n type BridgeInvokeMap,\n type BridgeEventMap,\n type BridgeEventEnvelope,\n type CreateConversationRequest,\n type SendRequest,\n type ConversationRef,\n} from \"../../src/bridge/contract\";\n\n// B3 — contract freeze guards. contract.ts is (almost) all TYPES, erased at\n// runtime, so these tests do two things a type-only module CAN assert at\n// runtime: (1) exercise the runtime constants, and (2) construct typed object\n// literals — which only compile if the mandated extensibility axes truly exist\n// as type fields (not just prose in the 09 doc). If an axis were removed, this\n// file would fail `npm run typecheck`, not just the suite.\n\ndescribe(\"contract — extensibility axes exist in real types (not just prose)\", () => {\n it(\"ProviderSpec carries authMode / kind / apiFormat / capabilities as real fields\", () => {\n // An OAuth-subscription, CUSTOM-kind provider with quota (no balance API) —\n // the exact future shape user 7/21 called out. This ONLY compiles if every\n // axis is a real field.\n const oauthCustom: ProviderSpec = {\n id: \"my-claude-max\",\n name: \"My Claude Max Sub\",\n kind: \"custom\", // open string, not a closed union\n category: \"custom\",\n apiFormat: \"anthropic\",\n authMode: \"oauth-subscription\", // reserved axis, real field\n baseUrl: \"https://example.invalid\",\n models: [\"some-model\"],\n capabilities: {\n balanceApi: false,\n modelDiscovery: true,\n subscriptionPlan: true, // quota not balance\n },\n };\n expect(oauthCustom.authMode).toBe(\"oauth-subscription\");\n expect(oauthCustom.capabilities.subscriptionPlan).toBe(true);\n expect(oauthCustom.capabilities.balanceApi).toBe(false);\n\n // A first-release api-key provider (the only authMode actually implemented).\n const apiKeyProvider: ProviderSpec = {\n id: \"deepseek\",\n name: \"DeepSeek\",\n kind: \"deepseek\",\n category: \"cn_official\",\n apiFormat: \"anthropic\",\n authMode: \"api-key\",\n baseUrl: \"https://api.deepseek.com/anthropic\",\n apiKeyUrl: \"https://platform.deepseek.com\",\n models: [\"deepseek-chat\"],\n capabilities: { balanceApi: true, modelDiscovery: false, subscriptionPlan: false },\n };\n expect(apiKeyProvider.authMode).toBe(\"api-key\");\n });\n\n it(\"ProviderKind is an OPEN string — an unknown/custom family needs no contract change\", () => {\n // A brand-new family string not in KNOWN_PROVIDER_KINDS still typechecks.\n const novelKind: ProviderKind = \"some-provider-invented-next-year\";\n const known: ProviderKind = KNOWN_PROVIDER_KINDS[0];\n expect(typeof novelKind).toBe(\"string\");\n expect(KNOWN_PROVIDER_KINDS).toContain(known);\n // Reference set is non-exhaustive but includes custom as first-class.\n expect(KNOWN_PROVIDER_KINDS).toContain(\"custom\");\n });\n\n it(\"capabilities is the dispatch axis (declared), never an id→capability map\", () => {\n const caps: ProviderCapabilities = {\n balanceApi: true,\n modelDiscovery: true,\n subscriptionPlan: false,\n };\n // The point: balance support is read off caps.balanceApi, NOT off the id.\n expect(caps.balanceApi).toBe(true);\n });\n\n it(\"authMode union includes the local no-key value ('none') plus the two reserved axes\", () => {\n // 07/21 revision widened the union with 'none' (local models: Ollama / LM\n // Studio need no key). Still asserts every reserved axis is present.\n const a: ProviderAuthMode = \"api-key\";\n const b: ProviderAuthMode = \"oauth-subscription\";\n const c: ProviderAuthMode = \"none\";\n expect([a, b, c]).toEqual([\"api-key\", \"oauth-subscription\", \"none\"]);\n });\n});\n\ndescribe(\"contract — policy-driven approval types (07/21 B3 revision)\", () => {\n it(\"PermissionMode carries the four Claude-Code-aligned modes incl. bypassPermissions\", () => {\n const modes: PermissionMode[] = [\"default\", \"acceptEdits\", \"bypassPermissions\", \"plan\"];\n expect(modes).toContain(\"bypassPermissions\");\n expect(modes).toContain(\"plan\");\n expect(modes.length).toBe(4);\n });\n\n it(\"PermissionPolicy pairs a mode with the dangerousCommandCaching toggle\", () => {\n const safe: PermissionPolicy = { mode: \"acceptEdits\", dangerousCommandCaching: false };\n const loose: PermissionPolicy = { mode: \"bypassPermissions\", dangerousCommandCaching: true };\n expect(safe.dangerousCommandCaching).toBe(false);\n expect(loose.mode).toBe(\"bypassPermissions\");\n expect(loose.dangerousCommandCaching).toBe(true);\n });\n\n it(\"CreateConversationRequest accepts an OPTIONAL permissionMode override\", () => {\n const base: CreateConversationRequest = { providerId: \"deepseek\", modelId: \"deepseek-chat\" };\n const overridden: CreateConversationRequest = {\n providerId: \"deepseek\",\n modelId: \"deepseek-chat\",\n permissionMode: \"bypassPermissions\",\n };\n expect(base.permissionMode).toBeUndefined();\n expect(overridden.permissionMode).toBe(\"bypassPermissions\");\n });\n\n it(\"CreateConversationRequest accepts OPTIONAL momo persona context (轮2卡A)\", () => {\n // Additive-only, per the Batch -1 precedent: every field is optional, so\n // existing callers (wiki popup, fixtures, tests) keep compiling untouched.\n const bare: CreateConversationRequest = { providerId: \"deepseek\", modelId: \"deepseek-chat\" };\n const withPersona: CreateConversationRequest = {\n providerId: \"deepseek\",\n modelId: \"deepseek-chat\",\n mode: \"buddy\",\n personaText: \"你是 momo。\",\n talkStyle: 2,\n webSearchEnabled: false,\n };\n expect(bare.personaText).toBeUndefined();\n expect(bare.mode).toBeUndefined();\n expect(withPersona.mode).toBe(\"buddy\");\n expect(withPersona.talkStyle).toBe(2);\n expect(withPersona.webSearchEnabled).toBe(false);\n // personaText carries the RESOLVED card body, never a card id: the host has\n // no persona-card registry, so an id would be unresolvable there.\n expect(withPersona.personaText).toContain(\"momo\");\n });\n\n it(\"CreateConversationRequest accepts OPTIONAL conversationId + resumeSessionId (轮2卡C)\", () => {\n // Restart continuity: the renderer re-claims a persisted conversation id\n // (host Maps are memory-only) and hands back the persisted session id as\n // the resume start point. Both optional — an ordinary create omits them.\n const fresh: CreateConversationRequest = { providerId: \"deepseek\", modelId: \"deepseek-chat\" };\n const reclaimed: CreateConversationRequest = {\n providerId: \"deepseek\",\n modelId: \"deepseek-chat\",\n conversationId: \"cid-from-sqlite\",\n resumeSessionId: \"sess-from-sqlite\",\n };\n expect(fresh.conversationId).toBeUndefined();\n expect(fresh.resumeSessionId).toBeUndefined();\n expect(reclaimed.conversationId).toBe(\"cid-from-sqlite\");\n expect(reclaimed.resumeSessionId).toBe(\"sess-from-sqlite\");\n });\n\n it(\"SendRequest carries an optional renderer message id for memory provenance\", () => {\n const legacy: SendRequest = { conversationId: \"c-1\", prompt: \"继续\" };\n const traced: SendRequest = {\n conversationId: \"c-1\",\n prompt: \"记住我偏好先给结论\",\n sourceMessageId: \"u7\",\n };\n expect(legacy.sourceMessageId).toBeUndefined();\n expect(traced.sourceMessageId).toBe(\"u7\");\n });\n\n it(\"CreateConversationRequest constrains mode and talkStyle to their unions\", () => {\n // @ts-expect-error mode is the closed 搭子/工作台 pair.\n const badMode: CreateConversationRequest = { providerId: \"d\", modelId: \"m\", mode: \"kiosk\" };\n // @ts-expect-error talkStyle is the three-stop slider (1|2|3).\n const badStyle: CreateConversationRequest = { providerId: \"d\", modelId: \"m\", talkStyle: 4 };\n expect(badMode.mode).toBe(\"kiosk\");\n expect(badStyle.talkStyle).toBe(4);\n });\n\n it(\"CreateConversationRequest accepts main/wiki purpose metadata only\", () => {\n const main: CreateConversationRequest = {\n providerId: \"deepseek\",\n modelId: \"deepseek-chat\",\n purpose: \"main\",\n };\n const wiki: CreateConversationRequest = {\n providerId: \"deepseek\",\n modelId: \"deepseek-chat\",\n purpose: \"wiki\",\n };\n // @ts-expect-error purpose is a closed two-value metadata field.\n const invalid: CreateConversationRequest = { providerId: \"deepseek\", modelId: \"deepseek-chat\", purpose: \"other\" };\n expect(main.purpose).toBe(\"main\");\n expect(wiki.purpose).toBe(\"wiki\");\n expect(invalid).toBeDefined();\n });\n});\n\ndescribe(\"contract — local no-key provider + NewMax capability axes (07/21)\", () => {\n it(\"authMode 'none' + capabilities.local typecheck for a local provider (Ollama)\", () => {\n // The exact local-model shape user 7/21 called out: no key, points at a\n // loopback endpoint. ONLY compiles if 'none' and `local` are real fields.\n const ollama: ProviderSpec = {\n id: \"ollama-local\",\n name: \"Ollama (local)\",\n kind: \"custom\",\n category: \"custom\",\n apiFormat: \"openai\",\n authMode: \"none\",\n baseUrl: \"http://127.0.0.1:11434/v1\",\n models: [\"llama3.1\"],\n capabilities: {\n balanceApi: false,\n modelDiscovery: true,\n subscriptionPlan: false,\n local: true,\n },\n };\n expect(ollama.authMode).toBe(\"none\");\n expect(ollama.capabilities.local).toBe(true);\n });\n\n it(\"ProviderCapabilities NewMax-parity axes are real optional fields\", () => {\n const caps: ProviderCapabilities = {\n balanceApi: false,\n modelDiscovery: true,\n subscriptionPlan: false,\n local: true,\n protocolSwitchable: true,\n multiKey: true,\n requiresProxy: true,\n };\n expect(caps.protocolSwitchable).toBe(true);\n expect(caps.multiKey).toBe(true);\n expect(caps.requiresProxy).toBe(true);\n // All optional — a minimal capabilities object (no NewMax axes) still valid.\n const minimal: ProviderCapabilities = {\n balanceApi: true,\n modelDiscovery: false,\n subscriptionPlan: false,\n };\n expect(minimal.local).toBeUndefined();\n });\n});\n\ndescribe(\"contract — reserved Phase-1 usage-summary types are usable now\", () => {\n it(\"UsageSummaryQuery + UsageSummary construct (contract occupies the slot pre-impl)\", () => {\n const q: UsageSummaryQuery = { range: \"last7d\", providerId: \"deepseek\" };\n const s: UsageSummary = {\n totalCostUsd: \"1.234560\",\n byProvider: [{ providerId: \"deepseek\", costUsd: \"1.234560\", inputTokens: 100, outputTokens: 50 }],\n byDay: [{ date: \"2026-07-21\", costUsd: \"1.234560\" }],\n };\n expect(q.range).toBe(\"last7d\");\n expect(s.byProvider[0].providerId).toBe(\"deepseek\");\n });\n});\n\ndescribe(\"contract — channel table is frozen + payload types correspond 1:1\", () => {\n it(\"BRIDGE_CHANNELS values are the bridge: namespaced strings\", () => {\n expect(BRIDGE_CHANNELS.createConversation).toBe(\"bridge:createConversation\");\n expect(BRIDGE_CHANNELS.event).toBe(\"bridge:event\");\n expect(BRIDGE_CHANNELS.approvalRequest).toBe(\"bridge:approvalRequest\");\n expect(BRIDGE_CHANNELS.askUser).toBe(\"bridge:askUser\");\n expect(BRIDGE_CHANNELS.usageSummary).toBe(\"bridge:usageSummary\");\n expect(BRIDGE_CHANNELS.listWhitelist).toBe(\"bridge:listWhitelist\");\n expect(BRIDGE_CHANNELS.revokeWhitelist).toBe(\"bridge:revokeWhitelist\");\n expect(BRIDGE_CHANNELS.listMemory).toBe(\"bridge:listMemory\");\n expect(BRIDGE_CHANNELS.undoMemory).toBe(\"bridge:undoMemory\");\n expect(BRIDGE_CHANNELS.pickSkillSource).toBe(\"bridge:pickSkillSource\");\n });\n\n it(\"every BRIDGE_CHANNELS value is a key in exactly one of the invoke/event maps\", () => {\n // Runtime mirror of the invoke/event key sets (kept in lockstep with the\n // typed BridgeInvokeMap/BridgeEventMap — a missing entry here would be an\n // obvious drift signal in review).\n const invokeKeys = new Set<keyof BridgeInvokeMap>([\n \"bridge:createConversation\",\n \"bridge:send\",\n \"bridge:interrupt\",\n \"bridge:setModel\",\n \"bridge:updateContext\",\n \"bridge:disposeConversation\",\n \"bridge:listProviders\",\n \"bridge:fetchBalance\",\n \"bridge:usageSummary\",\n \"bridge:listWhitelist\",\n \"bridge:revokeWhitelist\",\n \"bridge:approvalDecision\",\n \"bridge:askUserAnswer\",\n // 轮 2 卡 E\n \"bridge:listSkills\",\n \"bridge:openSkillsDir\",\n \"bridge:syncEnabledSkills\",\n \"bridge:inspectSkillSource\",\n \"bridge:pickSkillSource\",\n \"bridge:installSkill\",\n \"bridge:listCommunitySkills\",\n \"bridge:installCommunitySkill\",\n \"bridge:scanInstalledSkill\",\n \"bridge:removeSkill\",\n // 轮 10 — governed memory management\n \"bridge:listMemory\",\n \"bridge:updateMemory\",\n \"bridge:deleteMemory\",\n \"bridge:pinMemory\",\n \"bridge:memoryHistory\",\n \"bridge:undoMemory\",\n \"bridge:openMemoryDir\",\n // 轮 4 卡 H — 搜索源 key\n \"bridge:getSearchSources\",\n \"bridge:saveSearchKey\",\n \"bridge:searchAcademic\",\n // 轮 7 — MCP + built-in browser\n \"bridge:listMcpServers\",\n \"bridge:saveMcpServer\",\n \"bridge:deleteMcpServer\",\n \"bridge:testMcpServer\",\n \"bridge:readBrowserCapture\",\n // 轮 3 卡 F — provider configuration\n \"bridge:getProviderConfig\",\n \"bridge:saveProvider\",\n \"bridge:deleteProvider\",\n \"bridge:testConnection\",\n \"bridge:listRemoteModels\",\n ]);\n const eventKeys = new Set<keyof BridgeEventMap>([\n \"bridge:event\",\n \"bridge:approvalRequest\",\n \"bridge:askUser\",\n ]);\n\n for (const value of Object.values(BRIDGE_CHANNELS)) {\n const inInvoke = invokeKeys.has(value as keyof BridgeInvokeMap);\n const inEvent = eventKeys.has(value as keyof BridgeEventMap);\n // Exactly one of the two (XOR): no channel is both, none is neither.\n expect(inInvoke !== inEvent).toBe(true);\n }\n // And the count matches (no map key without a channel constant).\n expect(invokeKeys.size + eventKeys.size).toBe(Object.keys(BRIDGE_CHANNELS).length);\n });\n\n it(\"IPC request projections reference providers by id (no key crosses)\", () => {\n const req: CreateConversationRequest = { providerId: \"deepseek\", modelId: \"deepseek-chat\" };\n const ref: ConversationRef = { conversationId: \"c-123\" };\n expect(req.providerId).toBe(\"deepseek\");\n expect(ref.conversationId).toBe(\"c-123\");\n // CreateConversationRequest has no `apiKey` field — enforced structurally by\n // the type; asserted here for the reader.\n expect(\"apiKey\" in req).toBe(false);\n });\n\n it(\"envelopes bridge:event once while keeping LeemoEvent semantic payloads bare\", () => {\n const envelope: BridgeEventEnvelope = {\n conversationId: \"conv-1\",\n event: { type: \"text.delta\", text: \"hello\" },\n };\n const payload: BridgeEventMap[\"bridge:event\"] = envelope;\n // @ts-expect-error bridge:event no longer accepts a naked LeemoEvent.\n const legacyPayload: BridgeEventMap[\"bridge:event\"] = { type: \"text.delta\", text: \"hello\" };\n expect(payload.conversationId).toBe(\"conv-1\");\n expect(payload.event).toEqual({ type: \"text.delta\", text: \"hello\" });\n expect(legacyPayload).toBeDefined();\n });\n\n it(\"adds whitelist list/revoke mappings with the existing RiskLevel\", () => {\n const listRequest: BridgeInvokeMap[\"bridge:listWhitelist\"][\"request\"] = undefined;\n const listResponse: BridgeInvokeMap[\"bridge:listWhitelist\"][\"response\"] = [\n { toolName: \"Bash\", risk: \"dangerous\" },\n ];\n const revokeRequest: BridgeInvokeMap[\"bridge:revokeWhitelist\"][\"request\"] = {\n toolName: \"Bash\",\n risk: \"dangerous\",\n };\n const revokeResponse: BridgeInvokeMap[\"bridge:revokeWhitelist\"][\"response\"] = undefined;\n expect(listRequest).toBeUndefined();\n expect(listResponse).toEqual([{ toolName: \"Bash\", risk: \"dangerous\" }]);\n expect(revokeRequest).toEqual({ toolName: \"Bash\", risk: \"dangerous\" });\n expect(revokeResponse).toBeUndefined();\n });\n});\n"}}, "tests/main/persistence.test.ts": {"9170761f97ab6d8a7aee1187381ba21a5258d069": {"t": 1786068446.138739, "n": 24649, "h": "164af28eb8ea12a8", "c": "import { describe, it, expect, beforeEach } from \"vitest\";\nimport Database from \"better-sqlite3\";\nimport { createPersistence } from \"../../src/main/persistence/schema\";\nimport type { ConversationMeta } from \"../../src/renderer/stores/conversations\";\nimport type { TimelineItem } from \"../../src/renderer/stores/message-model\";\nimport type { WikiEntry } from \"../../src/renderer/stores/wiki-entries\";\nimport type { ScheduledTask, ScheduledTaskRun } from \"../../src/scheduled-tasks\";\n\nfunction makeDb() {\n return new Database(\":memory:\");\n}\n\nconst meta: ConversationMeta = {\n id: \"c1\",\n title: \"测试对话\",\n titleManuallyUpdated: false,\n bookId: null,\n source: \"buddy\",\n providerId: \"deepseek\",\n modelId: \"deepseek-chat\",\n createdAt: 1000,\n lastActivityAt: 2000,\n unread: false,\n pinned: false,\n archived: false,\n lastOpenedAt: 2000,\n} as ConversationMeta;\n\nconst items: TimelineItem[] = [\n { kind: \"text\", id: \"u0\", runId: \"run-1\", role: \"user\", text: \"hello\", streaming: false },\n { kind: \"text\", id: \"m1\", runId: \"run-1\", role: \"momo\", text: \"hi\", streaming: false },\n];\n\nconst wikiEntry: WikiEntry = {\n id: \"wiki-1\",\n workspaceId: \"workspace-course\",\n filePath: \"/foo.ts\",\n quotedText: \"some code\",\n turns: [{ question: \"what?\", answer: \"this\" }],\n createdAt: 3000,\n};\n\ndescribe(\"persistence schema\", () => {\n let db: InstanceType<typeof Database>;\n let p: ReturnType<typeof createPersistence>;\n\n beforeEach(() => {\n db = makeDb();\n p = createPersistence(db);\n });\n\n it(\"saves and loads a conversation with timeline\", () => {\n p.saveConversation(meta, items);\n const snap = p.loadAll();\n expect(snap.conversations).toHaveLength(1);\n expect(snap.conversations[0].meta).toEqual(meta);\n expect(snap.conversations[0].timeline).toEqual(items);\n });\n\n it(\"upserts conversation (idempotent)\", () => {\n p.saveConversation(meta, items);\n p.saveConversation({ ...meta, title: \"updated\" }, items);\n const snap = p.loadAll();\n expect(snap.conversations).toHaveLength(1);\n expect(snap.conversations[0].meta.title).toBe(\"updated\");\n });\n\n it(\"rebuilds the disposable conversation, message, and usage index exactly\", () => {\n const usage: TimelineItem[] = [\n ...items,\n { kind: \"usage\", id: \"usage-1\", runId: \"run-1\", usage: { providerId: \"deepseek\", modelId: \"deepseek-chat\", inputTokens: 3, outputTokens: 2, cacheReadTokens: 0, cacheCreationTokens: 0, costSource: \"unpriced\", tokensEstimated: false } },\n ];\n p.saveConversation(meta, usage);\n p.saveConversation({ ...meta, id: \"stale\", title: \"过期索引\" }, items);\n\n p.rebuildConversationIndex([{ meta: { ...meta, title: \"来自本子归档\" }, timeline: items }]);\n\n expect(p.loadAll().conversations).toEqual([\n { meta: { ...meta, title: \"来自本子归档\" }, timeline: items },\n ]);\n expect((db.prepare(\"SELECT COUNT(*) AS n FROM conversations\").get() as { n: number }).n).toBe(1);\n expect((db.prepare(\"SELECT COUNT(*) AS n FROM messages WHERE conversation_id = 'stale'\").get() as { n: number }).n).toBe(0);\n expect((db.prepare(\"SELECT COUNT(*) AS n FROM usage\").get() as { n: number }).n).toBe(0);\n });\n\n it(\"saves and loads wiki entries\", () => {\n p.saveWikiEntry(wikiEntry);\n const snap = p.loadAll();\n expect(snap.wikiEntries).toHaveLength(1);\n expect(snap.wikiEntries[0]).toEqual(wikiEntry);\n });\n\n it(\"upserts wiki entry (idempotent)\", () => {\n p.saveWikiEntry(wikiEntry);\n p.saveWikiEntry({ ...wikiEntry, turns: [{ question: \"q2\", answer: \"a2\" }] });\n const snap = p.loadAll();\n expect(snap.wikiEntries).toHaveLength(1);\n expect(snap.wikiEntries[0].turns).toHaveLength(1);\n expect(snap.wikiEntries[0].turns[0].question).toBe(\"q2\");\n });\n\n it(\"returns empty arrays when nothing saved\", () => {\n const snap = p.loadAll();\n expect(snap.conversations).toEqual([]);\n expect(snap.wikiEntries).toEqual([]);\n });\n\n it(\"preserves all TimelineItem kinds round-trip\", () => {\n const rich: TimelineItem[] = [\n { kind: \"text\", id: \"u0\", runId: \"r1\", role: \"user\", text: \"q\", streaming: false },\n { kind: \"thinking\", id: \"m1\", runId: \"r1\", text: \"...\", streaming: false },\n { kind: \"tool\", id: \"m2\", runId: \"r1\", toolUseId: \"t1\", name: \"Bash\", input: { cmd: \"ls\" }, status: \"ok\", summary: \"done\" },\n { kind: \"result\", id: \"m3\", runId: \"r1\", isError: false, interrupted: false, finalText: \"ok\", pathAudit: { claimed: [] } },\n { kind: \"usage\", id: \"m4\", runId: \"r1\", usage: { providerId: \"deepseek\", modelId: \"deepseek-chat\", inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheCreationTokens: 0, costUsd: \"0.000042\", costSource: \"local-pricing\", tokensEstimated: false } },\n { kind: \"memory\", id: \"m5\", runId: \"r1\", changeId: \"change-1\", action: \"remembered\", label: \"用户喜欢先看结论\", scope: { type: \"global\" }, undone: false },\n { kind: \"files\", id: \"m6\", runId: \"r1\", changes: [{ path: \"课程笔记/第一章.md\", change: \"modified\" }], omitted: 2 },\n ];\n p.saveConversation(meta, rich);\n const snap = p.loadAll();\n expect(snap.conversations[0].timeline).toEqual(rich);\n });\n\n it(\"writes a derived usage row per usage TimelineItem (06 §六 usage 表)\", () => {\n const withUsage: TimelineItem[] = [\n { kind: \"text\", id: \"u0\", runId: \"r1\", role: \"user\", text: \"q\", streaming: false },\n { kind: \"usage\", id: \"m1\", runId: \"r1\", usage: { providerId: \"glm\", modelId: \"glm-5.2\", inputTokens: 100, outputTokens: 20, cacheReadTokens: 5, cacheCreationTokens: 0, costUsd: \"0.001234\", costSource: \"local-pricing\", tokensEstimated: true } },\n ];\n p.saveConversation(meta, withUsage);\n const row = db.prepare(\"SELECT * FROM usage WHERE conversation_id = ?\").get(meta.id) as Record<string, unknown>;\n expect(row.provider_id).toBe(\"glm\");\n expect(row.model_id).toBe(\"glm-5.2\");\n expect(row.input_tokens).toBe(100);\n expect(row.output_tokens).toBe(20);\n expect(row.cache_read_tokens).toBe(5);\n expect(row.cache_creation_tokens).toBe(0);\n expect(row.cost_usd).toBe(\"0.001234\");\n expect(row.cost_source).toBe(\"local-pricing\");\n expect(row.tokens_estimated).toBe(1);\n });\n\n it(\"re-saving a conversation replaces (not duplicates) its usage rows\", () => {\n const once: TimelineItem[] = [\n { kind: \"usage\", id: \"m1\", runId: \"r1\", usage: { providerId: \"deepseek\", modelId: \"deepseek-chat\", inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheCreationTokens: 0, costSource: \"unpriced\", tokensEstimated: false } },\n ];\n p.saveConversation(meta, once);\n p.saveConversation(meta, once);\n const count = db.prepare(\"SELECT COUNT(*) AS n FROM usage WHERE conversation_id = ?\").get(meta.id) as { n: number };\n expect(count.n).toBe(1);\n });\n\n // 轮 2 卡 C — the session id is what makes a restarted conversation actually\n // remember; without persisting it, re-claiming only restores the ability to\n // send, not the context.\n it(\"round-trips a conversation's sessionId\", () => {\n p.saveConversation({ ...meta, sessionId: \"sess-abc-123\" }, items);\n expect(p.loadAll().conversations[0].meta.sessionId).toBe(\"sess-abc-123\");\n });\n\n it(\"loads a conversation that has no sessionId without inventing one\", () => {\n p.saveConversation(meta, items);\n expect(p.loadAll().conversations[0].meta.sessionId).toBeUndefined();\n });\n\n it(\"overwrites a stale sessionId on re-save\", () => {\n p.saveConversation({ ...meta, sessionId: \"sess-old\" }, items);\n p.saveConversation({ ...meta, sessionId: \"sess-new\" }, items);\n expect(p.loadAll().conversations[0].meta.sessionId).toBe(\"sess-new\");\n });\n\n it(\"round-trips the opaque workspace id without persisting a path\", () => {\n p.saveConversation({ ...meta, workspaceId: \"workspace-project\" }, items);\n expect(p.loadAll().conversations[0].meta.workspaceId).toBe(\"workspace-project\");\n const row = db.prepare(\"SELECT workspace_id FROM conversations WHERE id = ?\").get(meta.id) as { workspace_id: string };\n expect(row.workspace_id).toBe(\"workspace-project\");\n });\n\n it(\"round-trips conversation lifecycle state and fills legacy defaults\", () => {\n const lifecycle = {\n ...meta,\n pinned: true,\n archived: true,\n lastOpenedAt: 3_456,\n } as ConversationMeta;\n p.saveConversation(lifecycle, items);\n expect(p.loadAll().conversations[0]?.meta).toMatchObject({\n pinned: true,\n archived: true,\n lastOpenedAt: 3_456,\n });\n\n db.prepare(`UPDATE conversations SET pinned = NULL, archived = NULL, last_opened_at = NULL WHERE id = ?`).run(meta.id);\n expect(p.loadAll().conversations[0]?.meta).toMatchObject({\n pinned: false,\n archived: false,\n lastOpenedAt: meta.lastActivityAt,\n });\n });\n\n it(\"tombstones a deleted conversation so delayed saves and index rebuilds cannot resurrect it\", () => {\n p.saveConversation(meta, items);\n p.deleteConversation(meta.id);\n\n expect(p.isConversationDeleted(meta.id)).toBe(true);\n expect(p.loadAll().conversations).toEqual([]);\n expect((db.prepare(\"SELECT COUNT(*) AS n FROM messages WHERE conversation_id = ?\").get(meta.id) as { n: number }).n).toBe(0);\n expect((db.prepare(\"SELECT COUNT(*) AS n FROM usage WHERE conversation_id = ?\").get(meta.id) as { n: number }).n).toBe(0);\n\n p.saveConversation(meta, items);\n p.rebuildConversationIndex([{ meta, timeline: items }]);\n expect(p.loadAll().conversations).toEqual([]);\n });\n});\n\n// ---------------------------------------------------------------------------\n// Migration onto an EXISTING database (轮 2 卡 C).\n//\n// SCHEMA uses CREATE TABLE IF NOT EXISTS, which does NOTHING to a table that\n// already exists — so on every machine that has already chatted, editing the\n// DDL string alone leaves `conversations` permanently without session_id and\n// every write blows up with \"no column named session_id\". These tests build the\n// PRE-卡C schema by hand (byte-for-byte the old DDL, with real rows in it) and\n// then run createPersistence over it, which is exactly what a user's leemo.db\n// goes through on the next launch.\n// ---------------------------------------------------------------------------\n\n/** The `conversations` DDL exactly as it shipped BEFORE 卡 C. */\nconst OLD_CONVERSATIONS_DDL = `\nCREATE TABLE IF NOT EXISTS conversations (\n id TEXT PRIMARY KEY,\n title TEXT NOT NULL,\n title_manually_updated INTEGER NOT NULL,\n book_id TEXT,\n source TEXT NOT NULL,\n provider_id TEXT NOT NULL,\n model_id TEXT NOT NULL,\n created_at INTEGER NOT NULL,\n last_activity_at INTEGER NOT NULL,\n unread INTEGER NOT NULL\n);\n`;\n\nfunction seedOldDatabase(): InstanceType<typeof Database> {\n const db = new Database(\":memory:\");\n db.exec(OLD_CONVERSATIONS_DDL);\n db.exec(`\n CREATE TABLE IF NOT EXISTS messages (\n conversation_id TEXT NOT NULL, seq INTEGER NOT NULL, kind TEXT NOT NULL,\n item_json TEXT NOT NULL, PRIMARY KEY (conversation_id, seq)\n );\n `);\n // A real pre-existing conversation with a real message, as on the user's disk.\n db.prepare(\n `INSERT INTO conversations\n (id, title, title_manually_updated, book_id, source, provider_id, model_id, created_at, last_activity_at, unread)\n VALUES ('c-legacy', '重启前就存在的对话', 0, NULL, 'buddy', 'deepseek', 'deepseek-v4-flash', 111, 222, 0)`,\n ).run();\n db.prepare(`INSERT INTO messages (conversation_id, seq, kind, item_json) VALUES (?, ?, ?, ?)`).run(\n \"c-legacy\",\n 0,\n \"text\",\n JSON.stringify({ kind: \"text\", id: \"u0\", runId: \"run-1\", role: \"user\", text: \"旧消息\", streaming: false }),\n );\n return db;\n}\n\ndescribe(\"persistence schema — migration of an existing (pre-卡C) database\", () => {\n it(\"proves the fixture really lacks session_id (guards the test itself)\", () => {\n const db = seedOldDatabase();\n const cols = (db.prepare(\"PRAGMA table_info(conversations)\").all() as { name: string }[]).map((c) => c.name);\n expect(cols).not.toContain(\"session_id\");\n });\n\n it(\"adds session_id to a table CREATE TABLE IF NOT EXISTS would have skipped\", () => {\n const db = seedOldDatabase();\n createPersistence(db);\n const cols = (db.prepare(\"PRAGMA table_info(conversations)\").all() as { name: string }[]).map((c) => c.name);\n expect(cols).toContain(\"session_id\");\n expect(cols).toContain(\"workspace_id\");\n expect(cols).toContain(\"pinned\");\n expect(cols).toContain(\"archived\");\n expect(cols).toContain(\"last_opened_at\");\n });\n\n it(\"keeps the pre-existing rows intact (ALTER, not drop-and-recreate)\", () => {\n const db = seedOldDatabase();\n const p = createPersistence(db);\n const snap = p.loadAll();\n expect(snap.conversations).toHaveLength(1);\n expect(snap.conversations[0].meta.title).toBe(\"重启前就存在的对话\");\n expect(snap.conversations[0].meta.sessionId).toBeUndefined();\n expect(snap.conversations[0].meta.workspaceId).toBeUndefined();\n expect(snap.conversations[0].meta).toMatchObject({\n pinned: false,\n archived: false,\n lastOpenedAt: 222,\n });\n expect(snap.conversations[0].timeline).toHaveLength(1);\n });\n\n it(\"can WRITE a sessionId onto a legacy row (the crash the migration prevents)\", () => {\n const db = seedOldDatabase();\n const p = createPersistence(db);\n const legacy = p.loadAll().conversations[0];\n // Without the ALTER this throws: table conversations has no column named session_id.\n p.saveConversation({ ...legacy.meta, sessionId: \"sess-after-migration\" }, legacy.timeline);\n expect(p.loadAll().conversations[0].meta.sessionId).toBe(\"sess-after-migration\");\n });\n\n it(\"is idempotent — a second open must not fail on duplicate column\", () => {\n const db = seedOldDatabase();\n createPersistence(db);\n expect(() => createPersistence(db)).not.toThrow();\n const cols = (db.prepare(\"PRAGMA table_info(conversations)\").all() as { name: string }[]).map((c) => c.name);\n expect(cols.filter((c) => c === \"session_id\")).toHaveLength(1);\n expect(cols.filter((c) => c === \"workspace_id\")).toHaveLength(1);\n expect(cols.filter((c) => c === \"pinned\")).toHaveLength(1);\n expect(cols.filter((c) => c === \"archived\")).toHaveLength(1);\n expect(cols.filter((c) => c === \"last_opened_at\")).toHaveLength(1);\n });\n\n it(\"a brand-new database gets session_id straight from the DDL\", () => {\n const db = new Database(\":memory:\");\n createPersistence(db);\n const cols = (db.prepare(\"PRAGMA table_info(conversations)\").all() as { name: string }[]).map((c) => c.name);\n expect(cols).toContain(\"session_id\");\n expect(cols).toContain(\"workspace_id\");\n expect(cols).toContain(\"pinned\");\n expect(cols).toContain(\"archived\");\n expect(cols).toContain(\"last_opened_at\");\n expect((db.prepare(\"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'conversation_tombstones'\").get() as { name: string }).name).toBe(\"conversation_tombstones\");\n });\n});\n\ndescribe(\"wiki entry workspace migration\", () => {\n it(\"adds workspace ownership without losing older selection history\", () => {\n const db = makeDb();\n db.exec(`\n CREATE TABLE wiki_entries (\n id TEXT PRIMARY KEY,\n file_path TEXT NOT NULL,\n quoted_text TEXT NOT NULL,\n turns_json TEXT NOT NULL,\n created_at INTEGER NOT NULL\n );\n INSERT INTO wiki_entries (id, file_path, quoted_text, turns_json, created_at)\n VALUES ('legacy-wiki', 'notes.md', '旧选区', '[]', 1);\n `);\n\n const persistence = createPersistence(db);\n const columns = (db.prepare(\"PRAGMA table_info(wiki_entries)\").all() as { name: string }[])\n .map((column) => column.name);\n expect(columns).toContain(\"workspace_id\");\n expect(persistence.loadAll().wikiEntries).toContainEqual({\n id: \"legacy-wiki\",\n filePath: \"notes.md\",\n quotedText: \"旧选区\",\n turns: [],\n createdAt: 1,\n });\n });\n});\n\n// ── 轮 7 A3: settings 表 ────────────────────────────────────────────────────\ndescribe(\"settings persistence (轮 7 A3)\", () => {\n it(\"round-trips every JSON value shape a setting can hold\", () => {\n const p = createPersistence(makeDb());\n p.saveSettings({\n webEnabled: true,\n dangerousCommandCaching: false,\n talkStyle: 2,\n permissionMode: \"bypassPermissions\",\n defaultModelId: null,\n });\n // Booleans must not come back as 0/1 and null must not become \"null\":\n // that is the whole reason values are JSON-encoded rather than stringified.\n expect(p.loadAll().settings).toEqual({\n webEnabled: true,\n dangerousCommandCaching: false,\n talkStyle: 2,\n permissionMode: \"bypassPermissions\",\n defaultModelId: null,\n });\n });\n\n it(\"a fresh database reports empty settings, not undefined\", () => {\n // The bootstrap does `if (snap.settings)` — undefined would silently skip\n // hydration forever on a new install.\n expect(createPersistence(makeDb()).loadAll().settings).toEqual({});\n });\n\n it(\"saving REPLACES the map (a removed key does not linger)\", () => {\n const p = createPersistence(makeDb());\n p.saveSettings({ webEnabled: true, talkStyle: 1 });\n p.saveSettings({ talkStyle: 3 });\n expect(p.loadAll().settings).toEqual({ talkStyle: 3 });\n });\n\n it(\"skips an undefined value rather than writing the string \\\"undefined\\\"\", () => {\n const p = createPersistence(makeDb());\n p.saveSettings({ good: 1, bad: undefined });\n expect(p.loadAll().settings).toEqual({ good: 1 });\n });\n\n it(\"a corrupt row is skipped, and never costs the user their conversations\", () => {\n const db = makeDb();\n const p = createPersistence(db);\n p.saveConversation(meta, items);\n p.saveSettings({ webEnabled: true });\n // Simulate a value written by a build with a different encoding, or a\n // partially-flushed row.\n db.prepare(`INSERT INTO settings (key, value_json) VALUES ('broken', '{oops')`).run();\n const snap = p.loadAll();\n expect(snap.settings).toEqual({ webEnabled: true }); // bad key dropped\n expect(snap.conversations).toHaveLength(1); // the load-bearing part\n });\n\n it(\"an existing pre-A3 database gains the settings table without losing rows\", () => {\n const db = seedOldDatabase();\n const p = createPersistence(db);\n p.saveSettings({ webEnabled: true });\n expect(p.loadAll().settings).toEqual({ webEnabled: true });\n });\n});\n\ndescribe(\"approval whitelist persistence\", () => {\n it(\"persists unique tool-risk entries across database reopen and removes the exact entry\", () => {\n const db = makeDb();\n const first = createPersistence(db);\n first.addToWhitelist({ toolName: \"Write\", risk: \"moderate\" });\n first.addToWhitelist({ toolName: \"Write\", risk: \"moderate\" });\n first.addToWhitelist({ toolName: \"Read\", risk: \"safe\" });\n first.addToWhitelist({ toolName: \"Bash\", risk: \"moderate\" });\n first.addToWhitelist({ toolName: \"Write\", risk: \"dangerous\" });\n\n const reopened = createPersistence(db);\n expect(reopened.getWhitelist()).toEqual([\n { toolName: \"Read\", risk: \"safe\" },\n { toolName: \"Write\", risk: \"moderate\" },\n ]);\n\n reopened.removeFromWhitelist({ toolName: \"Write\", risk: \"moderate\" });\n expect(reopened.getWhitelist()).toEqual([{ toolName: \"Read\", risk: \"safe\" }]);\n });\n});\n\ndescribe(\"scheduled task persistence\", () => {\n const task: ScheduledTask = {\n id: \"task-1\",\n name: \"每日英语练习\",\n prompt: \"给我一份 10 分钟英语练习\",\n schedule: { kind: \"daily\", hour: 8, minute: 0 },\n timezone: \"Asia/Tokyo\",\n nextRunAt: 2_000,\n workspaceId: \"leemo-home\",\n status: \"active\",\n createdAt: 1_000,\n updatedAt: 1_000,\n };\n const run: ScheduledTaskRun = {\n id: \"run-1\",\n taskId: task.id,\n scheduledFor: 2_000,\n trigger: \"scheduled\",\n status: \"queued\",\n createdAt: 2_000,\n };\n\n it(\"round-trips schedules, result workspace, conversation, and run history\", () => {\n const p = createPersistence(makeDb());\n p.saveScheduledTask({ ...task, conversationId: \"conversation-1\" });\n p.saveScheduledTaskRun(run);\n\n expect(p.listScheduledTasks()).toEqual([{ ...task, conversationId: \"conversation-1\" }]);\n expect(p.getScheduledTask(task.id)?.schedule).toEqual(task.schedule);\n expect(p.listScheduledTaskRuns(task.id)).toEqual([run]);\n });\n\n it(\"queues one occurrence with its advanced task and claims it only once\", () => {\n const p = createPersistence(makeDb());\n p.saveScheduledTask(task);\n p.queueScheduledOccurrence({ ...task, nextRunAt: 86_400_000, lastRunAt: 2_000, updatedAt: 2_000 }, run);\n\n expect(p.getScheduledTask(task.id)).toMatchObject({ nextRunAt: 86_400_000, lastRunAt: 2_000 });\n expect(p.claimScheduledTaskRun(run.id, 2_100)).toMatchObject({ status: \"running\", startedAt: 2_100 });\n expect(p.claimScheduledTaskRun(run.id, 2_200)).toBeUndefined();\n\n p.completeScheduledTaskRun({\n ...run,\n status: \"succeeded\",\n startedAt: 2_100,\n completedAt: 2_500,\n conversationId: \"conversation-1\",\n });\n expect(p.listScheduledTaskRuns(task.id)[0]).toMatchObject({\n status: \"succeeded\",\n completedAt: 2_500,\n conversationId: \"conversation-1\",\n });\n });\n\n it(\"turns unfinished work into an honest missed run after restart\", () => {\n const p = createPersistence(makeDb());\n p.saveScheduledTask(task);\n p.saveScheduledTaskRun({ ...run, status: \"running\", startedAt: 2_050 });\n p.markStaleScheduledRunsMissed(3_000);\n expect(p.listScheduledTaskRuns(task.id)[0]).toMatchObject({\n status: \"missed\",\n completedAt: 3_000,\n });\n });\n\n it(\"deleting a task removes only its own local records\", () => {\n const p = createPersistence(makeDb());\n p.saveScheduledTask(task);\n p.saveScheduledTaskRun(run);\n p.deleteScheduledTask(task.id);\n expect(p.listScheduledTasks()).toEqual([]);\n expect(p.listScheduledTaskRuns()).toEqual([]);\n });\n});\n\ndescribe(\"usage summaries\", () => {\n function usage(\n providerId: string,\n modelId: string,\n inputTokens: number,\n outputTokens: number,\n costUsd?: string,\n ): TimelineItem {\n return {\n kind: \"usage\",\n id: `${providerId}-${inputTokens}`,\n runId: `${providerId}-${inputTokens}`,\n usage: {\n providerId,\n modelId,\n inputTokens,\n outputTokens,\n cacheReadTokens: 0,\n cacheCreationTokens: 0,\n ...(costUsd === undefined ? {} : { costUsd }),\n costSource: costUsd === undefined ? \"unpriced\" : \"local-pricing\",\n tokensEstimated: false,\n },\n };\n }\n\n it(\"aggregates today and last seven local days without floating-point cost drift\", () => {\n const p = createPersistence(makeDb());\n const now = new Date(2026, 6, 29, 15, 0, 0).getTime();\n p.saveConversation(\n { ...meta, id: \"today-priced\", lastActivityAt: new Date(2026, 6, 29, 9).getTime() },\n [usage(\"alpha\", \"a1\", 100, 20, \"0.000001\")],\n );\n p.saveConversation(\n { ...meta, id: \"today-unpriced\", lastActivityAt: new Date(2026, 6, 29, 11).getTime() },\n [usage(\"alpha\", \"a2\", 50, 10)],\n );\n p.saveConversation(\n { ...meta, id: \"yesterday\", lastActivityAt: new Date(2026, 6, 28, 18).getTime() },\n [usage(\"beta\", \"b1\", 200, 40, \"0.100005\")],\n );\n p.saveConversation(\n { ...meta, id: \"too-old\", lastActivityAt: new Date(2026, 6, 21, 18).getTime() },\n [usage(\"old\", \"old\", 999, 999, \"9.999999\")],\n );\n\n expect(p.usageSummary({ range: \"today\" }, now)).toEqual({\n totalCostUsd: \"0.000001\",\n byProvider: [{ providerId: \"alpha\", costUsd: \"0.000001\", inputTokens: 150, outputTokens: 30 }],\n byDay: undefined,\n });\n expect(p.usageSummary({ range: \"last7d\" }, now)).toEqual({\n totalCostUsd: \"0.100006\",\n byProvider: [\n { providerId: \"alpha\", costUsd: \"0.000001\", inputTokens: 150, outputTokens: 30 },\n { providerId: \"beta\", costUsd: \"0.100005\", inputTokens: 200, outputTokens: 40 },\n ],\n byDay: [\n { date: \"2026-07-28\", costUsd: \"0.100005\" },\n { date: \"2026-07-29\", costUsd: \"0.000001\" },\n ],\n });\n });\n\n it(\"supports provider filtering and leaves cost absent when every matching row is unpriced\", () => {\n const p = createPersistence(makeDb());\n const now = new Date(2026, 6, 29, 15, 0, 0).getTime();\n p.saveConversation(\n { ...meta, id: \"unpriced\", lastActivityAt: new Date(2026, 6, 29, 11).getTime() },\n [usage(\"alpha\", \"a1\", 12, 3)],\n );\n p.saveConversation(\n { ...meta, id: \"other\", lastActivityAt: new Date(2026, 6, 29, 12).getTime() },\n [usage(\"beta\", \"b1\", 90, 10, \"0.500000\")],\n );\n\n expect(p.usageSummary({ range: \"today\", providerId: \"alpha\" }, now)).toEqual({\n byProvider: [{ providerId: \"alpha\", inputTokens: 12, outputTokens: 3 }],\n byDay: undefined,\n });\n });\n});\n"}}}}