diff --git a/specs/agent-sidepanel-v3/wp-14-chat-threads-repo/brief.md b/specs/agent-sidepanel-v3/wp-14-chat-threads-repo/brief.md new file mode 100644 index 00000000..ffbb230d --- /dev/null +++ b/specs/agent-sidepanel-v3/wp-14-chat-threads-repo/brief.md @@ -0,0 +1,148 @@ +# WP-14 — ChatThreadsRepository port + domain VO tests + +**Branch:** `claude/asv3-wp14-chat-threads-repo` (cut from `origin/develop`) +**Lane:** Tests (independent — no inter-WP dependency) +**Estimated size:** medium (~300–450 LOC: new port + adapter + persistence migration, plus VO tests on `SessionId` / `ChatThreadRecord`) + +## Goal (one sentence) + +Lift chat-threads persistence out of the plugin-layer `scheduleChatThreadsPersistence` callback shape into a domain-owned `ChatThreadsRepositoryPort` with an `ObsidianPluginDataAdapter`, and add domain VO tests for `SessionId` (currently 0% statement coverage as a separate file) and the `ChatThreadRecord` invariants enforced by `parseChatThreadRecord`. + +## Problem statement + +Chat-threads persistence today goes through a plugin-layer side channel. `src/plugin/SpecoratorView.ts:194` and `src/plugin/AgentSidepanelView.ts:155` each call `this.plugin.scheduleChatThreadsPersistence(state.chatThreads)` inside a Pinia `$subscribe` callback. The plugin's `scheduleChatThreadsPersistence` (`src/plugin/main.ts:530`) serialises the `Map` through helpers in `src/plugin/chatThreadsPersistence.ts` (190 LOC: `parseChatThreadRecord`, `decodeChatThreadsBlob`, etc.) and writes via `Plugin.saveData`. The flow is correct but it's ad hoc: stores subscribe → plugin schedules → plugin serialises → Obsidian's `data.json` gets the blob. + +WP-3's loop-state.md flagged this as a carry-out: _"WP-14: lift `chatThreads` persistence into a `ChatThreadsRepositoryPort`. Today `SpecoratorView` and `AgentSidepanelView` each call `$subscribe(...)` on the threads store and shovel the snapshot into `plugin.scheduleChatThreadsPersistence`. WP-14 should replace that with a port-shaped repository the view injects into the store."_ + +Today the `chatThreadsStore` (Pinia, 102 LOC) does NOT subscribe to itself or persist directly — it stays decoupled from infrastructure. The repo-port pattern preserves that decoupling while moving the side-channel into a single narrow port the views consume. + +Independently, the audit flagged low-coverage hotspots in domain VOs: + +- `src/domain/chat/SessionId.ts` (23 LOC, `asSessionId` branded constructor + `SessionId` type) — present in `chatThreadsPersistence.ts` chain but no co-located test file under `tests/domain/chat/`. +- `src/domain/chat/ChatThreadRecord.ts` invariants are checked by `parseChatThreadRecord` (in the plugin layer, lines 81+). The defect-categorisation functions (`findIdentityDefect`, `findShapeDefect`) live in the plugin file but operate on domain-shaped data. Adding parser-level tests for each defect path lifts coverage AND documents the contract. + +## Scope — IN + +**New `ChatThreadsRepositoryPort`** (`src/domain/ports/ChatThreadsRepositoryPort.ts`): + +```ts +export interface ChatThreadsRepositoryPort { + load(): Promise> + save(records: ReadonlyMap): Promise +} +``` + +Lives in domain because `ChatThreadRecord` is domain. Pure interface — no Obsidian / plugin imports. + +**New `ObsidianChatThreadsRepository`** (`src/infrastructure/obsidian/ObsidianChatThreadsRepository.ts`): + +- `load()` — reads `_storedData.specorator.chatThreads` via `Plugin.loadData()`; runs through the existing `decodeChatThreadsBlob` helper (extracted from `src/plugin/chatThreadsPersistence.ts` into a shared module under `src/infrastructure/chat/`). +- `save()` — debounces + writes via `Plugin.saveData()`. Owns the debounce timer that `main.ts` currently owns at line 530. + +**New `MockChatThreadsRepository`** (`src/infrastructure/mock/MockChatThreadsRepository.ts`): + +- In-memory `Map`; load/save echo. Used by `MockBridge` + standalone UI + tests. + +**Inject the port into the views via the bridge.** `SpecoratorView.onOpen` and `AgentSidepanelView.onOpen` no longer call `plugin.scheduleChatThreadsPersistence`. Instead they: + +1. On open: `repo.load()` → seed `chatThreadsStore` with the hydrated map. +2. Subscribe to the store: on change → `repo.save(state.chatThreads)`. The debounce moves into the repo's `save` implementation. + +**Domain VO tests** under `tests/domain/chat/`: + +- `tests/domain/chat/SessionId.test.ts` — asserts the brand, asserts `asSessionId('abc')` is structurally a string, asserts type-only invariants (compile-time tests via `expectTypeOf` or equivalent — Vitest supports this). +- `tests/domain/chat/ChatThreadRecord.test.ts` — moved from `tests/plugin/chatThreadsPersistence.test.ts` (the parser tests for `parseChatThreadRecord` move under `tests/infrastructure/chat/` once the helper relocates; the pure shape-defect tests can live in `tests/domain/chat/ChatThreadRecord.test.ts` as type-level + invariant tests). + +**Migrate the persistence helper** out of `src/plugin/` into `src/infrastructure/chat/chatThreadsCodec.ts`. The plugin layer keeps only the `Plugin.saveData` / `Plugin.loadData` wiring (now inside the repo adapter). + +## Scope — OUT + +- The `ChatThreadRecord` shape itself (stable — owned by SPEC-ASM-001 §2.2). +- `messagesStore`, `streamingTurnStore`, `proposalStore` persistence (only `chatThreadsStore` is persisted today). +- Pinia store internal shape (stays as today: `chatThreads: Ref>`, etc.). +- Session-log persistence (`SessionLogWriter` / `SessionLogMirror` — that's WP-5). +- `MockSecretStore` / `LocalStorageSecretStore` test catch-up — that's WP-13 / WP-9. + +## Approach + +1. **Iteration 1 — port + adapter scaffolds.** Add the port interface, the Obsidian + Mock adapters, the `chatThreadsCodec.ts` relocation. Don't wire the views yet. +2. **Iteration 2 — wire the views.** `SpecoratorView.onOpen` + `AgentSidepanelView.onOpen` switch to `repo.load()` + subscribe-to-save. Delete `plugin.scheduleChatThreadsPersistence` from `main.ts`. Update `_pendingChatThreadsSnapshot` field accordingly (delete; the repo owns the debounce). +3. **Iteration 3 — domain VO tests.** Move + write the `tests/domain/chat/` files. +4. **Iteration 4 — coverage sweep.** Run `npm run test:coverage`; assert `src/domain/chat/SessionId.ts` ≥ 90% and `parseChatThreadRecord` defect paths ≥ 90% branches. Adjust tests if any branch still uncovered. +5. **Run the full pre-PR gate every iteration.** + +## Deliverables + +**New files:** + +- `src/domain/ports/ChatThreadsRepositoryPort.ts` — the port interface. +- `src/infrastructure/obsidian/ObsidianChatThreadsRepository.ts` — production adapter with debounce. +- `src/infrastructure/mock/MockChatThreadsRepository.ts` — test/standalone adapter. +- `src/infrastructure/chat/chatThreadsCodec.ts` — `parseChatThreadRecord`, `decodeChatThreadsBlob`, etc. moved here. +- `tests/domain/chat/SessionId.test.ts`. +- `tests/domain/chat/ChatThreadRecord.test.ts`. +- `tests/infrastructure/obsidian/ObsidianChatThreadsRepository.test.ts`. +- `tests/infrastructure/mock/MockChatThreadsRepository.test.ts`. +- `tests/infrastructure/chat/chatThreadsCodec.test.ts` (moved from `tests/plugin/chatThreadsPersistence.test.ts`). + +**Modified files:** + +- `src/infrastructure/bridge/ports.ts` — add `CHAT_THREADS_REPO_KEY` InjectionKey. +- `src/ui/composables/useChatThreadsRepo.ts` — new composable (mirrors the existing per-port composable pattern). +- `src/plugin/SpecoratorView.ts:194` — replace `plugin.scheduleChatThreadsPersistence` call with `repo.save`. +- `src/plugin/AgentSidepanelView.ts:155` — same. +- `src/plugin/main.ts` — remove `scheduleChatThreadsPersistence`, `_pendingChatThreadsSnapshot`, the related debounce timer; wire `ObsidianChatThreadsRepository` into the bridge. +- `tests/__fakes__/fake-ports.ts` — add `chatThreadsRepo` to the factory output. + +**Deleted (no back-compat shims, per CLAUDE.md):** + +- `src/plugin/chatThreadsPersistence.ts` after its contents migrate. Replace with a re-export only if any external test fixture still imports the path; otherwise delete. +- `Plugin.scheduleChatThreadsPersistence` method. + +## Definition of done + +- [ ] `npm audit --audit-level=high --omit=dev` clean. +- [ ] `npm run typecheck` clean. +- [ ] `npm run lint` 0 errors. +- [ ] `npm run test` passes; new port + adapter + domain VO tests ≥ 90% statements/branches each. +- [ ] `npm run build` + `npm run build:web` succeed. +- [ ] `npm run docs:api` succeeds. +- [ ] `npm run test:coverage` — `src/domain/chat/SessionId.ts` ≥ 90% statements; `src/infrastructure/chat/chatThreadsCodec.ts` ≥ 90% branches (each defect path covered). +- [ ] **Port shape**: `ChatThreadsRepositoryPort` is two methods only (`load`, `save`); no Obsidian / plugin types leak across. +- [ ] **Views migrated**: neither `SpecoratorView.ts` nor `AgentSidepanelView.ts` references `plugin.scheduleChatThreadsPersistence` (verified by `grep`). +- [ ] **Plugin slim**: `src/plugin/main.ts` LOC drops by ≥ 30 (the persistence wiring moves out). The 387-LOC max-lines warning the audit flagged for `main.ts` may now drop below 350 — confirm in the loop-state. +- [ ] PR opened against `develop`, title `refactor(asv3): ChatThreadsRepository port + domain VO tests (WP-14)`, body cites WP-3 carry-out + audit VO-coverage gap. + +## Risks / known unknowns + +`Plugin.saveData` is Obsidian's atomic-write API; its debounce semantics aren't documented. The existing code in `main.ts:530` debounces because rapid Pinia mutations would otherwise spam disk. Preserve a debounce inside `ObsidianChatThreadsRepository.save` (e.g. 200 ms trailing-edge); document the value in JSDoc. The `MockChatThreadsRepository` does NOT debounce — tests want determinism. + +Test relocation can break in-flight CI for tests that import from the old path. Run a single-shot `npm run test` after each move to catch them. The codec function moves are pure — they retain test invariants line-for-line. + +## RALPH iteration template + +``` +loop: + 1. Read brief.md + loop-state.md. + 2. Pick the next failing check (audit → typecheck → lint → test → build → docs → DoD). + 3. Implement the smallest change that moves one check red→green. + STAY IN SCOPE — no streaming/proposal/messages store changes, no codec + SCHEMA changes, no settings persistence. + 4. Run from inside .worktrees/asv3-wp14: + npm audit --audit-level=high --omit=dev \ + && npm run typecheck && npm run lint && npm run test \ + && npm run build && npm run build:web && npm run docs:api + 5. Update loop-state.md. + 6. If all gates green AND all DoD met → commit, push, open PR via gh. + Else → goto 1. + Hard cap: 10 RALPH iterations. +``` + +## Conventions + +- **Worktree:** `.worktrees/asv3-wp14` (already created, branch `claude/asv3-wp14-chat-threads-repo`). +- **Commits:** conventional, squash on merge. Prefix `refactor(asv3):`. +- **PR target:** `develop`. Ready for review. +- **Do not touch:** Pinia store internals beyond seeding (still 4 stores), `messagesStore` / `proposalStore` persistence, `SessionLogWriter`. +- **Coordinate with WP-17** (plugin-core split). Both touch `src/plugin/main.ts`. If WP-17 lands first, the debounce timer ownership may have moved into a new module — rebase mechanically. If you land first, WP-17 inherits a slimmer `main.ts`. +- **Never** push to `develop`. Never force-push. diff --git a/specs/agent-sidepanel-v3/wp-14-chat-threads-repo/loop-state.md b/specs/agent-sidepanel-v3/wp-14-chat-threads-repo/loop-state.md new file mode 100644 index 00000000..d5d176d2 --- /dev/null +++ b/specs/agent-sidepanel-v3/wp-14-chat-threads-repo/loop-state.md @@ -0,0 +1,177 @@ +# WP-14 loop state + +Updated by the implementer subagent each RALPH iteration. The brief is `brief.md` in this folder. + +> **Worktree context** — All implementation entries below describe work performed on `claude/asv3-wp14-chat-threads-repo` inside `.worktrees/asv3-wp14/`. The brief was scaffolded from the main worktree on `claude/improve-sidepanel-chat-8pgcT` (PR #395 era). + +## Iterations + +### Iteration 1 — scaffold port + adapters + codec relocation; wire views + +What landed: + +- `src/domain/ports/ChatThreadsRepositoryPort.ts` — narrow port interface (`load` / `save` only), re-exported from `src/domain/ports/index.ts`. +- `src/infrastructure/chat/chatThreadsCodec.ts` — pure codec migrated from `src/plugin/chatThreadsPersistence.ts` (which is deleted). `parseChatThreadRecord`, `decodeChatThreadsBlob`, `encodeChatThreadsBlob`, `mostRecentlyUsedThreadId` preserved verbatim. +- `src/infrastructure/obsidian/ObsidianChatThreadsRepository.ts` — production adapter. Owns the 1 s debounce previously on `Plugin.scheduleChatThreadsPersistence`. Tail-chained flush queue (Codex P1, PR #350). `flushPending()` drains the in-flight snapshot from `Plugin.onunload()` (Codex P1, PR #346). +- `src/infrastructure/mock/MockChatThreadsRepository.ts` — in-memory adapter for tests + standalone UI. No debounce (test determinism). +- `src/infrastructure/bridge/ports.ts` — new `CHAT_THREADS_REPO` InjectionKey. +- `src/ui/composables/useChatThreadsRepo.ts` — new composable mirroring the existing per-port pattern (throws when missing — no defaulting; persistence must be explicit). +- `src/plugin/SpecoratorView.ts` and `src/plugin/AgentSidepanelView.ts` — `onOpen()` now `await repo.load()` (the views are async), and subscribe → `void repo.save(state.chatThreads)`. Repo provided under `CHAT_THREADS_REPO` for UI consumers. +- `src/plugin/main.ts` — `scheduleChatThreadsPersistence`, `_flushChatThreads`, `_chatThreadsFlushTimer`, `_pendingChatThreadsSnapshot`, `_chatThreadsFlushQueue`, `_CHAT_THREADS_FLUSH_DEBOUNCE_MS`, `_initialChatThreads`, and `getInitialChatThreads()` are deleted. `loadSettings()` constructs the `ObsidianChatThreadsRepository`, wired to `loadData` / `saveData` and `activeWindow.{set,clear}Timeout`. `onunload()` calls `repo.flushPending()` (replaces the inline timer/snapshot dance). +- `tests/__fakes__/fake-ports.ts` — `fakeModulePorts()` now exposes `chatThreadsRepo: MockChatThreadsRepository`. + +### Iteration 2 — domain VO tests + adapter tests + +- `tests/domain/chat/SessionId.test.ts` — covers `asSessionId` zero-cost brand semantics, JSON round-trip, and type-level invariants via `expectTypeOf` (the file had 0% explicit coverage before). +- `tests/domain/chat/ChatThreadRecord.test.ts` — type-level invariants for the seven SPEC §2.2 fields, transport literal pins `'api-key' | 'subscription'` (degraded not persisted), and `SessionId` brand check. +- `tests/infrastructure/chat/chatThreadsCodec.test.ts` — moved from `tests/plugin/chatThreadsPersistence.test.ts`; import path swung to `@/infrastructure/chat/chatThreadsCodec`. +- `tests/infrastructure/obsidian/ObsidianChatThreadsRepository.test.ts` — new. Covers `load`, debounced `save`, coalescing, sibling-key preservation, degraded filtering, custom `debounceMs`, `flushPending`, and the serialised flush-queue invariant (Codex P1, PR #350). +- `tests/infrastructure/mock/MockChatThreadsRepository.test.ts` — new. Covers load/save round-trip, initial Map/Array seed, defensive copies on `load()` / `snapshot()`. +- `tests/plugin/main.chat-threads-flush.test.ts` — deleted. The covered behaviours (debounce, coalescing, sibling preservation, onunload flush, queue serialisation) all moved into the adapter tests above. The `updateSettings` sibling-preservation case it carried at the tail was the only non-flush test there; `updateSettings` itself is unchanged and other tests (`tests/plugin/settings.test.ts`) still cover its surface. + +### Iteration 3 — pre-PR gate + +``` +npm audit --audit-level=high --omit=dev # 0 vulnerabilities +npm run typecheck # clean +npm run lint # 0 errors, 24 pre-existing warnings +npm run test # 1882 / 1882 pass (was 1851; +31 new tests) +npm run build # OK +npm run build:web # OK +npm run docs:api # 0 errors, 1 pre-existing typedoc link warning +``` + +Coverage on relocated codec: 98.33% statements / 96.15% branches / 100% functions / 98.14% lines (above the brief's 90% requirement). Overall thresholds (80/70/80/80) intact at 93.53 / 87.6 / 92.3 / 94.62. + +`src/plugin/main.ts` shrank by ~85 raw LOC (662 → 577). Effective code is 359 (was 387 in the audit) — the max-lines lint warning at 350 remains a `warn`, not error, and the audit's "may now drop below 350" was conditional. WP-16 (Wave 2) will split `main.ts` further and can drive it below the limit. + +### Iteration 4 — Codex P1 round-1 (PR #408 review feedback) + +Codex flagged two real data-loss races introduced by the refactor: + +- **P1.1 (`src/plugin/main.ts:424`)** — `_storedData` was never updated when `ObsidianChatThreadsRepository` flushed to disk. A subsequent `updateSettings` / `updateModuleSettings` would call `saveData(this._storedData)` and silently re-emit the pre-chat `specorator.chatThreads` snapshot, destroying recent threads. +- **P1.2 (`src/infrastructure/obsidian/ObsidianChatThreadsRepository.ts:74`)** — `load()` always read from disk. Reopening a chat view inside the 1 s debounce window rehydrated stale threads, and the next save from the store would persist that stale map on top of the in-flight new thread. + +Fixes: + +- Added `OnChatThreadsPersisted` constructor option to the adapter. `_flushChatThreads` invokes it AFTER `saveData()` resolves so a write failure cannot poison the host cache. `main.ts` passes a closure that mirrors the encoded blob into `this._storedData.specorator.chatThreads`. +- `load()` now returns a defensive `new Map(this._pendingSnapshot)` when a debounced write is in flight; falls through to disk decode otherwise. Documented precedence in the JSDoc. +- New tests in `tests/infrastructure/obsidian/ObsidianChatThreadsRepository.test.ts`: + - `load — pending-snapshot precedence (Codex P1)` × 3 cases. + - `onChatThreadsPersisted hook (Codex P1)` × 4 cases (including a regression reproducer, the success path, the rejection guard, and the end-to-end save-reopen-flush sequence). + +Pre-PR gate: + +``` +npm audit --audit-level=high --omit=dev # 0 vulnerabilities +npm run typecheck # clean +npm run lint # 0 errors, 24 pre-existing warnings +npm run test # 1889 / 1889 pass (was 1882; +7 new) +npm run build # OK +npm run build:web # OK +npm run docs:api # 0 errors, 1 pre-existing typedoc link warning +``` + +### Iteration 5 — Codex P1 round-2 (PR #408 review feedback) + +Codex flagged a third real race symmetric to P1.1: `_flushChatThreads` +rebuilt the disk blob from `await host.loadData()` and overwrote it. If +an `updateSettings` / `updateModuleSettings` call had mutated +`_storedData` but its `saveData` was still in flight, `loadData()` +returned the pre-settings disk blob → the chat-threads flush merged its +new `chatThreads` into that stale snapshot → wrote back → silently rolled +back the in-flight settings change (or vice versa, depending on write +order). Round-1's `OnChatThreadsPersisted` fixed the symmetric *write* +path; this iteration fixes the *read* path. + +Fixes: + +- New optional `ReadHostData` constructor closure on + `ObsidianChatThreadsRepository`. Signature: + `readHostData?: () => Record | null | undefined`. +- `_flushChatThreads` prefers `readHostData()` over `host.loadData()` + when the closure is provided and returns a non-null object. Falls back + to `loadData()` otherwise (preserves the no-closure path used by + existing bare-host tests and defensively handles a host whose cache + hasn't hydrated yet). +- `src/plugin/main.ts` wires `readHostData: () => this._storedData` + alongside the existing `onChatThreadsPersisted`. Both writers + (`updateSettings` / `updateModuleSettings` and the chat-threads flush) + now share `_storedData` as the single source of truth. Worst-case + race degrades to "two writes hit disk in unpredictable order with the + same up-to-date payload" — convergent, not destructive. +- New tests under `readHostData hook (Codex P1 round-2)` (×3): the race + reproducer (in-flight settings mutation survives the chat-threads + flush, `loadData` is never called), the no-closure fallback regression + guard, and the null-return defensive fallback. + +Pre-PR gate: + +``` +npm audit --audit-level=high --omit=dev # 0 vulnerabilities +npm run typecheck # clean +npm run lint # 0 errors, 24 pre-existing warnings +npm run test # 1892 / 1892 pass (was 1889; +3 new) +npm run build # OK +npm run build:web # OK +npm run docs:api # 0 errors, 1 pre-existing typedoc link warning +``` + +### Iteration 6 — Codex P1 round-3 (PR #408 review feedback) + +Codex flagged a fourth race: `save()` cleared `_pendingSnapshot` as soon +as the debounce timer fired, even though the queued +`_flushChatThreads(snapshot)` had not yet won the `_flushQueue` and +written to disk. Window: + +1. `save(A)` → `_pendingSnapshot = A`, debounce scheduled. +2. Debounce fires → `_pendingSnapshot = null` (bug), flush(A) enqueued. +3. `_flushQueue` is busy with an older flush, so flush(A) waits. +4. `load()` runs → `_pendingSnapshot` is null → falls through to disk, + which still has the *pre-A* state. +5. The view rehydrates the stale state. The next mutation persists that + stale map → silently clobbers the in-flight new threads. + +Fix: + +- Removed the eager `_pendingSnapshot = null` assignment from the + `setActiveTimeout` callback in `save()`. The clear now lives inside + the queued flush's `.then(async () => { ... })` handler and runs + AFTER `_flushChatThreads(snapshot)` resolves. +- Identity equality on clear: `if (this._pendingSnapshot === snapshot)`. + A newer `save()` that replaces `_pendingSnapshot` mid-flight is left + alone — its own debounce schedules the next flush. Without this + guard, an older flush completing would erase the newer snapshot. +- `flushPending()` follows the same pattern: it no longer clears + `_pendingSnapshot` synchronously; the queued flush clears it on + success via the identity check. Composes correctly when called + during an in-flight flush — both flushes are awaited. +- On rejection (`saveData` throws), `_pendingSnapshot` is preserved. + The next `save()` / `flushPending()` can retry. + +New tests in `tests/infrastructure/obsidian/ObsidianChatThreadsRepository.test.ts` +under `pending snapshot held until queued flush completes (Codex P1 round-3)`: + +- `load()` returns the pending snapshot while the queued flush is + in flight (the race reproducer — fails on `cf66087`). +- A newer `save()` during an in-flight older flush is not cleared by + the older flush completing (identity-equality guard). +- `flushPending()` drains both the in-flight flush and the next + queued flush. +- Does NOT clear `_pendingSnapshot` when the queued flush rejects. + +Pre-PR gate: + +``` +npm audit --audit-level=high --omit=dev # 0 vulnerabilities +npm run typecheck # clean +npm run lint # 0 errors, 24 pre-existing warnings +npm run test # 1896 / 1896 pass (was 1892; +4 new) +npm run build # OK +npm run build:web # OK +npm run docs:api # 0 errors, 1 pre-existing typedoc link warning +``` + +## Carry-out items + +_None._ diff --git a/src/domain/ports/ChatThreadsRepositoryPort.ts b/src/domain/ports/ChatThreadsRepositoryPort.ts new file mode 100644 index 00000000..c34d1e79 --- /dev/null +++ b/src/domain/ports/ChatThreadsRepositoryPort.ts @@ -0,0 +1,31 @@ +import type { ChatThreadRecord } from '@/domain/chat/ChatThreadRecord' + +/** + * Narrow port (ADR-008) for persistence of the `chatThreads` map + * (SPEC-ASM-001 §9.3, REQ-ASM-037). + * + * `load()` returns the rehydrated set of records (malformed entries already + * filtered out and logged at `warn` by the adapter — see + * `src/infrastructure/chat/chatThreadsCodec.ts`). `save()` accepts an + * in-memory snapshot and persists it; production adapters debounce internally + * to coalesce rapid mutations (OQ-ASM-T1). + * + * Domain layer (ADR-008): no `obsidian` imports. The two methods are the + * full contract — no read/write or list methods leak in. + */ +export interface ChatThreadsRepositoryPort { + /** + * Read the persisted `chatThreads` blob and return it as a + * `Map`. Malformed records are dropped at decode + * time (SPEC §11.3). Returns an empty map on first load. + */ + load(): Promise> + + /** + * Persist the in-memory `chatThreads` map. Production adapters debounce + * (see `ObsidianChatThreadsRepository.save`); the mock adapter writes + * synchronously for test determinism. `'degraded'`-transport records are + * filtered at encode time (SPEC §2.2, ADR-0031). + */ + save(records: ReadonlyMap): Promise +} diff --git a/src/domain/ports/index.ts b/src/domain/ports/index.ts index d0d74267..f8614684 100644 --- a/src/domain/ports/index.ts +++ b/src/domain/ports/index.ts @@ -32,3 +32,4 @@ export type { TransportLifecyclePort } from './TransportLifecyclePort'; export type { ConfirmModalPort, ConfirmModalRequest } from './ConfirmModalPort'; export type { SecretStorePort } from './SecretStorePort'; export { SECRET_ID_ANTHROPIC } from './SecretStorePort'; +export type { ChatThreadsRepositoryPort } from './ChatThreadsRepositoryPort'; diff --git a/src/infrastructure/bridge/ports.ts b/src/infrastructure/bridge/ports.ts index b26b829a..d0b495b3 100644 --- a/src/infrastructure/bridge/ports.ts +++ b/src/infrastructure/bridge/ports.ts @@ -12,6 +12,7 @@ import type { ConfirmModalPort, SecretStorePort, TransportLifecyclePort, + ChatThreadsRepositoryPort, } from '@/domain/ports' import type { MarkdownRenderPort } from '@/domain/ports/MarkdownRenderPort' import type { TransportKind } from '@/domain/chat/TransportKind' @@ -43,6 +44,18 @@ export const CONFIRM_MODAL_PORT: InjectionKey = Symbol('Confir * branch without reading the synced `PluginSettings` blob. */ export const SECRET_STORE_PORT: InjectionKey = Symbol('SecretStorePort') +/** + * Persistence port for the `chatThreads` map (SPEC-ASM-001 §9.3, REQ-ASM-037). + * Owned by views: `SpecoratorView.onOpen()` / `AgentSidepanelView.onOpen()` + * call `load()` to seed the Pinia chat-threads store, then subscribe to the + * store and forward mutations to `save()`. WP-14 lifted this off the plugin + * layer's `scheduleChatThreadsPersistence` side-channel into a narrow port + * (ADR-008) so the codec lives in `infrastructure/chat/` and the production + * adapter (`ObsidianChatThreadsRepository`) owns the debounce. + */ +export const CHAT_THREADS_REPO: InjectionKey = Symbol( + 'ChatThreadsRepositoryPort', +) /** * Optional `MarkdownRenderPort` provided by the Obsidian view. When * present, `MarkdownBlock.vue` delegates rendering to Obsidian's native diff --git a/src/plugin/chatThreadsPersistence.ts b/src/infrastructure/chat/chatThreadsCodec.ts similarity index 94% rename from src/plugin/chatThreadsPersistence.ts rename to src/infrastructure/chat/chatThreadsCodec.ts index f028e1d9..c20127df 100644 --- a/src/plugin/chatThreadsPersistence.ts +++ b/src/infrastructure/chat/chatThreadsCodec.ts @@ -3,7 +3,7 @@ import { asSessionId } from '@/domain/chat/SessionId' import type { LoggerPort } from '@/domain/ports/LoggerPort' /** - * Pure helpers for the `chatThreads` plugin-data blob defined by + * Pure codec helpers for the `chatThreads` plugin-data blob defined by * SPEC-ASM-001 §9.3 (REQ-ASM-037, ADR-0031). * * The blob lives under `_storedData.specorator.chatThreads` and serialises the @@ -17,7 +17,11 @@ import type { LoggerPort } from '@/domain/ports/LoggerPort' * - `transport === 'degraded'` records are not persisted (degraded threads * have no resumable session and are user-session-scoped). * - * No `obsidian` imports — pure functions consumed by `main.ts`. + * No `obsidian` imports — pure functions consumed by the + * `ChatThreadsRepositoryPort` adapters (`ObsidianChatThreadsRepository`, + * `MockChatThreadsRepository`). Relocated from `src/plugin/chatThreadsPersistence.ts` + * in WP-14 so the codec sits in `infrastructure/` and the persistence + * adapters can share it. */ /** JSON-friendly serialisation of a `ChatThreadRecord`. */ diff --git a/src/infrastructure/mock/MockChatThreadsRepository.ts b/src/infrastructure/mock/MockChatThreadsRepository.ts new file mode 100644 index 00000000..b166b55f --- /dev/null +++ b/src/infrastructure/mock/MockChatThreadsRepository.ts @@ -0,0 +1,53 @@ +import type { ChatThreadRecord } from '@/domain/chat/ChatThreadRecord' +import type { ChatThreadsRepositoryPort } from '@/domain/ports/ChatThreadsRepositoryPort' + +/** + * In-memory `ChatThreadsRepositoryPort` for unit tests and the standalone + * browser dev mode. Writes are synchronous and deterministic — there is no + * debounce, unlike the production `ObsidianChatThreadsRepository` (tests want + * determinism; SPEC-ASM-001 §9.3). + * + * Seed initial state with the `initial` constructor option. Test hooks + * (`snapshot`, `saveCount`) inspect what has been persisted without going + * through `load()`. + */ +export class MockChatThreadsRepository implements ChatThreadsRepositoryPort { + private _records: Map + private _saveCount = 0 + + constructor(opts: { initial?: ReadonlyMap | ReadonlyArray } = {}) { + if (opts.initial === undefined) { + this._records = new Map() + return + } + const initial = opts.initial + if (Array.isArray(initial)) { + const entries: Array<[string, ChatThreadRecord]> = (initial as ReadonlyArray).map( + (r) => [r.threadId, r], + ) + this._records = new Map(entries) + } else { + this._records = new Map(initial as ReadonlyMap) + } + } + + async load(): Promise> { + // Defensive copy so callers cannot mutate the internal map. + return new Map(this._records) + } + + async save(records: ReadonlyMap): Promise { + this._records = new Map(records) + this._saveCount += 1 + } + + /** Test helper: synchronous snapshot of currently-persisted records. */ + snapshot(): ReadonlyMap { + return new Map(this._records) + } + + /** Test helper: count of `save()` calls so coalescing assertions can run. */ + get saveCount(): number { + return this._saveCount + } +} diff --git a/src/infrastructure/obsidian/ObsidianChatThreadsRepository.ts b/src/infrastructure/obsidian/ObsidianChatThreadsRepository.ts new file mode 100644 index 00000000..8023be5d --- /dev/null +++ b/src/infrastructure/obsidian/ObsidianChatThreadsRepository.ts @@ -0,0 +1,272 @@ +import type { ChatThreadRecord } from '@/domain/chat/ChatThreadRecord' +import type { ChatThreadsRepositoryPort } from '@/domain/ports/ChatThreadsRepositoryPort' +import type { LoggerPort } from '@/domain/ports/LoggerPort' +import { + decodeChatThreadsBlob, + encodeChatThreadsBlob, +} from '@/infrastructure/chat/chatThreadsCodec' + +/** + * Minimal contract this adapter expects from the host Obsidian plugin. We do + * NOT depend on the concrete `Plugin` class to keep the adapter testable + * outside the Obsidian runtime; the host wires the three callbacks at + * construction time. See `src/plugin/main.ts` for the production wiring. + * + * - `loadData()` reads the raw plugin-data blob (everything stored under + * `data.json`). May be `null` on first load. + * - `saveData(data)` writes the full blob back to disk (Obsidian's atomic + * write). + * - `setActiveTimeout` / `clearActiveTimeout` route through Obsidian's + * `activeWindow.{setTimeout,clearTimeout}` so popout windows get correct + * timing semantics. In tests we route through `globalThis.setTimeout`. + */ +export interface ObsidianPluginDataHost { + loadData(): Promise + saveData(data: Record): Promise + setActiveTimeout(cb: () => void, ms: number): number + clearActiveTimeout(id: number): void +} + +/** + * Optional constructor hook invoked after each successful on-disk flush so + * the host plugin can mirror the just-persisted `chatThreads` snapshot into + * its own in-memory data cache. Codex P1 (PR #408): without this, a later + * `Plugin.saveData(this._storedData)` from `updateSettings` / + * `updateModuleSettings` would re-emit the stale pre-chat snapshot from + * `_storedData.specorator.chatThreads` and silently destroy recent threads. + * + * The closure runs *after* the disk write resolves so a failed + * `saveData()` cannot poison the host cache with a snapshot that never + * landed. Synchronous; throwing here surfaces in the flush queue. + */ +export type OnChatThreadsPersisted = ( + chatThreads: Record, +) => void + +/** + * Optional constructor hook returning the host plugin's live in-memory + * data cache (`SpecoratorPlugin._storedData`). Codex P1 round-2 (PR #408): + * the symmetric race to `OnChatThreadsPersisted`. Without a shared + * read-source, `_flushChatThreads` reads `await host.loadData()` and merges + * its `chatThreads` into a disk blob that may already be stale relative to + * an in-flight `updateSettings` / `updateModuleSettings` write — the chat + * flush then writes that stale settings blob back, silently rolling back + * the just-made settings change (or vice versa, depending on timing). + * + * When provided, the adapter prefers this synchronous read of the host + * cache over `host.loadData()` so both writers share `_storedData` as the + * single source of truth. The worst case degrades to "two writes hit disk + * in unpredictable order with the same up-to-date payload" — convergent, + * not destructive. Returning `null`/`undefined`/non-object falls back to + * `host.loadData()` so callers that haven't hydrated their cache yet still + * get correct behaviour. + */ +export type ReadHostData = () => Record | null | undefined + +/** + * Production `ChatThreadsRepositoryPort` adapter (WP-14). Lifts the + * persistence side-channel that previously lived on + * `SpecoratorPlugin.scheduleChatThreadsPersistence` (`src/plugin/main.ts`) + * into a narrow port adapter the views consume directly. + * + * Behaviour preserved from the prior implementation: + * - Reads `_storedData.specorator.chatThreads` and decodes via + * `decodeChatThreadsBlob` — malformed records are dropped and logged at + * `warn` (SPEC §11.3). When a debounced write is in flight, + * `_pendingSnapshot` is returned instead so reopening a view inside the + * debounce window rehydrates the latest in-memory state rather than the + * stale disk copy. The pending snapshot is held until the queued flush + * has *actually committed to disk* (not just until the debounce fires) + * so the gap between debounce-fire and queue-head is closed too + * (Codex P1 round-3, PR #408). + * - Writes coalesce via a 1 s trailing-edge debounce (OQ-ASM-T1) so rapid + * streaming mutations do not thrash disk. + * - Writes preserve every sibling key under `_storedData.specorator` + * (SPEC §9.3 coexistence guarantee — PluginSettings keys, per-module + * blobs, etc. survive a `save()`). + * - Flushes are serialised via a tail-chained queue so an older snapshot + * can never resolve after a newer one (Codex P1, PR #350). + * - `flushPending()` performs a final synchronous flush of the most + * recently scheduled snapshot — called from `Plugin.onunload()` so + * messages sent inside the debounce window survive plugin reload. + * - After every successful disk write the optional + * `onChatThreadsPersisted` hook fires with the encoded blob so the host + * plugin can mirror it into its own data cache and prevent later + * `saveData(this._storedData)` calls from rolling back chat history + * (Codex P1, PR #408). + * - When the host provides `readHostData()`, the flush reads sibling + * keys from the host's in-memory `_storedData` cache instead of disk + * (Codex P1 round-2, PR #408). This closes the symmetric race against + * `OnChatThreadsPersisted`: settings saves and chat-threads flushes + * share one source of truth, so neither can clobber the other by + * merging into a pre-write disk blob. + * + * Satisfies REQ-ASM-037, SPEC-ASM-001 §9.3, ADR-0031. + */ +export class ObsidianChatThreadsRepository implements ChatThreadsRepositoryPort { + /** Default debounce window in milliseconds for `save()` flushes. */ + static readonly DEFAULT_DEBOUNCE_MS = 1_000 + + private _flushTimer: number | null = null + private _pendingSnapshot: ReadonlyMap | null = null + private _flushQueue: Promise = Promise.resolve() + private readonly _debounceMs: number + private readonly _onChatThreadsPersisted: OnChatThreadsPersisted | null + private readonly _readHostData: ReadHostData | null + + constructor( + private readonly host: ObsidianPluginDataHost, + private readonly logger: LoggerPort, + opts: { + debounceMs?: number + onChatThreadsPersisted?: OnChatThreadsPersisted + readHostData?: ReadHostData + } = {}, + ) { + this._debounceMs = opts.debounceMs ?? ObsidianChatThreadsRepository.DEFAULT_DEBOUNCE_MS + this._onChatThreadsPersisted = opts.onChatThreadsPersisted ?? null + this._readHostData = opts.readHostData ?? null + } + + /** + * Returns the current chat-thread map. Precedence (Codex P1, PR #408): + * 1. If a debounced write is pending (`_pendingSnapshot !== null`), + * return a defensive copy of that in-memory snapshot. This is the + * authoritative source-of-truth between `save()` and the disk + * flush — the disk copy is by definition stale. + * 2. Otherwise, decode the persisted blob under + * `_storedData.specorator.chatThreads`. Malformed records are + * filtered by `decodeChatThreadsBlob` at `warn` (SPEC §11.3). + * + * Without (1), reopening a chat view inside the 1 s debounce window + * rehydrates the pre-save threads and the next `save()` from the store + * would persist that stale view, losing the just-created thread. + * + * Codex P1 round-3 (PR #408): the pending snapshot is now held for the + * entire lifetime of the queued flush (cleared only after `saveData()` + * resolves), not just until the debounce timer fires. This closes the + * gap where flush(A) was waiting behind an older queued flush and + * `load()` would fall through to a still-stale disk copy. + */ + async load(): Promise> { + if (this._pendingSnapshot !== null) { + return new Map(this._pendingSnapshot) + } + const stored = (await this.host.loadData()) as Record | null + if (stored === null || typeof stored !== 'object') return new Map() + const specoratorBlob = (stored.specorator ?? {}) as Record + const chatThreadsBlob = specoratorBlob.chatThreads + const records = decodeChatThreadsBlob(chatThreadsBlob, this.logger) + return new Map(records.map((r) => [r.threadId, r])) + } + + /** + * Schedule a debounced flush of `records`. Resolves immediately once the + * snapshot is captured; the actual write happens after the debounce + * elapses. Use `flushPending()` to force-flush before plugin teardown. + * + * Codex P1 round-3 (PR #408): `_pendingSnapshot` is held until the + * queued flush has resolved successfully — NOT cleared when the + * debounce timer fires. Clearing too early opened a window where an + * older flush was still in the queue, the new flush hadn't started yet, + * and `load()` would fall through to the stale disk copy. The flush + * itself clears the snapshot via an identity check (`=== snapshot`) so + * a newer `save()` that replaces `_pendingSnapshot` mid-flight is left + * alone — its own debounce schedules the next flush. + */ + async save(records: ReadonlyMap): Promise { + const snapshot = new Map(records) + this._pendingSnapshot = snapshot + if (this._flushTimer !== null) { + this.host.clearActiveTimeout(this._flushTimer) + } + this._flushTimer = this.host.setActiveTimeout(() => { + this._flushTimer = null + // Serialise via the tail-chained queue so older snapshots can never + // resolve after newer ones (Codex P1, PR #350). `.catch(() => undefined)` + // keeps the chain alive past a transient saveData failure. + this._flushQueue = this._flushQueue + .catch(() => undefined) + .then(async () => { + await this._flushChatThreads(snapshot) + // Identity equality: only clear if this exact snapshot is still + // the pending one. A newer save() that replaced it must not be + // erased by an older flush completing (Codex P1 round-3). + if (this._pendingSnapshot === snapshot) { + this._pendingSnapshot = null + } + }) + void this._flushQueue + }, this._debounceMs) + } + + /** + * Cancel any pending debounce and synchronously chain the latest pending + * snapshot onto the flush queue. Returns the queue tail so callers may + * `await` it (e.g. test code); production `onunload()` is fire-and-forget. + * + * No-op when no flush is pending. Codex P1 (PR #346): a message sent + * within the debounce window must persist even if Obsidian exits or the + * plugin is disabled before the timer fires. + * + * Codex P1 round-3 (PR #408): the queued flush clears `_pendingSnapshot` + * itself via an identity check on success. If `flushPending()` runs + * while an older flush is in-flight AND `_pendingSnapshot` has been + * replaced by a newer `save()`, the returned promise composes + * correctly: it enqueues a flush of the latest snapshot behind the + * in-flight one, and only resolves after both have committed. + */ + flushPending(): Promise { + if (this._flushTimer !== null) { + this.host.clearActiveTimeout(this._flushTimer) + this._flushTimer = null + } + if (this._pendingSnapshot === null) return this._flushQueue + const snapshot = this._pendingSnapshot + this._flushQueue = this._flushQueue + .catch(() => undefined) + .then(async () => { + await this._flushChatThreads(snapshot) + if (this._pendingSnapshot === snapshot) { + this._pendingSnapshot = null + } + }) + return this._flushQueue + } + + /** + * Internal: write the encoded `chatThreads` blob into the stored data + * while preserving every other sibling key under `specorator` (SPEC §9.3). + * + * Read source (Codex P1 round-2, PR #408): + * 1. When the host provides `readHostData()`, read from the host's + * in-memory `_storedData` cache. This is the same object the + * plugin's `updateSettings` / `updateModuleSettings` mutates, so + * both writers share a single source of truth and an in-flight + * settings save cannot be silently rolled back by a chat-threads + * flush that merged into a stale disk blob (and vice versa). + * 2. Otherwise fall back to `await host.loadData()` (preserves the + * no-closure code path used by tests with a bare host wiring). + * + * After a successful `saveData()` resolve, invokes + * `_onChatThreadsPersisted(encoded)` so the host plugin can mirror the + * write into its in-memory data cache. The hook fires *after* disk + * commit so a failed `saveData` (rejection) cannot poison the host + * cache with a snapshot that never landed (Codex P1, PR #408). + */ + private async _flushChatThreads( + records: ReadonlyMap, + ): Promise { + const encoded = encodeChatThreadsBlob(records) + const fromHost = this._readHostData?.() + const stored: Record = + fromHost !== null && fromHost !== undefined && typeof fromHost === 'object' + ? fromHost + : ((await this.host.loadData()) as Record | null) ?? {} + const currentSpecorator = (stored.specorator ?? {}) as Record + const nextSpecorator = { ...currentSpecorator, chatThreads: encoded } + const nextStored: Record = { ...stored, specorator: nextSpecorator } + await this.host.saveData(nextStored) + this._onChatThreadsPersisted?.(encoded) + } +} diff --git a/src/plugin/AgentSidepanelView.ts b/src/plugin/AgentSidepanelView.ts index 9ccd0e2d..0323d51b 100644 --- a/src/plugin/AgentSidepanelView.ts +++ b/src/plugin/AgentSidepanelView.ts @@ -26,7 +26,9 @@ import type { TransportKind } from '@/domain/chat/TransportKind'; import type { TransportSelection } from '@/plugin/transport/TransportSelector'; import { useChatThreadsStore } from '@/ui/stores/chatThreadsStore'; import { useMessagesStore } from '@/ui/stores/messagesStore'; -import { mostRecentlyUsedThreadId } from './chatThreadsPersistence'; +import { mostRecentlyUsedThreadId } from '@/infrastructure/chat/chatThreadsCodec'; +import type { ChatThreadsRepositoryPort } from '@/domain/ports/ChatThreadsRepositoryPort'; +import { CHAT_THREADS_REPO } from '@/infrastructure/bridge/ports'; import { trySync } from '@/domain/shared/tryAsync'; import type SpecoratorPlugin from './main'; @@ -42,6 +44,13 @@ export interface AgentSidepanelViewOptions { readonly subscriptionAdapter: ClaudeCliPort; readonly selectTransport: SelectAgentTransportFactory; readonly confirmModalAdapter?: ConfirmModalPort; + /** + * Persistence port for the `chatThreads` map (SPEC-ASM-001 §9.3, WP-14). + * When provided, `onOpen()` calls `load()` to seed the chat-threads store + * and forwards subsequent mutations to `save()`. Optional so legacy test + * wiring continues to compile. + */ + readonly chatThreadsRepo?: ChatThreadsRepositoryPort; /** * Optional lifecycle handles for the SDK and subscription transports. * Split off `ClaudeCliPort` in WP-12 (Arch review #3) — `startup`/`shutdown` @@ -126,7 +135,7 @@ export class AgentSidepanelView extends ItemView { return 'message-square'; } - onOpen(): Promise { + async onOpen(): Promise { const container = this.containerEl.children[1] as HTMLElement; container.empty(); @@ -141,19 +150,23 @@ export class AgentSidepanelView extends ItemView { this.pinia = createPinia(); - // Hydrate persisted chat threads. The plugin decoded the blob during - // `loadSettings()`; malformed records were already filtered out and - // logged at `warn`. Seed `activeThreadId` to the most recently used - // record so the panel resumes the user's last conversation on reopen. - // Subscribe to subsequent mutations so any change to `chatThreads` - // triggers the plugin's debounced flush. - const persisted = this.plugin.getInitialChatThreads(); + // Hydrate persisted chat threads via the `ChatThreadsRepositoryPort` + // (WP-14 — lifted off `plugin.scheduleChatThreadsPersistence`). The + // codec drops malformed records and logs at `warn` (SPEC §11.3). Seed + // `activeThreadId` to the most recently used record so the panel + // resumes the user's last conversation on reopen. Subscribe to + // subsequent mutations and forward to `repo.save()`. const threadsStore = useChatThreadsStore(this.pinia); - for (const record of persisted) threadsStore.upsertThread(record); - threadsStore.setActiveThreadId(mostRecentlyUsedThreadId(persisted)); - threadsStore.$subscribe((_mutation, state) => { - this.plugin.scheduleChatThreadsPersistence(state.chatThreads); - }); + const repo = this._options?.chatThreadsRepo; + if (repo !== undefined) { + const persistedMap = await repo.load(); + const persistedList = Array.from(persistedMap.values()); + for (const record of persistedList) threadsStore.upsertThread(record); + threadsStore.setActiveThreadId(mostRecentlyUsedThreadId(persistedList)); + threadsStore.$subscribe((_mutation, state) => { + void repo.save(state.chatThreads); + }); + } this._installPendingRefreshWatcher(); @@ -181,6 +194,9 @@ export class AgentSidepanelView extends ItemView { if (this.plugin.secretStore !== null) { this.vueApp.provide(SECRET_STORE_PORT, this.plugin.secretStore); } + if (this._options?.chatThreadsRepo !== undefined) { + this.vueApp.provide(CHAT_THREADS_REPO, this._options.chatThreadsRepo); + } if (this._options?.confirmModalAdapter !== undefined) { this.vueApp.provide(CONFIRM_MODAL_PORT, this._options.confirmModalAdapter); } @@ -219,7 +235,6 @@ export class AgentSidepanelView extends ItemView { window.addEventListener('unhandledrejection', this._onUnhandledRejection); this.vueApp.mount(mountPoint); - return Promise.resolve(); } onClose(): Promise { diff --git a/src/plugin/SpecoratorView.ts b/src/plugin/SpecoratorView.ts index ac6ae9fb..1aad3e7a 100644 --- a/src/plugin/SpecoratorView.ts +++ b/src/plugin/SpecoratorView.ts @@ -30,7 +30,9 @@ import type { TransportKind } from '@/domain/chat/TransportKind' import type { TransportSelection } from '@/plugin/transport/TransportSelector' import { useChatThreadsStore } from '@/ui/stores/chatThreadsStore' import { useMessagesStore } from '@/ui/stores/messagesStore' -import { mostRecentlyUsedThreadId } from './chatThreadsPersistence' +import { mostRecentlyUsedThreadId } from '@/infrastructure/chat/chatThreadsCodec' +import type { ChatThreadsRepositoryPort } from '@/domain/ports/ChatThreadsRepositoryPort' +import { CHAT_THREADS_REPO } from '@/infrastructure/bridge/ports' import { trySync } from '@/domain/shared/tryAsync' import type SpecoratorPlugin from './main' @@ -49,6 +51,14 @@ export type SelectTransportFactory = (settings: PluginSettings) => TransportSele export interface SpecoratorViewOptions { readonly subscriptionAdapter: ClaudeCliPort readonly selectTransport: SelectTransportFactory + /** + * Persistence port for the `chatThreads` map (SPEC-ASM-001 §9.3, + * WP-14). When provided, `onOpen()` calls `load()` to seed the chat-threads + * store and forwards subsequent mutations to `save()`. Optional so legacy + * test wiring continues to compile; absent → no persistence (store starts + * empty, mutations are not persisted). + */ + readonly chatThreadsRepo?: ChatThreadsRepositoryPort /** * Production-grade modal adapter (`ObsidianConfirmModalAdapter`) constructed * in `main.ts`'s `onload()` (SPEC-ASM-001 §9.1). Provided to Vue under @@ -164,7 +174,7 @@ export class SpecoratorView extends ItemView { getDisplayText(): string { return 'Specorator' } getIcon(): string { return 'layout-dashboard' } - onOpen(): Promise { + async onOpen(): Promise { const container = this.containerEl.children[1] as HTMLElement container.empty() @@ -180,19 +190,23 @@ export class SpecoratorView extends ItemView { this.pinia = createPinia() // SPEC-ASM-001 §9.5 / REQ-ASM-037 — hydrate persisted chat threads into - // the Pinia chat store before the view mounts. The plugin decoded the - // blob during `loadSettings()`; malformed records were already filtered - // out and logged at `warn`. `activeThreadId` is seeded to the most - // recently used record so the chat sidebar resumes the user's last - // conversation. Subscribe to subsequent mutations so any change to - // `chatThreads` triggers a debounced flush back to plugin data. - const persisted = this.plugin.getInitialChatThreads() + // the Pinia chat store before the view mounts. WP-14 lifted the + // persistence side-channel onto `ChatThreadsRepositoryPort`: load via + // `repo.load()`, then subscribe + forward mutations to `repo.save()`. + // The codec drops malformed records and logs at `warn` (SPEC §11.3). + // `activeThreadId` is seeded to the most recently used record so the + // chat sidebar resumes the user's last conversation. const threadsStore = useChatThreadsStore(this.pinia) - for (const record of persisted) threadsStore.upsertThread(record) - threadsStore.setActiveThreadId(mostRecentlyUsedThreadId(persisted)) - threadsStore.$subscribe((_mutation, state) => { - this.plugin.scheduleChatThreadsPersistence(state.chatThreads) - }) + const repo = this._options?.chatThreadsRepo + if (repo !== undefined) { + const persistedMap = await repo.load() + const persistedList = Array.from(persistedMap.values()) + for (const record of persistedList) threadsStore.upsertThread(record) + threadsStore.setActiveThreadId(mostRecentlyUsedThreadId(persistedList)) + threadsStore.$subscribe((_mutation, state) => { + void repo.save(state.chatThreads) + }) + } this._installPendingRefreshWatcher() @@ -229,6 +243,9 @@ export class SpecoratorView extends ItemView { if (this.plugin.secretStore !== null) { this.vueApp.provide(SECRET_STORE_PORT, this.plugin.secretStore) } + if (this._options?.chatThreadsRepo !== undefined) { + this.vueApp.provide(CHAT_THREADS_REPO, this._options.chatThreadsRepo) + } // REQ-ASM-044 / SPEC-ASM-001 §9.5 — production-grade confirmation modal // for proposal-flow accepts. `ChatSidebar` injects this via // `useConfirmModalPort()` (PR-ASM-4 batch 7). The provide is @@ -290,7 +307,6 @@ export class SpecoratorView extends ItemView { this._router = router this.vueApp.mount(mountPoint) - return Promise.resolve() } onClose(): Promise { diff --git a/src/plugin/main.ts b/src/plugin/main.ts index 68875065..3b0c682c 100644 --- a/src/plugin/main.ts +++ b/src/plugin/main.ts @@ -12,11 +12,7 @@ import { promoteLegacyFlatSettings } from './loadSettings-migrate' import { ensureLeafLoaded } from './leafLoader' import { selectTransport } from './transport/TransportSelector' import { DEFAULT_SETTINGS, type PluginSettings } from '@/domain/settings/PluginSettings' -import type { ChatThreadRecord } from '@/domain/chat/ChatThreadRecord' -import { - decodeChatThreadsBlob, - encodeChatThreadsBlob, -} from './chatThreadsPersistence' +import { ObsidianChatThreadsRepository } from '@/infrastructure/obsidian/ObsidianChatThreadsRepository' import { ObsidianBridge } from '@/infrastructure/obsidian/ObsidianBridge' import { ObsidianConfirmModalAdapter } from '@/infrastructure/obsidian/ObsidianConfirmModalAdapter' import { ObsidianMcpServerAdapter } from '@/infrastructure/obsidian/ObsidianMcpServerAdapter' @@ -88,32 +84,17 @@ export default class SpecoratorPlugin extends Plugin { private _agentSidepanelView: AgentSidepanelView | null = null /** - * Initial `chatThreads` records hydrated from plugin data at `loadSettings()` - * (SPEC-ASM-001 §9.3, ADR-0031). Read by `SpecoratorView.onOpen()` to seed - * the Pinia chat store and reset on each successful read. Malformed records - * are filtered out at decode time and logged at `warn` (SPEC §11.3). - */ - private _initialChatThreads: ReadonlyArray = [] - /** Debounced persistence timer for `chatThreads`. SPEC §9.3 / OQ-ASM-T1. */ - private _chatThreadsFlushTimer: number | null = null - /** - * Latest snapshot scheduled by `scheduleChatThreadsPersistence` but not yet - * flushed to plugin data. Kept on the class so `onunload()` can perform a - * final synchronous flush before the debounce timer fires — without this, - * a message sent within the 1 s debounce window before Obsidian exits or - * the plugin is disabled would silently fail to persist (Codex P1, PR #346). - */ - private _pendingChatThreadsSnapshot: ReadonlyMap | null = null - /** - * Tail of the chat-thread flush chain. Each new flush is chained off the - * previous one's settled promise so writes are strictly serialised: an - * older snapshot can never resolve AFTER a newer snapshot and clobber it - * (Codex P1, PR #350). Initialised to a settled promise so the first - * flush attaches without an extra branch. + * Production `ChatThreadsRepositoryPort` adapter (WP-14). Owns the + * debounced `Plugin.saveData` persistence path previously hosted by + * `scheduleChatThreadsPersistence`. Constructed in `loadSettings()` + * because the host callbacks close over `this.loadData` / `this.saveData` + * and `this.app`'s `activeWindow` for the debounce timer. + * + * `flushPending()` is called from `onunload()` to drain any in-flight + * snapshot so a message sent inside the debounce window survives plugin + * reload (Codex P1, PR #346). */ - private _chatThreadsFlushQueue: Promise = Promise.resolve() - /** Default debounce window in milliseconds for chatThreads flushes. */ - private static readonly _CHAT_THREADS_FLUSH_DEBOUNCE_MS = 1_000 + private _chatThreadsRepo: ObsidianChatThreadsRepository | null = null async onload(): Promise { await this.loadSettings() @@ -216,6 +197,7 @@ export default class SpecoratorPlugin extends Plugin { // them on settings bumps without depending on `ClaudeCliPort`. sdkLifecycle: this._claudeCliAdapter!, subscriptionLifecycle: this._subscriptionAdapter!, + chatThreadsRepo: this._chatThreadsRepo!, selectTransport: (settings) => selectTransport(settings, { sdkAdapter: this._claudeCliAdapter!, @@ -245,6 +227,7 @@ export default class SpecoratorPlugin extends Plugin { confirmModalAdapter: this._confirmModalAdapter!, sdkLifecycle: this._claudeCliAdapter!, subscriptionLifecycle: this._subscriptionAdapter!, + chatThreadsRepo: this._chatThreadsRepo!, selectTransport: (settings) => selectTransport(settings, { sdkAdapter: this._claudeCliAdapter!, @@ -396,27 +379,12 @@ export default class SpecoratorPlugin extends Plugin { // expected cleanup path despite the obsidianmd/detach-leaves rule's caution. // eslint-disable-next-line obsidianmd/detach-leaves override onunload(): void { - // Cancel the pending debounce — we're about to flush directly below. - if (this._chatThreadsFlushTimer !== null) { - activeWindow.clearTimeout(this._chatThreadsFlushTimer) - this._chatThreadsFlushTimer = null - } - // Final synchronous flush of any snapshot scheduled within the debounce - // window but not yet written. Without this, a message sent immediately - // before Obsidian exits / the plugin is disabled would be lost (Codex P1, - // PR #346). `_flushChatThreads` is async; onunload() is fire-and-forget - // per Obsidian's contract, so void is correct here. - if (this._pendingChatThreadsSnapshot !== null) { - const snapshot = this._pendingChatThreadsSnapshot - this._pendingChatThreadsSnapshot = null - // Tail-chain so an in-flight debounced flush finishes before this - // final write (Codex P1, PR #350). Without this, the final flush - // could race a still-resolving prior flush and lose its update. - this._chatThreadsFlushQueue = this._chatThreadsFlushQueue - .catch(() => undefined) - .then(() => this._flushChatThreads(snapshot)) - void this._chatThreadsFlushQueue - } + // Drain any in-flight chatThreads snapshot before exit so a message sent + // inside the debounce window survives plugin reload (Codex P1, PR #346). + // `flushPending()` is async; onunload() is fire-and-forget per Obsidian's + // contract, so void is correct here. WP-14: lifted from the inline timer + // dance onto the `ObsidianChatThreadsRepository`'s queue. + void this._chatThreadsRepo?.flushPending() this.app.workspace.detachLeavesOfType(VIEW_TYPE) this.app.workspace.detachLeavesOfType(VIEW_TYPE_AGENT) this.bridge?.hideAllNotices() @@ -438,19 +406,48 @@ export default class SpecoratorPlugin extends Plugin { await this._initializeSecretStore() - // SPEC-ASM-001 §9.3 — read the `chatThreads` blob alongside settings so - // `SpecoratorView.onOpen()` can hydrate the chat store after the view - // mounts. Decoding uses the plugin bridge logger when present (typed once - // available in `onload()`); during `loadSettings()` we route warnings to - // `console.warn` because `this.bridge` is not yet constructed. - const specoratorBlob = (this._storedData.specorator ?? {}) as Record - const chatThreadsBlob = specoratorBlob.chatThreads - this._initialChatThreads = decodeChatThreadsBlob(chatThreadsBlob, { + // SPEC-ASM-001 §9.3, WP-14 — construct the chatThreads repo so the views + // can `load()`/`save()` directly via the narrow `ChatThreadsRepositoryPort`. + // The repo wraps `Plugin.loadData`/`saveData` and owns the 1 s debounce + // (OQ-ASM-T1). During `loadSettings()` warnings route to `console.warn` + // because `this.bridge` is not yet constructed. + const chatThreadsLogger = { debug: () => undefined, info: () => undefined, - warn: (msg, ctx) => { console.warn(msg, ctx ?? {}) }, - error: (msg, ctx) => { console.error(msg, ctx ?? {}) }, - }) + warn: (msg: string, ctx?: unknown) => { console.warn(msg, ctx ?? {}) }, + error: (msg: string, ctx?: unknown) => { console.error(msg, ctx ?? {}) }, + } + this._chatThreadsRepo = new ObsidianChatThreadsRepository( + { + loadData: () => this.loadData(), + saveData: (data) => this.saveData(data), + setActiveTimeout: (cb, ms) => activeWindow.setTimeout(cb, ms), + clearActiveTimeout: (id) => { activeWindow.clearTimeout(id) }, + }, + chatThreadsLogger, + { + // Codex P1 (PR #408): mirror every successful chat-threads disk + // write into `this._storedData` so the next `updateSettings` / + // `updateModuleSettings` call — which persists from this cache — + // cannot resurrect the pre-chat snapshot and silently destroy + // recent threads. Runs *after* `saveData()` resolves, never on + // failure, matching the adapter's flush ordering. + onChatThreadsPersisted: (chatThreads) => { + const currentSpecorator = (this._storedData.specorator ?? {}) as Record + this._storedData = { + ...this._storedData, + specorator: { ...currentSpecorator, chatThreads }, + } + }, + // Codex P1 round-2 (PR #408): symmetric to `onChatThreadsPersisted`. + // Without this, the adapter would `await host.loadData()` at flush + // time and merge `chatThreads` into a pre-`updateSettings` disk + // blob, silently rolling back any in-flight settings save (or + // vice versa, depending on write order). Returning `this._storedData` + // makes both writers share one source of truth. + readHostData: () => this._storedData, + }, + ) } /** @@ -509,66 +506,6 @@ export default class SpecoratorPlugin extends Plugin { return outcome.value ?? '' } - /** - * Records hydrated from `_storedData.specorator.chatThreads` during - * `loadSettings()`. Consumed by `SpecoratorView.onOpen()` (SPEC §9.5 / - * REQ-ASM-037) to seed the Pinia chat store. - */ - getInitialChatThreads(): ReadonlyArray { - return this._initialChatThreads - } - - /** - * Persists the in-memory `chatThreads` map to plugin data under - * `_storedData.specorator.chatThreads` (SPEC §9.3). Filters out - * `degraded`-transport records at encode time. Coalesces rapid mutations - * via a 1 s debounce (OQ-ASM-T1) to prevent disk thrashing during streaming - * turns. - * - * Satisfies REQ-ASM-037 / T-ASM-054. - */ - scheduleChatThreadsPersistence(records: ReadonlyMap): void { - const snapshot = new Map(records) - this._pendingChatThreadsSnapshot = snapshot - // Refresh the rehydrate-on-reopen snapshot so a panel close/reopen in the - // same plugin session sees the latest threads, not the stale set captured - // at `loadSettings()` time (Codex P1, PR #350). Without this, reopening - // the panel after thread mutations would restore the stale map and the - // next mutation would persist it back, losing newer conversations. - this._initialChatThreads = Array.from(snapshot.values()) - if (this._chatThreadsFlushTimer !== null) { - activeWindow.clearTimeout(this._chatThreadsFlushTimer) - } - this._chatThreadsFlushTimer = activeWindow.setTimeout(() => { - this._chatThreadsFlushTimer = null - this._pendingChatThreadsSnapshot = null - // Serialise via the tail-chained queue so older snapshots can never - // resolve after newer ones (Codex P1, PR #350). `.catch(() => undefined)` - // keeps the chain alive past a transient saveData failure — the next - // flush should still attempt to write, not be silently swallowed by a - // rejected predecessor. - this._chatThreadsFlushQueue = this._chatThreadsFlushQueue - .catch(() => undefined) - .then(() => this._flushChatThreads(snapshot)) - void this._chatThreadsFlushQueue - }, SpecoratorPlugin._CHAT_THREADS_FLUSH_DEBOUNCE_MS) - } - - /** - * Internal: write the encoded `chatThreads` blob into `_storedData` while - * preserving every other sibling key under `specorator` (PluginSettings, - * unrelated module data, etc.) — see SPEC §9.3 coexistence guarantee. - */ - private async _flushChatThreads( - records: ReadonlyMap, - ): Promise { - const encoded = encodeChatThreadsBlob(records) - const currentSpecorator = (this._storedData.specorator ?? {}) as Record - const nextSpecorator = { ...currentSpecorator, chatThreads: encoded } - this._storedData = { ...this._storedData, specorator: nextSpecorator } - await this.saveData(this._storedData) - } - async updateSettings(partial: Partial): Promise { const merged = { ...this.settings, ...partial } this.settings = merged diff --git a/src/ui/composables/useChatThreadsRepo.ts b/src/ui/composables/useChatThreadsRepo.ts new file mode 100644 index 00000000..c82bec89 --- /dev/null +++ b/src/ui/composables/useChatThreadsRepo.ts @@ -0,0 +1,21 @@ +import { inject } from 'vue' +import type { ChatThreadsRepositoryPort } from '@/domain/ports/ChatThreadsRepositoryPort' +import { CHAT_THREADS_REPO } from '@/infrastructure/bridge/ports' + +/** + * Composable accessor for the {@link ChatThreadsRepositoryPort} (ADR-008, + * WP-14). Throws if no repository was provided — there is no sensible + * defaulting behaviour for a missing persistence port, and silently + * returning a stub would mask wiring bugs. + * + * Consumed by `SpecoratorView` and `AgentSidepanelView` to load + save the + * `chatThreads` map. UI components do NOT call this directly: store + * persistence is the views' responsibility. + */ +export function useChatThreadsRepo(): ChatThreadsRepositoryPort { + const port = inject(CHAT_THREADS_REPO, null) + if (port === null) { + throw new Error('ChatThreadsRepositoryPort was not provided') + } + return port +} diff --git a/tests/__fakes__/fake-ports.ts b/tests/__fakes__/fake-ports.ts index d444ecd2..f00c8038 100644 --- a/tests/__fakes__/fake-ports.ts +++ b/tests/__fakes__/fake-ports.ts @@ -2,6 +2,7 @@ import { vi } from 'vitest' import { MockBridge } from '@/infrastructure/mock/MockBridge' import { MockMetadataCacheAdapter } from '@/infrastructure/mock/MockMetadataCacheAdapter' import { MockCanvasAdapter } from '@/infrastructure/mock/MockCanvasAdapter' +import { MockChatThreadsRepository } from '@/infrastructure/mock/MockChatThreadsRepository' import type { SettingsPort, VaultPort, @@ -37,12 +38,14 @@ export interface FakePorts { readonly bridge: MockBridge readonly metadataCache: MockMetadataCacheAdapter readonly canvas: MockCanvasAdapter + readonly chatThreadsRepo: MockChatThreadsRepository } export function fakeModulePorts(): FakePorts { const bridge = new MockBridge() const metadataCache = new MockMetadataCacheAdapter() const canvas = new MockCanvasAdapter() + const chatThreadsRepo = new MockChatThreadsRepository() return { settings: bridge, vault: bridge, @@ -60,5 +63,6 @@ export function fakeModulePorts(): FakePorts { bridge, metadataCache, canvas, + chatThreadsRepo, } } diff --git a/tests/domain/chat/ChatThreadRecord.test.ts b/tests/domain/chat/ChatThreadRecord.test.ts new file mode 100644 index 00000000..c81e9d95 --- /dev/null +++ b/tests/domain/chat/ChatThreadRecord.test.ts @@ -0,0 +1,76 @@ +/** + * Type-level invariants for {@link ChatThreadRecord} (SPEC-ASM-001 §2.2). + * + * WP-14: complementary to the wire-shape tests in + * `tests/infrastructure/chat/chatThreadsCodec.test.ts` — those tests cover + * runtime validation via `parseChatThreadRecord`; this file pins down the + * compile-time shape so future schema drift is caught at type-check time. + */ +import { describe, it, expect, expectTypeOf } from 'vitest' +import type { ChatThreadRecord } from '@/domain/chat/ChatThreadRecord' +import { asSessionId, type SessionId } from '@/domain/chat/SessionId' + +describe('ChatThreadRecord — required fields', () => { + it('exposes the seven SPEC §2.2 fields with the documented types', () => { + expectTypeOf().toHaveProperty('threadId').toEqualTypeOf() + expectTypeOf().toHaveProperty('sessionId').toEqualTypeOf() + expectTypeOf().toHaveProperty('feature').toEqualTypeOf() + expectTypeOf().toHaveProperty('logPath').toEqualTypeOf() + expectTypeOf().toHaveProperty('transport') + .toEqualTypeOf<'api-key' | 'subscription'>() + expectTypeOf().toHaveProperty('createdAt').toEqualTypeOf() + expectTypeOf().toHaveProperty('lastUsedAt').toEqualTypeOf() + }) + + it('all fields are readonly (immutable record)', () => { + // Constructing a value satisfies the readonly constraint — direct + // assignment would be a compile error. We assert structural shape at + // runtime as a sanity check. + const record: ChatThreadRecord = { + threadId: 't1', + sessionId: asSessionId('s1'), + feature: 'foo', + logPath: 'specs/foo/sessions/s1.md', + transport: 'subscription', + createdAt: '2026-05-17T00:00:00.000Z', + lastUsedAt: '2026-05-17T00:00:00.000Z', + } + expect(record.threadId).toBe('t1') + expect(record.sessionId).toBe('s1') + }) +}) + +describe('ChatThreadRecord — transport literal (degraded not persisted)', () => { + it('rejects transport === "degraded" at the type level', () => { + // `transport` is `'api-key' | 'subscription'` — `'degraded'` records exist + // in memory (user-session-scoped) but are filtered at encode time by + // `encodeChatThreadsBlob` (ADR-0031). The compile-time shape pins this so + // a refactor cannot accidentally widen the union. + expectTypeOf().not.toEqualTypeOf() + expectTypeOf<'degraded'>().not.toExtend() + expectTypeOf<'api-key'>().toExtend() + expectTypeOf<'subscription'>().toExtend() + }) +}) + +describe('ChatThreadRecord — sessionId nullability', () => { + it('accepts null for the pre-init / SDK case', () => { + const record: ChatThreadRecord = { + threadId: 't1', + sessionId: null, + feature: null, + logPath: '.specorator/sessions/t1.md', + transport: 'api-key', + createdAt: '2026-05-17T00:00:00.000Z', + lastUsedAt: '2026-05-17T00:00:00.000Z', + } + expect(record.sessionId).toBeNull() + expect(record.feature).toBeNull() + }) + + it('non-null sessionId is branded (not a plain string)', () => { + expectTypeOf>().toEqualTypeOf() + // A raw string is NOT a SessionId (the brand is unforgeable). + expectTypeOf().not.toExtend() + }) +}) diff --git a/tests/domain/chat/SessionId.test.ts b/tests/domain/chat/SessionId.test.ts new file mode 100644 index 00000000..b97b1138 --- /dev/null +++ b/tests/domain/chat/SessionId.test.ts @@ -0,0 +1,70 @@ +/** + * Domain VO tests for {@link SessionId} (SPEC-ASM-001 §2.2). + * + * WP-14: this file lifts coverage on `src/domain/chat/SessionId.ts` (previously + * 0% statement coverage as a standalone file — the brand was only exercised + * transitively via `parseChatThreadRecord`). + * + * Asserts: + * - `asSessionId` is a zero-cost brand: the returned value is `===` to the + * raw string at runtime (no wrapping, no allocation). + * - Distinct brand identity: a `SessionId` value is still a string at + * runtime so JSON serialisation round-trips it without special handling. + * - The brand is opaque: arbitrary strings cannot be assigned to a + * `SessionId` slot at the type level (compile-time check via + * `expectTypeOf`). + */ +import { describe, it, expect, expectTypeOf } from 'vitest' +import { asSessionId, type SessionId } from '@/domain/chat/SessionId' + +describe('asSessionId — zero-cost brand', () => { + it('returns the exact same string reference at runtime (no wrapping)', () => { + const raw = 'sess-abc-123' + const branded = asSessionId(raw) + // `===` rather than `toBe` to make the intent explicit. + expect(branded === raw).toBe(true) + }) + + it('preserves the underlying string contents (typeof string at runtime)', () => { + const branded = asSessionId('abc') + expect(typeof branded).toBe('string') + expect(String(branded)).toBe('abc') + }) + + it('accepts the empty string (validation is the caller\'s responsibility — see SPEC §3.1)', () => { + // The brand constructor does NOT validate; the subprocess adapter is the + // single authority on what a valid session id looks like. The codec layer + // (`parseChatThreadRecord`) covers shape validation separately. + const branded = asSessionId('') + expect(branded).toBe('') + }) + + it('round-trips through JSON.stringify / JSON.parse as a plain string', () => { + const branded = asSessionId('sess-roundtrip') + const json = JSON.stringify({ id: branded }) + const parsed = JSON.parse(json) as { id: string } + expect(parsed.id).toBe('sess-roundtrip') + }) +}) + +describe('SessionId — type-level invariants (compile-time)', () => { + it('is structurally a string (assignable to string)', () => { + const branded = asSessionId('s1') + expectTypeOf().toExtend() + // Assignable to string at the type level (no runtime check needed). + const asString: string = branded + expect(asString).toBe('s1') + }) + + it('is NOT a plain string (brand prevents accidental mixing)', () => { + // A raw string literal must not be assignable to `SessionId` — the brand + // is unforgeable except through `asSessionId`. We assert this at the type + // level via `expectTypeOf`: `'plain'` does NOT extend `SessionId`. + expectTypeOf<'plain'>().not.toExtend() + }) + + it('asSessionId returns a SessionId-typed value', () => { + expectTypeOf(asSessionId).returns.toEqualTypeOf() + expectTypeOf(asSessionId).parameter(0).toEqualTypeOf() + }) +}) diff --git a/tests/plugin/chatThreadsPersistence.test.ts b/tests/infrastructure/chat/chatThreadsCodec.test.ts similarity index 99% rename from tests/plugin/chatThreadsPersistence.test.ts rename to tests/infrastructure/chat/chatThreadsCodec.test.ts index bf1dfc91..a6cb163e 100644 --- a/tests/plugin/chatThreadsPersistence.test.ts +++ b/tests/infrastructure/chat/chatThreadsCodec.test.ts @@ -17,7 +17,7 @@ */ import { describe, it, expect, beforeEach, vi } from 'vitest' import { setActivePinia, createPinia } from 'pinia' -import { getChatStoresFacade } from '../__fakes__/chatStoresFacade' +import { getChatStoresFacade } from '../../__fakes__/chatStoresFacade' import { asSessionId } from '@/domain/chat/SessionId' import type { ChatThreadRecord } from '@/domain/chat/ChatThreadRecord' import type { LoggerPort } from '@/domain/ports/LoggerPort' @@ -27,7 +27,7 @@ import { mostRecentlyUsedThreadId, parseChatThreadRecord, type SerialisedChatThreadRecord, -} from '@/plugin/chatThreadsPersistence' +} from '@/infrastructure/chat/chatThreadsCodec' function makeLogger(): LoggerPort { return { diff --git a/tests/infrastructure/mock/MockChatThreadsRepository.test.ts b/tests/infrastructure/mock/MockChatThreadsRepository.test.ts new file mode 100644 index 00000000..42355f85 --- /dev/null +++ b/tests/infrastructure/mock/MockChatThreadsRepository.test.ts @@ -0,0 +1,106 @@ +/** + * Tests for {@link MockChatThreadsRepository} (WP-14). + * + * Asserts the in-memory adapter mirrors the `ChatThreadsRepositoryPort` + * contract: `save()` is synchronous (no debounce), `load()` returns the + * persisted snapshot, and seed options work for both Map and Array shapes. + */ +import { describe, it, expect } from 'vitest' +import { asSessionId } from '@/domain/chat/SessionId' +import type { ChatThreadRecord } from '@/domain/chat/ChatThreadRecord' +import { MockChatThreadsRepository } from '@/infrastructure/mock/MockChatThreadsRepository' + +function makeRecord(overrides: Partial = {}): ChatThreadRecord { + return { + threadId: 't1', + sessionId: asSessionId('sess-1'), + feature: 'foo', + logPath: 'specs/foo/sessions/sess-1.md', + transport: 'subscription', + createdAt: '2026-05-17T10:00:00.000Z', + lastUsedAt: '2026-05-17T10:00:00.000Z', + ...overrides, + } +} + +describe('MockChatThreadsRepository — load/save round-trip', () => { + it('starts empty when no initial seed is supplied', async () => { + const repo = new MockChatThreadsRepository() + const loaded = await repo.load() + expect(loaded.size).toBe(0) + }) + + it('persists what save() writes; load() returns the saved snapshot', async () => { + const repo = new MockChatThreadsRepository() + const map = new Map([ + ['t1', makeRecord({ threadId: 't1' })], + ['t2', makeRecord({ threadId: 't2', transport: 'api-key', sessionId: null })], + ]) + await repo.save(map) + const loaded = await repo.load() + expect(loaded.size).toBe(2) + expect(loaded.get('t1')?.threadId).toBe('t1') + expect(loaded.get('t2')?.sessionId).toBeNull() + }) + + it('save() is synchronous: snapshot reflects the latest call immediately', async () => { + const repo = new MockChatThreadsRepository() + await repo.save(new Map([['t1', makeRecord({ threadId: 't1' })]])) + await repo.save( + new Map([ + ['t1', makeRecord({ threadId: 't1' })], + ['t2', makeRecord({ threadId: 't2', transport: 'api-key', sessionId: null })], + ]), + ) + expect(repo.saveCount).toBe(2) + const snapshot = repo.snapshot() + expect(Array.from(snapshot.keys()).sort()).toEqual(['t1', 't2']) + }) +}) + +describe('MockChatThreadsRepository — initial seed', () => { + it('accepts an initial Map seed', async () => { + const initial = new Map([['t1', makeRecord({ threadId: 't1' })]]) + const repo = new MockChatThreadsRepository({ initial }) + const loaded = await repo.load() + expect(loaded.size).toBe(1) + expect(loaded.get('t1')?.threadId).toBe('t1') + }) + + it('accepts an initial array seed keyed by threadId', async () => { + const initial: ChatThreadRecord[] = [ + makeRecord({ threadId: 'a' }), + makeRecord({ threadId: 'b', transport: 'api-key', sessionId: null }), + ] + const repo = new MockChatThreadsRepository({ initial }) + const loaded = await repo.load() + expect(loaded.size).toBe(2) + expect(loaded.get('a')?.threadId).toBe('a') + expect(loaded.get('b')?.transport).toBe('api-key') + }) +}) + +describe('MockChatThreadsRepository — defensive copies', () => { + it('load() returns a fresh Map that mutating does not affect the store', async () => { + const repo = new MockChatThreadsRepository({ + initial: new Map([['t1', makeRecord({ threadId: 't1' })]]), + }) + const loaded = await repo.load() + // Mutate the returned map (cast to writable to defeat the type guard). + ;(loaded as Map).delete('t1') + const reloaded = await repo.load() + // Internal store is untouched. + expect(reloaded.size).toBe(1) + expect(reloaded.get('t1')?.threadId).toBe('t1') + }) + + it('snapshot() returns a fresh Map distinct from load()', async () => { + const repo = new MockChatThreadsRepository() + await repo.save(new Map([['t1', makeRecord({ threadId: 't1' })]])) + const a = repo.snapshot() + const b = await repo.load() + expect(a).not.toBe(b) + expect(a.get('t1')?.threadId).toBe('t1') + expect(b.get('t1')?.threadId).toBe('t1') + }) +}) diff --git a/tests/infrastructure/obsidian/ObsidianChatThreadsRepository.test.ts b/tests/infrastructure/obsidian/ObsidianChatThreadsRepository.test.ts new file mode 100644 index 00000000..4c909556 --- /dev/null +++ b/tests/infrastructure/obsidian/ObsidianChatThreadsRepository.test.ts @@ -0,0 +1,888 @@ +/** + * Tests for {@link ObsidianChatThreadsRepository} (WP-14). + * + * Asserts the production adapter preserves the persistence semantics + * previously hosted by `SpecoratorPlugin.scheduleChatThreadsPersistence`: + * - `load()` decodes the blob under `_storedData.specorator.chatThreads`. + * - `save()` debounces (1 s default) and coalesces rapid mutations. + * - Writes preserve every sibling key under `specorator.*` (SPEC §9.3). + * - Degraded-transport records are filtered at encode time. + * - Flushes are serialised: an older snapshot cannot resolve after a + * newer one (Codex P1, PR #350). + * - `flushPending()` drains the latest pending snapshot synchronously + * so onunload-time writes survive plugin reload (Codex P1, PR #346). + * + * The tests route `setActiveTimeout`/`clearActiveTimeout` through the + * standard `setTimeout`/`clearTimeout` because there is no Obsidian + * `activeWindow` in the vitest environment; production wires them through + * `activeWindow` (see `src/plugin/main.ts`). The obsidianmd timer/doc + * lint rules are disabled in this test scope only. + */ +/* eslint-disable obsidianmd/prefer-active-window-timers, obsidianmd/prefer-active-doc */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { asSessionId } from '@/domain/chat/SessionId' +import type { ChatThreadRecord } from '@/domain/chat/ChatThreadRecord' +import type { LoggerPort } from '@/domain/ports/LoggerPort' +import { + ObsidianChatThreadsRepository, + type ObsidianPluginDataHost, +} from '@/infrastructure/obsidian/ObsidianChatThreadsRepository' + +interface HarnessState { + blob: Record | null + saveCount: number +} + +interface Harness { + readonly state: HarnessState + readonly host: ObsidianPluginDataHost + readonly repo: ObsidianChatThreadsRepository +} + +function silentLogger(): LoggerPort { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } +} + +function makeHarness(initial: Record | null = null): Harness { + const state: HarnessState = { blob: initial, saveCount: 0 } + const host: ObsidianPluginDataHost = { + loadData: vi.fn(async () => state.blob), + saveData: vi.fn(async (data: Record) => { + state.blob = data + state.saveCount += 1 + }), + setActiveTimeout: (cb, ms) => setTimeout(cb, ms) as unknown as number, + clearActiveTimeout: (id) => { clearTimeout(id) }, + } + const repo = new ObsidianChatThreadsRepository(host, silentLogger()) + return { state, host, repo } +} + +function makeRecord(overrides: Partial = {}): ChatThreadRecord { + return { + threadId: 't1', + sessionId: asSessionId('sess-1'), + feature: 'foo', + logPath: 'specs/foo/sessions/sess-1.md', + transport: 'subscription', + createdAt: '2026-05-17T10:00:00.000Z', + lastUsedAt: '2026-05-17T10:00:00.000Z', + ...overrides, + } +} + +describe('ObsidianChatThreadsRepository.load', () => { + it('returns an empty map when no plugin data has been written', async () => { + const { repo } = makeHarness(null) + const loaded = await repo.load() + expect(loaded.size).toBe(0) + }) + + it('returns an empty map when the chatThreads sub-key is missing', async () => { + const { repo } = makeHarness({ specorator: { locale: 'en' } }) + const loaded = await repo.load() + expect(loaded.size).toBe(0) + }) + + it('decodes a well-formed blob under specorator.chatThreads', async () => { + const { repo } = makeHarness({ + specorator: { + chatThreads: { + t1: { + threadId: 't1', sessionId: 's1', feature: 'foo', + logPath: 'specs/foo/sessions/s1.md', transport: 'subscription', + createdAt: '2026-05-17T08:00:00.000Z', + lastUsedAt: '2026-05-17T09:00:00.000Z', + }, + }, + }, + }) + const loaded = await repo.load() + expect(loaded.size).toBe(1) + expect(loaded.get('t1')?.threadId).toBe('t1') + expect(loaded.get('t1')?.sessionId).toBe('s1') + }) + + it('returns an empty map when stored data is non-object (defensive)', async () => { + const { repo, host } = makeHarness() + ;(host.loadData as ReturnType).mockResolvedValueOnce('not-an-object') + const loaded = await repo.load() + expect(loaded.size).toBe(0) + }) +}) + +describe('ObsidianChatThreadsRepository.save — debounced flush', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('writes a single blob after the 1 s debounce window expires', async () => { + const { state, repo } = makeHarness({ specorator: { locale: 'en' } }) + await repo.save(new Map([['t1', makeRecord({ threadId: 't1' })]])) + expect(state.saveCount).toBe(0) + await vi.advanceTimersByTimeAsync(1_000) + expect(state.saveCount).toBe(1) + const specorator = state.blob?.specorator as Record + expect(specorator.chatThreads).toEqual({ + t1: { + threadId: 't1', sessionId: 'sess-1', feature: 'foo', + logPath: 'specs/foo/sessions/sess-1.md', transport: 'subscription', + createdAt: '2026-05-17T10:00:00.000Z', lastUsedAt: '2026-05-17T10:00:00.000Z', + }, + }) + }) + + it('coalesces rapid mutations into one flush', async () => { + const { state, repo } = makeHarness({ specorator: {} }) + await repo.save(new Map([['t1', makeRecord({ threadId: 't1' })]])) + await repo.save(new Map([['t1', makeRecord({ threadId: 't1' })]])) + await repo.save( + new Map([ + ['t1', makeRecord({ threadId: 't1' })], + ['t2', makeRecord({ threadId: 't2', transport: 'api-key', sessionId: null })], + ]), + ) + await vi.advanceTimersByTimeAsync(1_000) + expect(state.saveCount).toBe(1) + const specorator = state.blob?.specorator as Record + const chatThreads = specorator.chatThreads as Record + expect(Object.keys(chatThreads).sort()).toEqual(['t1', 't2']) + }) + + it('preserves sibling specorator keys (SPEC §9.3 coexistence)', async () => { + const { state, repo } = makeHarness({ + specorator: { + locale: 'en', + specsFolder: 'specs', + claudeCliPath: '/usr/local/bin/claude', + transportKind: 'auto', + }, + _moduleVersions: { hello: 1 }, + hello: { showBadge: true }, + }) + await repo.save(new Map([['t1', makeRecord({ threadId: 't1' })]])) + await vi.advanceTimersByTimeAsync(1_000) + const specorator = state.blob?.specorator as Record + expect(specorator.locale).toBe('en') + expect(specorator.specsFolder).toBe('specs') + expect(specorator.claudeCliPath).toBe('/usr/local/bin/claude') + expect(specorator.transportKind).toBe('auto') + expect(state.blob?._moduleVersions).toEqual({ hello: 1 }) + expect(state.blob?.hello).toEqual({ showBadge: true }) + }) + + it('filters degraded-transport records at flush time', async () => { + const { state, repo } = makeHarness({ specorator: {} }) + const persisted = makeRecord({ threadId: 'keep', transport: 'subscription' }) + const degraded = { + ...makeRecord({ threadId: 'drop' }), + transport: 'degraded', + } as unknown as ChatThreadRecord + await repo.save( + new Map([ + ['keep', persisted], + ['drop', degraded], + ]), + ) + await vi.advanceTimersByTimeAsync(1_000) + const specorator = state.blob?.specorator as Record + const chatThreads = specorator.chatThreads as Record + expect(Object.keys(chatThreads)).toEqual(['keep']) + expect(chatThreads.drop).toBeUndefined() + }) + + it('honours a custom debounceMs option', async () => { + const state: HarnessState = { blob: { specorator: {} }, saveCount: 0 } + const host: ObsidianPluginDataHost = { + loadData: vi.fn(async () => state.blob), + saveData: vi.fn(async (d: Record) => { + state.blob = d + state.saveCount += 1 + }), + setActiveTimeout: (cb, ms) => globalThis.setTimeout(cb, ms) as unknown as number, + clearActiveTimeout: (id) => { globalThis.clearTimeout(id) }, + } + const repo = new ObsidianChatThreadsRepository(host, silentLogger(), { debounceMs: 50 }) + await repo.save(new Map([['t1', makeRecord({ threadId: 't1' })]])) + await vi.advanceTimersByTimeAsync(40) + expect(state.saveCount).toBe(0) + await vi.advanceTimersByTimeAsync(20) + expect(state.saveCount).toBe(1) + }) +}) + +describe('ObsidianChatThreadsRepository.flushPending', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('drains a pending snapshot before the debounce timer fires', async () => { + const { state, repo } = makeHarness({ specorator: {} }) + await repo.save(new Map([['just-sent', makeRecord({ threadId: 'just-sent' })]])) + await vi.advanceTimersByTimeAsync(500) + expect(state.blob).toEqual({ specorator: {} }) + await repo.flushPending() + const specorator = state.blob?.specorator as Record + const chatThreads = specorator.chatThreads as Record + expect(Object.keys(chatThreads)).toEqual(['just-sent']) + }) + + it('is a no-op when nothing is pending', async () => { + const { state, repo } = makeHarness({ specorator: { locale: 'en' } }) + await repo.flushPending() + expect(state.saveCount).toBe(0) + }) +}) + +describe('ObsidianChatThreadsRepository — serialised flush queue (Codex P1)', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('queues a newer snapshot behind an in-flight older one', async () => { + const state: HarnessState = { blob: { specorator: {} }, saveCount: 0 } + const writeOrder: string[] = [] + const resolvers: Array<() => void> = [] + const host: ObsidianPluginDataHost = { + loadData: vi.fn(async () => state.blob), + saveData: vi.fn(async (data: Record) => { + const specorator = (data.specorator ?? {}) as Record + const threads = (specorator.chatThreads ?? {}) as Record + const ids = Object.keys(threads).sort().join(',') + writeOrder.push(`start:${ids}`) + await new Promise((resolve) => resolvers.push(resolve)) + writeOrder.push(`finish:${ids}`) + state.blob = data + state.saveCount += 1 + }), + setActiveTimeout: (cb, ms) => globalThis.setTimeout(cb, ms) as unknown as number, + clearActiveTimeout: (id) => { globalThis.clearTimeout(id) }, + } + const repo = new ObsidianChatThreadsRepository(host, silentLogger()) + + await repo.save(new Map([['t1', makeRecord({ threadId: 't1' })]])) + await vi.advanceTimersByTimeAsync(1_000) + // A has started; saveData is parked on its resolver. + expect(writeOrder).toEqual(['start:t1']) + + await repo.save( + new Map([ + ['t1', makeRecord({ threadId: 't1' })], + ['t2', makeRecord({ threadId: 't2', transport: 'api-key', sessionId: null })], + ]), + ) + await vi.advanceTimersByTimeAsync(1_000) + // B is queued behind A — has not started yet. + expect(writeOrder).toEqual(['start:t1']) + + resolvers[0]?.() + await vi.runAllTimersAsync() + await Promise.resolve() + await Promise.resolve() + expect(writeOrder).toContain('finish:t1') + expect(writeOrder.find((s) => s.startsWith('start:t1,t2'))).toBe('start:t1,t2') + + resolvers[1]?.() + await vi.runAllTimersAsync() + await Promise.resolve() + await Promise.resolve() + expect(writeOrder).toEqual([ + 'start:t1', + 'finish:t1', + 'start:t1,t2', + 'finish:t1,t2', + ]) + }) +}) + +/** + * Codex P1, PR #408 — `load()` must return the in-memory pending snapshot + * when a debounced write is still in flight. Otherwise reopening a view + * inside the debounce window rehydrates pre-save threads and the next + * `save()` from the store would persist that stale view, silently dropping + * just-created threads. + */ +describe('ObsidianChatThreadsRepository.load — pending-snapshot precedence (Codex P1)', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('returns the in-flight snapshot while a debounced write is pending', async () => { + const { repo } = makeHarness({ + specorator: { + chatThreads: { + stale: { + threadId: 'stale', sessionId: 'sess-stale', feature: 'foo', + logPath: 'specs/foo/sessions/sess-stale.md', transport: 'subscription', + createdAt: '2026-05-17T08:00:00.000Z', + lastUsedAt: '2026-05-17T08:00:00.000Z', + }, + }, + }, + }) + await repo.save( + new Map([ + ['just-sent', makeRecord({ threadId: 'just-sent' })], + ]), + ) + // Debounce timer has NOT fired yet — disk still has the stale blob. + await vi.advanceTimersByTimeAsync(500) + const loaded = await repo.load() + expect(Array.from(loaded.keys())).toEqual(['just-sent']) + expect(loaded.get('stale')).toBeUndefined() + }) + + it('reverts to disk after the pending snapshot has been flushed', async () => { + const { state, repo } = makeHarness({ specorator: {} }) + await repo.save(new Map([['t1', makeRecord({ threadId: 't1' })]])) + await vi.advanceTimersByTimeAsync(1_000) + // Wait for any queued microtasks chained onto the flush queue. + await Promise.resolve() + await Promise.resolve() + expect(state.saveCount).toBe(1) + const loaded = await repo.load() + expect(Array.from(loaded.keys())).toEqual(['t1']) + }) + + it('returns a defensive copy so mutating the result does not corrupt the pending snapshot', async () => { + const { repo } = makeHarness({ specorator: {} }) + await repo.save(new Map([['t1', makeRecord({ threadId: 't1' })]])) + await vi.advanceTimersByTimeAsync(500) + const loaded = await repo.load() + expect(loaded.size).toBe(1) + // Caller-side delete must not leak into the pending snapshot. + ;(loaded as Map).delete('t1') + const reloaded = await repo.load() + expect(reloaded.size).toBe(1) + expect(reloaded.has('t1')).toBe(true) + }) +}) + +/** + * Codex P1, PR #408 — after each successful disk write the adapter must + * notify the host plugin so its `_storedData` cache mirrors the new + * `chatThreads` blob. Without this, a later `updateSettings(...)` call + * that persists from the cache would silently re-emit the stale pre-chat + * snapshot, destroying recent threads. + */ +describe('ObsidianChatThreadsRepository — onChatThreadsPersisted hook (Codex P1)', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('reproduces the data-loss path when the host cache is NOT synced', async () => { + // Simulates the broken wiring: host cache is never updated, so a later + // settings-style write re-emits the stale snapshot and clobbers + // chat-threads on disk. The assertion below is what fails on `develop` + // before this PR and what the synced cache (next test) prevents. + const state: HarnessState = { blob: { specorator: { chatThreads: {} } }, saveCount: 0 } + const hostCache: { stored: Record } = { stored: { specorator: { chatThreads: {} } } } + const host: ObsidianPluginDataHost = { + loadData: vi.fn(async () => state.blob), + saveData: vi.fn(async (data: Record) => { + state.blob = data + state.saveCount += 1 + }), + setActiveTimeout: (cb, ms) => globalThis.setTimeout(cb, ms) as unknown as number, + clearActiveTimeout: (id) => { globalThis.clearTimeout(id) }, + } + // Note: no onChatThreadsPersisted hook configured. + const repo = new ObsidianChatThreadsRepository(host, silentLogger()) + await repo.save(new Map([['t1', makeRecord({ threadId: 't1' })]])) + await vi.advanceTimersByTimeAsync(1_000) + // Disk now has the chat thread. + const specorator = state.blob?.specorator as Record + expect(specorator.chatThreads).toHaveProperty('t1') + // The host's in-memory cache still has the empty chatThreads — a later + // settings save would write THAT back and destroy the thread. + expect((hostCache.stored.specorator as Record).chatThreads).toEqual({}) + }) + + it('mirrors the encoded chatThreads into the host cache after a successful flush', async () => { + const state: HarnessState = { blob: { specorator: { locale: 'en' } }, saveCount: 0 } + const hostCache: { stored: Record } = { + stored: { specorator: { locale: 'en' } }, + } + const host: ObsidianPluginDataHost = { + loadData: vi.fn(async () => state.blob), + saveData: vi.fn(async (data: Record) => { + state.blob = data + state.saveCount += 1 + }), + setActiveTimeout: (cb, ms) => globalThis.setTimeout(cb, ms) as unknown as number, + clearActiveTimeout: (id) => { globalThis.clearTimeout(id) }, + } + const repo = new ObsidianChatThreadsRepository(host, silentLogger(), { + onChatThreadsPersisted: (chatThreads) => { + const currentSpec = (hostCache.stored.specorator ?? {}) as Record + hostCache.stored = { + ...hostCache.stored, + specorator: { ...currentSpec, chatThreads }, + } + }, + }) + await repo.save(new Map([['t1', makeRecord({ threadId: 't1' })]])) + await vi.advanceTimersByTimeAsync(1_000) + await Promise.resolve() + await Promise.resolve() + const mirrored = (hostCache.stored.specorator as Record).chatThreads as Record + expect(mirrored).toHaveProperty('t1') + // Sibling keys preserved. + expect((hostCache.stored.specorator as Record).locale).toBe('en') + }) + + it('does NOT invoke the hook when saveData rejects (cache must stay clean)', async () => { + const state: HarnessState = { blob: { specorator: {} }, saveCount: 0 } + const hookSeen: Array> = [] + const host: ObsidianPluginDataHost = { + loadData: vi.fn(async () => state.blob), + saveData: vi.fn(async () => { + throw new Error('disk full') + }), + setActiveTimeout: (cb, ms) => globalThis.setTimeout(cb, ms) as unknown as number, + clearActiveTimeout: (id) => { globalThis.clearTimeout(id) }, + } + const repo = new ObsidianChatThreadsRepository(host, silentLogger(), { + onChatThreadsPersisted: (chatThreads) => { hookSeen.push(chatThreads) }, + }) + // Use flushPending() to obtain a handle on the queue tail so we can + // await its rejection deterministically without an unhandled-rejection + // event escaping the runner. The .catch swallow on the returned + // promise mirrors how `Plugin.onunload()` fires-and-forgets the flush. + await repo.save(new Map([['t1', makeRecord({ threadId: 't1' })]])) + await repo.flushPending().catch(() => undefined) + expect(hookSeen).toEqual([]) + // Disk write was attempted (and rejected) — saveData fires exactly once. + expect((host.saveData as ReturnType).mock.calls.length).toBe(1) + }) + + it('end-to-end: pending-snapshot precedence + cache sync close the data-loss race', async () => { + // Reproduces the full sequence from the Codex finding: + // 1. user sends a chat message → repo.save() debounces + // 2. user reopens the view inside the debounce → repo.load() must + // return the in-flight snapshot, NOT the stale disk copy + // 3. debounce fires → disk + host cache both updated + // 4. later updateSettings persists from host cache → must include + // the latest chatThreads + const state: HarnessState = { blob: { specorator: {} }, saveCount: 0 } + let storedDataCache: Record = {} + const host: ObsidianPluginDataHost = { + loadData: vi.fn(async () => state.blob), + saveData: vi.fn(async (data: Record) => { + state.blob = data + state.saveCount += 1 + }), + setActiveTimeout: (cb, ms) => globalThis.setTimeout(cb, ms) as unknown as number, + clearActiveTimeout: (id) => { globalThis.clearTimeout(id) }, + } + const repo = new ObsidianChatThreadsRepository(host, silentLogger(), { + onChatThreadsPersisted: (chatThreads) => { + const currentSpec = (storedDataCache.specorator ?? {}) as Record + storedDataCache = { + ...storedDataCache, + specorator: { ...currentSpec, chatThreads }, + } + }, + }) + + // 1. save the new thread (debounce starts) + await repo.save(new Map([['t1', makeRecord({ threadId: 't1' })]])) + // 2. view reopens inside debounce — load must return in-flight snapshot + const midWindow = await repo.load() + expect(midWindow.has('t1')).toBe(true) + // 3. debounce fires → host cache mirrored + await vi.advanceTimersByTimeAsync(1_000) + await Promise.resolve() + await Promise.resolve() + const mirroredThreads = (storedDataCache.specorator as Record).chatThreads as Record + expect(mirroredThreads).toHaveProperty('t1') + // 4. simulate updateSettings persisting from cache — chatThreads survives + expect(mirroredThreads.t1).toBeDefined() + }) +}) + +/** + * Codex P1 round-2, PR #408 — symmetric race to `onChatThreadsPersisted`. + * Without a shared read-source, `_flushChatThreads` reads from disk via + * `await host.loadData()` and merges `chatThreads` into a snapshot that + * may already be stale relative to an in-flight `updateSettings` / + * `updateModuleSettings` write — the chat-threads flush then writes the + * stale settings blob back, silently rolling back the just-made settings + * change. The fix is a read-through closure that returns the host's live + * `_storedData` so both writers share one source of truth. + */ +describe('ObsidianChatThreadsRepository — readHostData hook (Codex P1 round-2)', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('reads sibling keys from readHostData(), preserving in-flight settings writes', async () => { + // Simulate the race: disk still holds the pre-settings blob (an + // updateSettings call has mutated `_storedData` but its saveData has + // not yet landed). The chat-threads flush MUST see the new setting + // via the read-through closure, not the stale disk blob. + const storedData: { current: Record } = { + current: { specorator: { someSetting: 'old-value', chatThreads: {} } }, + } + const diskBlob: { current: Record | null } = { + // Disk is intentionally stale: pre-settings-write snapshot. + current: { specorator: { someSetting: 'old-value', chatThreads: {} } }, + } + const host: ObsidianPluginDataHost = { + loadData: vi.fn(async () => diskBlob.current), + saveData: vi.fn(async (data: Record) => { + diskBlob.current = data + }), + setActiveTimeout: (cb, ms) => globalThis.setTimeout(cb, ms) as unknown as number, + clearActiveTimeout: (id) => { globalThis.clearTimeout(id) }, + } + const repo = new ObsidianChatThreadsRepository(host, silentLogger(), { + readHostData: () => storedData.current, + onChatThreadsPersisted: (chatThreads) => { + const currentSpec = (storedData.current.specorator ?? {}) as Record + storedData.current = { + ...storedData.current, + specorator: { ...currentSpec, chatThreads }, + } + }, + }) + + // 1. user mutates a setting directly on the host cache (simulating + // `updateSettings({ someSetting: 'new-value' })` mid-flight — the + // cache is mutated *before* saveData resolves). + const specBefore = storedData.current.specorator as Record + storedData.current = { + ...storedData.current, + specorator: { ...specBefore, someSetting: 'new-value' }, + } + + // 2. chat-threads flush fires. + await repo.save(new Map([['t1', makeRecord({ threadId: 't1' })]])) + await vi.advanceTimersByTimeAsync(1_000) + await Promise.resolve() + await Promise.resolve() + + // 3. the disk write must preserve the NEW setting, not the stale one. + const flushedSpecorator = (diskBlob.current!).specorator as Record + expect(flushedSpecorator.someSetting).toBe('new-value') + // ...and chatThreads must also land. + expect(flushedSpecorator.chatThreads).toHaveProperty('t1') + // 4. loadData was NEVER consulted at flush time — the closure short-circuits it. + expect((host.loadData as ReturnType).mock.calls.length).toBe(0) + }) + + it('falls back to host.loadData() when no readHostData closure is provided', async () => { + // Regression guard for the no-closure path (existing bare-host tests). + const state: HarnessState = { + blob: { specorator: { someSetting: 'from-disk', chatThreads: {} } }, + saveCount: 0, + } + const host: ObsidianPluginDataHost = { + loadData: vi.fn(async () => state.blob), + saveData: vi.fn(async (data: Record) => { + state.blob = data + state.saveCount += 1 + }), + setActiveTimeout: (cb, ms) => globalThis.setTimeout(cb, ms) as unknown as number, + clearActiveTimeout: (id) => { globalThis.clearTimeout(id) }, + } + const repo = new ObsidianChatThreadsRepository(host, silentLogger()) + await repo.save(new Map([['t1', makeRecord({ threadId: 't1' })]])) + await vi.advanceTimersByTimeAsync(1_000) + await Promise.resolve() + await Promise.resolve() + // loadData WAS used (fallback branch). + expect((host.loadData as ReturnType).mock.calls.length).toBeGreaterThanOrEqual(1) + const specorator = state.blob?.specorator as Record + expect(specorator.someSetting).toBe('from-disk') + expect(specorator.chatThreads).toHaveProperty('t1') + }) + + it('falls back to host.loadData() when readHostData() returns null/undefined/non-object', async () => { + // Defensive: a host that hasn't hydrated its cache yet returns null — + // the adapter must NOT call `.specorator` on null and crash. + const state: HarnessState = { + blob: { specorator: { someSetting: 'disk', chatThreads: {} } }, + saveCount: 0, + } + const host: ObsidianPluginDataHost = { + loadData: vi.fn(async () => state.blob), + saveData: vi.fn(async (data: Record) => { + state.blob = data + state.saveCount += 1 + }), + setActiveTimeout: (cb, ms) => globalThis.setTimeout(cb, ms) as unknown as number, + clearActiveTimeout: (id) => { globalThis.clearTimeout(id) }, + } + const repo = new ObsidianChatThreadsRepository(host, silentLogger(), { + readHostData: () => null, + }) + await repo.save(new Map([['t1', makeRecord({ threadId: 't1' })]])) + await vi.advanceTimersByTimeAsync(1_000) + await Promise.resolve() + await Promise.resolve() + const specorator = state.blob?.specorator as Record + // Falls back to disk → sibling key 'someSetting' from disk is preserved. + expect(specorator.someSetting).toBe('disk') + expect(specorator.chatThreads).toHaveProperty('t1') + }) +}) + +/** + * Codex P1 round-3, PR #408 — close the gap between the debounce firing + * and the queued flush actually writing to disk. Previously `save()` + * cleared `_pendingSnapshot` as soon as the debounce timer fired, even + * though the queued `_flushChatThreads(snapshot)` had not yet won the + * `_flushQueue` and committed to disk. During that window: + * + * 1. `save(A)` schedules a debounce, sets `_pendingSnapshot = A`. + * 2. Debounce fires → `_pendingSnapshot = null` (bug), flush(A) enqueued. + * 3. An older flush is still in `_flushQueue`, so flush(A) waits. + * 4. `load()` is called → `_pendingSnapshot` is null → falls through to + * disk, which is still pre-A. The view rehydrates the stale state. + * + * The fix: hold `_pendingSnapshot` until the queued flush has actually + * resolved. Use identity equality (same Map reference) when clearing so a + * newer `save()` mid-flight is not erroneously cleared by an older flush's + * completion. + */ +describe('ObsidianChatThreadsRepository — pending snapshot held until queued flush completes (Codex P1 round-3)', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + }) + + it('load() returns the pending snapshot while the queued flush is still in flight', async () => { + // Set up a host whose saveData parks on a resolver the test controls + // so we can interrogate `load()` while the queued flush is mid-air. + const diskBlob: { current: Record | null } = { + current: { + specorator: { + chatThreads: { + stale: { + threadId: 'stale', sessionId: 'sess-stale', feature: 'foo', + logPath: 'specs/foo/sessions/sess-stale.md', transport: 'subscription', + createdAt: '2026-05-17T08:00:00.000Z', + lastUsedAt: '2026-05-17T08:00:00.000Z', + }, + }, + }, + }, + } + const saveResolvers: Array<() => void> = [] + const host: ObsidianPluginDataHost = { + loadData: vi.fn(async () => diskBlob.current), + saveData: vi.fn(async (data: Record) => { + await new Promise((resolve) => saveResolvers.push(resolve)) + diskBlob.current = data + }), + setActiveTimeout: (cb, ms) => globalThis.setTimeout(cb, ms) as unknown as number, + clearActiveTimeout: (id) => { globalThis.clearTimeout(id) }, + } + const repo = new ObsidianChatThreadsRepository(host, silentLogger()) + + // 1. save(A) — debounce starts. + await repo.save(new Map([['just-sent', makeRecord({ threadId: 'just-sent' })]])) + // 2. advance past debounce → timer fires, flush(A) enqueued, flush(A) + // awaits the parked saveData. + await vi.advanceTimersByTimeAsync(1_000) + await Promise.resolve() + await Promise.resolve() + // saveData has been invoked (i.e. queue entered the flush) but not resolved. + expect((host.saveData as ReturnType).mock.calls.length).toBe(1) + // Disk is still pre-A (saveData parked). + const onDisk = diskBlob.current! + expect(((onDisk.specorator as Record).chatThreads as Record)) + .toHaveProperty('stale') + + // 3. load() while the queued flush is in flight MUST return the + // in-memory snapshot, not the stale disk copy. + const loaded = await repo.load() + expect(Array.from(loaded.keys())).toEqual(['just-sent']) + expect(loaded.has('stale')).toBe(false) + + // 4. Resolve the parked save → flush completes → pending snapshot + // clears via identity check → load() now reads from disk (which has A). + saveResolvers[0]?.() + await vi.runAllTimersAsync() + await Promise.resolve() + await Promise.resolve() + const reloaded = await repo.load() + expect(Array.from(reloaded.keys())).toEqual(['just-sent']) + }) + + it('a newer save() during an in-flight older flush is not cleared by the older flush completing', async () => { + // Identity-equality guard: when flush(A) finishes, it must NOT clear + // `_pendingSnapshot` if a newer save(B) has already replaced it. + const diskBlob: { current: Record | null } = { + current: { specorator: {} }, + } + const saveResolvers: Array<() => void> = [] + const host: ObsidianPluginDataHost = { + loadData: vi.fn(async () => diskBlob.current), + saveData: vi.fn(async (data: Record) => { + await new Promise((resolve) => saveResolvers.push(resolve)) + diskBlob.current = data + }), + setActiveTimeout: (cb, ms) => globalThis.setTimeout(cb, ms) as unknown as number, + clearActiveTimeout: (id) => { globalThis.clearTimeout(id) }, + } + const repo = new ObsidianChatThreadsRepository(host, silentLogger()) + + // save(A) → debounce → flush(A) enqueued and parked. + await repo.save(new Map([['a', makeRecord({ threadId: 'a' })]])) + await vi.advanceTimersByTimeAsync(1_000) + await Promise.resolve() + await Promise.resolve() + expect((host.saveData as ReturnType).mock.calls.length).toBe(1) + + // save(B) replaces _pendingSnapshot while flush(A) is parked. + await repo.save(new Map([['b', makeRecord({ threadId: 'b' })]])) + // load() now must surface B, not A (B is the live in-memory state). + const midFlight = await repo.load() + expect(Array.from(midFlight.keys())).toEqual(['b']) + + // Resolve flush(A). Its identity check sees `_pendingSnapshot` is no + // longer A's Map reference, so it does NOT clear it. B remains pending. + saveResolvers[0]?.() + await Promise.resolve() + await Promise.resolve() + const afterAFinishes = await repo.load() + expect(Array.from(afterAFinishes.keys())).toEqual(['b']) + + // Advance to fire B's debounce → flush(B) enqueued and parked. + await vi.advanceTimersByTimeAsync(1_000) + await Promise.resolve() + await Promise.resolve() + expect((host.saveData as ReturnType).mock.calls.length).toBe(2) + // Resolve flush(B) → identity match → _pendingSnapshot cleared. + saveResolvers[1]?.() + await vi.runAllTimersAsync() + await Promise.resolve() + await Promise.resolve() + // Disk now has B; load() falls through to disk and still returns B. + const finalLoad = await repo.load() + expect(Array.from(finalLoad.keys())).toEqual(['b']) + }) + + it('flushPending() drains both the in-flight flush and the next queued flush', async () => { + // Composition with flushPending(): if flushPending() is called while a + // queued flush is in flight AND `_pendingSnapshot` is non-null (because + // a newer save replaced it mid-flight), the returned promise must + // resolve only after BOTH flushes have committed. + const diskBlob: { current: Record | null } = { + current: { specorator: {} }, + } + const saveResolvers: Array<() => void> = [] + const host: ObsidianPluginDataHost = { + loadData: vi.fn(async () => diskBlob.current), + saveData: vi.fn(async (data: Record) => { + await new Promise((resolve) => saveResolvers.push(resolve)) + diskBlob.current = data + }), + setActiveTimeout: (cb, ms) => globalThis.setTimeout(cb, ms) as unknown as number, + clearActiveTimeout: (id) => { globalThis.clearTimeout(id) }, + } + const repo = new ObsidianChatThreadsRepository(host, silentLogger()) + + await repo.save(new Map([['a', makeRecord({ threadId: 'a' })]])) + await vi.advanceTimersByTimeAsync(1_000) + await Promise.resolve() + await Promise.resolve() + // flush(A) is parked. save(B) replaces pending snapshot. + await repo.save(new Map([['b', makeRecord({ threadId: 'b' })]])) + + // flushPending() should enqueue flush(B) and return a promise that + // resolves only after both flushes complete. + const drained = repo.flushPending() + let drainedSettled = false + void drained.then(() => { drainedSettled = true }) + + // Resolve flush(A) — drained should still be pending (flush(B) queued). + saveResolvers[0]?.() + // Several microtask flushes: flush(A) finishes → identity check + // (B !== A → no clear) → queue then-handler resolves → flush(B) + // starts → saveData(B) is invoked → parks on its own resolver. + for (let i = 0; i < 10; i += 1) await Promise.resolve() + expect(drainedSettled).toBe(false) + expect((host.saveData as ReturnType).mock.calls.length).toBe(2) + + // Resolve flush(B) — drained settles. + saveResolvers[1]?.() + for (let i = 0; i < 10; i += 1) await Promise.resolve() + await drained + expect(drainedSettled).toBe(true) + // Disk now has B (the latest snapshot). + const onDisk = diskBlob.current! + expect(((onDisk.specorator as Record).chatThreads as Record)) + .toHaveProperty('b') + }) + + it('does NOT clear _pendingSnapshot when the queued flush rejects', async () => { + // Rejection guard: a failed saveData must NOT swallow the pending + // snapshot. The next save() / flushPending() must still see it and + // can retry the write. We drive both flushes through `flushPending()` + // so the test owns a promise handle for each — this mirrors how the + // existing onChatThreadsPersisted-rejection test composes deterministic + // rejection handling without leaking unhandled-rejection events. + const diskBlob: { current: Record | null } = { + current: { specorator: {} }, + } + let failNext = true + const host: ObsidianPluginDataHost = { + loadData: vi.fn(async () => diskBlob.current), + saveData: vi.fn(async (data: Record) => { + if (failNext) { + failNext = false + throw new Error('disk full') + } + diskBlob.current = data + }), + setActiveTimeout: (cb, ms) => globalThis.setTimeout(cb, ms) as unknown as number, + clearActiveTimeout: (id) => { globalThis.clearTimeout(id) }, + } + const repo = new ObsidianChatThreadsRepository(host, silentLogger()) + + await repo.save(new Map([['t1', makeRecord({ threadId: 't1' })]])) + // Force the first flush via flushPending() so we own the rejection + // (no debounce-fire path that leaks an unhandled rejection). + await repo.flushPending().catch(() => undefined) + + // After the rejection, load() must STILL return the pending snapshot — + // the in-memory state is still authoritative until a successful flush. + const afterReject = await repo.load() + expect(Array.from(afterReject.keys())).toEqual(['t1']) + + // flushPending() retries; this time saveData succeeds → snapshot clears. + await repo.flushPending() + for (let i = 0; i < 5; i += 1) await Promise.resolve() + const onDisk = diskBlob.current! + expect(((onDisk.specorator as Record).chatThreads as Record)) + .toHaveProperty('t1') + }) +}) diff --git a/tests/plugin/main.chat-threads-flush.test.ts b/tests/plugin/main.chat-threads-flush.test.ts deleted file mode 100644 index 08d8052a..00000000 --- a/tests/plugin/main.chat-threads-flush.test.ts +++ /dev/null @@ -1,489 +0,0 @@ -/** - * T-ASM-053 / T-ASM-054 — Persistence-flush tests for the chatThreads blob. - * - * These tests exercise the debounced save path on the plugin directly, using - * fake timers and a minimal plugin shim so we can assert the on-disk blob - * shape without booting the Obsidian runtime. - * - * Covers: - * - Persisted writes land under `_storedData.specorator.chatThreads` (SPEC §9.3). - * - The flush is debounced — rapid mutations coalesce into one `saveData` call. - * - The flush filters out degraded-transport threads (SPEC §2.2, ADR-0031). - * - The flush preserves sibling `specorator.*` keys (settings coexistence). - * - * The plugin shim is constructed by extending the real `SpecoratorPlugin` - * class with stubs for `loadData` / `saveData` and an `activeWindow` shim; - * we avoid the full Obsidian boot path that requires a real `App`. - * - * Satisfies REQ-ASM-037. - */ -import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest' - -// `SpecoratorPlugin` (and its `SpecoratorView` import) extends Obsidian's -// `Plugin` / `ItemView`. The default vitest stub does not export those, so -// we install minimal shims for this file only. -vi.mock('obsidian', async () => { - const actual = await vi.importActual>('obsidian') - return { - ...actual, - Platform: { isMobile: false }, - Plugin: class { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - constructor(public app: any, public manifest: any) {} - register(_cb: () => void): void { /* no-op */ } - addRibbonIcon(): unknown { return null } - addCommand(): unknown { return null } - addSettingTab(): unknown { return null } - registerView(): unknown { return null } - registerEvent(): unknown { return null } - registerObsidianProtocolHandler(): unknown { return null } - async loadData(): Promise { return null } - async saveData(_data: unknown): Promise { /* no-op */ } - }, - ItemView: class { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - constructor(public leaf: any) {} - }, - PluginSettingTab: class { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - constructor(public app: any, public plugin: any) {} - }, - Setting: class { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - constructor(public containerEl: any) {} - setName(): this { return this } - setDesc(): this { return this } - setHeading(): this { return this } - addText(): this { return this } - addToggle(): this { return this } - addDropdown(): this { return this } - addButton(): this { return this } - addExtraButton(): this { return this } - }, - Modal: class { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - constructor(public app: any) {} - open(): void { /* no-op */ } - close(): void { /* no-op */ } - }, - } -}) - -import { asSessionId } from '@/domain/chat/SessionId' -import type { ChatThreadRecord } from '@/domain/chat/ChatThreadRecord' -import SpecoratorPlugin from '@/plugin/main' - -interface SavedState { - blob: Record | null - saveCount: number -} - -function makePlugin(initialData: Record): { - plugin: SpecoratorPlugin - state: SavedState -} { - const state: SavedState = { blob: null, saveCount: 0 } - - // Build a minimal stand-in. We don't instantiate via `new SpecoratorPlugin(app, manifest)` - // because the Obsidian `Plugin` constructor expects a fully-wired App. Instead we - // construct a bare object whose prototype is `SpecoratorPlugin.prototype` and - // install the two private fields the persistence path touches. - const plugin = Object.create(SpecoratorPlugin.prototype) as Record - plugin._storedData = { ...initialData } - plugin._initialChatThreads = [] - plugin._chatThreadsFlushTimer = null - plugin._pendingChatThreadsSnapshot = null - plugin._chatThreadsFlushQueue = Promise.resolve() - plugin.loadData = vi.fn(async () => initialData) - plugin.saveData = vi.fn(async (data: Record) => { - state.blob = data - state.saveCount += 1 - }) - // Stub the minimum App surface that `onunload()` reaches into so the - // unload-flush regression tests don't NPE on `app.workspace`. The - // detachLeavesOfType call is a no-op for these tests — we're only - // exercising the chatThreads flush path. - plugin.app = { workspace: { detachLeavesOfType: vi.fn() } } - return { plugin: plugin as unknown as SpecoratorPlugin, state } -} - -function makeRecord(overrides: Partial = {}): ChatThreadRecord { - return { - threadId: 'thread-1', - sessionId: asSessionId('sess-1'), - feature: 'foo', - logPath: 'specs/foo/sessions/sess-1.md', - transport: 'subscription', - createdAt: '2026-05-14T10:00:00.000Z', - lastUsedAt: '2026-05-14T10:00:00.000Z', - ...overrides, - } -} - -// Obsidian exposes `activeWindow` as a global pointing at the active popout -// window. Under vitest the global is absent, so install a shim that routes -// through the test environment's `setTimeout` / `clearTimeout`. Using a typed -// helper keeps the obsidianmd `prefer-active-doc` rule satisfied for the -// surrounding source while still allowing the shim itself to exist. -/* eslint-disable obsidianmd/prefer-active-doc */ -interface ActiveWindowShim { - setTimeout: (cb: () => void, ms: number) => number - clearTimeout: (id: number) => void -} -function installActiveWindowShim(): ActiveWindowShim | undefined { - const target = globalThis as unknown as { activeWindow?: ActiveWindowShim } - const prior = target.activeWindow - target.activeWindow = { - setTimeout: (cb, ms) => globalThis.setTimeout(cb, ms) as unknown as number, - clearTimeout: (id) => { globalThis.clearTimeout(id) }, - } - return prior -} -function restoreActiveWindow(prior: ActiveWindowShim | undefined): void { - const target = globalThis as unknown as { activeWindow?: ActiveWindowShim } - if (prior === undefined) delete target.activeWindow - else target.activeWindow = prior -} -/* eslint-enable obsidianmd/prefer-active-doc */ - -describe('scheduleChatThreadsPersistence — debounced flush (T-ASM-054)', () => { - let activeWindowRestore: ActiveWindowShim | undefined - beforeEach(() => { - vi.useFakeTimers() - activeWindowRestore = installActiveWindowShim() - }) - afterEach(() => { - vi.useRealTimers() - restoreActiveWindow(activeWindowRestore) - }) - - it('writes a single blob after the 1 s debounce window expires', async () => { - const { plugin, state } = makePlugin({ - specorator: { locale: 'en', specsFolder: 'specs' }, - }) - const map = new Map([['t1', makeRecord({ threadId: 't1' })]]) - - plugin.scheduleChatThreadsPersistence(map) - expect(state.saveCount).toBe(0) // not flushed yet — still inside debounce - - await vi.advanceTimersByTimeAsync(1_000) - - expect(state.saveCount).toBe(1) - const specorator = state.blob?.specorator as Record - expect(specorator.chatThreads).toEqual({ - t1: { - threadId: 't1', sessionId: 'sess-1', feature: 'foo', - logPath: 'specs/foo/sessions/sess-1.md', transport: 'subscription', - createdAt: '2026-05-14T10:00:00.000Z', lastUsedAt: '2026-05-14T10:00:00.000Z', - }, - }) - }) - - it('coalesces rapid mutations into one flush (prevents disk thrashing)', async () => { - const { plugin, state } = makePlugin({ - specorator: { locale: 'en', specsFolder: 'specs' }, - }) - plugin.scheduleChatThreadsPersistence(new Map([['t1', makeRecord({ threadId: 't1' })]])) - plugin.scheduleChatThreadsPersistence(new Map([['t1', makeRecord({ threadId: 't1' })]])) - plugin.scheduleChatThreadsPersistence(new Map([ - ['t1', makeRecord({ threadId: 't1' })], - ['t2', makeRecord({ threadId: 't2', sessionId: asSessionId('s2'), transport: 'api-key' })], - ])) - - await vi.advanceTimersByTimeAsync(1_000) - - expect(state.saveCount).toBe(1) - const specorator = state.blob?.specorator as Record - const chatThreads = specorator.chatThreads as Record - expect(Object.keys(chatThreads).sort()).toEqual(['t1', 't2']) - }) - - it('preserves sibling specorator keys (PluginSettings coexistence, SPEC §9.3)', async () => { - const { plugin, state } = makePlugin({ - specorator: { - locale: 'en', - specsFolder: 'specs', - claudeCliPath: '/usr/local/bin/claude', - transportKind: 'auto', - }, - _moduleVersions: { hello: 1 }, - hello: { showBadge: true }, - }) - plugin.scheduleChatThreadsPersistence(new Map([['t1', makeRecord({ threadId: 't1' })]])) - await vi.advanceTimersByTimeAsync(1_000) - - const specorator = state.blob?.specorator as Record - expect(specorator.locale).toBe('en') - expect(specorator.specsFolder).toBe('specs') - expect(specorator.claudeCliPath).toBe('/usr/local/bin/claude') - expect(specorator.transportKind).toBe('auto') - // Sibling top-level keys outside `specorator` survive too. - expect(state.blob?._moduleVersions).toEqual({ hello: 1 }) - expect(state.blob?.hello).toEqual({ showBadge: true }) - }) - - it('filters degraded-transport records at flush time (NOT persisted)', async () => { - const { plugin, state } = makePlugin({ specorator: {} }) - const persisted = makeRecord({ threadId: 'keep', transport: 'subscription' }) - // Simulate an in-memory degraded thread that the store currently holds. - const degraded = { - ...makeRecord({ threadId: 'drop' }), - transport: 'degraded', - } as unknown as ChatThreadRecord - - plugin.scheduleChatThreadsPersistence( - new Map([ - ['keep', persisted], - ['drop', degraded], - ]), - ) - await vi.advanceTimersByTimeAsync(1_000) - - const specorator = state.blob?.specorator as Record - const chatThreads = specorator.chatThreads as Record - expect(Object.keys(chatThreads)).toEqual(['keep']) - expect(chatThreads.drop).toBeUndefined() - }) - - it('flushes the pending snapshot in onunload() before timer fires (Codex P1 regression)', async () => { - // Codex P1, PR #346: a message sent within the 1 s debounce window - // must be persisted if Obsidian exits or the plugin is disabled before - // the timer fires. Prior to the fix, onunload() cleared the timer but - // never flushed the pending snapshot — the just-sent thread was lost. - const { plugin, state } = makePlugin({ specorator: {} }) - const recent = makeRecord({ threadId: 'just-sent', transport: 'subscription' }) - - plugin.scheduleChatThreadsPersistence( - new Map([['just-sent', recent]]), - ) - - // Advance time only partially — the debounce hasn't fired yet. - await vi.advanceTimersByTimeAsync(500) - expect(state.blob).toBeNull() - - // User closes Obsidian. The fix flushes the pending snapshot directly. - plugin.onunload() - // _flushChatThreads is async (Obsidian's onunload contract is fire- - // and-forget, so we use void in the impl) — flush microtasks to - // observe the write. - await Promise.resolve() - await Promise.resolve() - - const specorator = state.blob?.specorator as Record - const chatThreads = specorator.chatThreads as Record - expect(Object.keys(chatThreads)).toEqual(['just-sent']) - }) - - it('is a no-op in onunload() when no flush is pending', async () => { - const { plugin, state } = makePlugin({ specorator: {} }) - - // No scheduleChatThreadsPersistence call — no pending snapshot. - plugin.onunload() - await Promise.resolve() - - // saveData was never called; the blob is whatever was already on disk. - expect(state.blob).toBeNull() - }) - - it('refreshes getInitialChatThreads() in-session so a panel reopen sees the latest threads (Codex P1, PR #350)', () => { - // No fake timers needed — we only verify the synchronous side-effect of - // `scheduleChatThreadsPersistence` updating the in-memory snapshot. - const { plugin } = makePlugin({ - specorator: { locale: 'en' }, - }) - // Initial state: empty. - expect(plugin.getInitialChatThreads()).toEqual([]) - - // User mutates the in-memory map; persistence is scheduled. - const map = new Map([ - ['t1', makeRecord({ threadId: 't1' })], - ['t2', makeRecord({ threadId: 't2', sessionId: asSessionId('s2'), transport: 'api-key' })], - ]) - plugin.scheduleChatThreadsPersistence(map) - - // Without leaving the session: a panel reopen would call - // `getInitialChatThreads()` again. It must reflect the LATEST scheduled - // state, not the empty load-time snapshot — otherwise rehydrate would - // restore stale state and the next mutation would persist the stale map - // back to disk, losing newer threads. - const refreshed = plugin.getInitialChatThreads() - expect(refreshed.map((r) => r.threadId).sort()).toEqual(['t1', 't2']) - }) - - it('serialises flushes so a slow earlier saveData cannot overwrite a newer one (Codex P1, PR #350)', async () => { - // Build a plugin with a saveData that records the order it was INVOKED in, - // and resolves on demand. Older flushes must complete (resolve) before - // newer ones start. - const state: SavedState = { blob: null, saveCount: 0 } - const plugin = Object.create(SpecoratorPlugin.prototype) as Record - plugin._storedData = { specorator: { locale: 'en' } } - plugin._initialChatThreads = [] - plugin._chatThreadsFlushTimer = null - plugin._pendingChatThreadsSnapshot = null - plugin._chatThreadsFlushQueue = Promise.resolve() - plugin.app = { workspace: { detachLeavesOfType: vi.fn() } } - plugin.loadData = vi.fn(async () => ({})) - - const writeOrder: string[] = [] - const pendingResolvers: Array<() => void> = [] - plugin.saveData = vi.fn(async (data: Record) => { - const specorator = (data.specorator ?? {}) as Record - const threads = (specorator.chatThreads ?? {}) as Record - const ids = Object.keys(threads).sort().join(',') - writeOrder.push(`start:${ids}`) - await new Promise((resolve) => pendingResolvers.push(resolve)) - writeOrder.push(`finish:${ids}`) - state.blob = data - state.saveCount += 1 - }) - - const view = plugin as unknown as SpecoratorPlugin - - // Schedule snapshot A, fire its debounce, then schedule snapshot B and - // fire its debounce. Both saveData calls are awaiting their resolvers. - view.scheduleChatThreadsPersistence(new Map([['t1', makeRecord({ threadId: 't1' })]])) - await vi.advanceTimersByTimeAsync(1_000) - // A has started; its saveData is parked. - expect(writeOrder).toEqual(['start:t1']) - - view.scheduleChatThreadsPersistence( - new Map([ - ['t1', makeRecord({ threadId: 't1' })], - ['t2', makeRecord({ threadId: 't2', sessionId: asSessionId('s2'), transport: 'api-key' })], - ]), - ) - await vi.advanceTimersByTimeAsync(1_000) - // Critical: B has NOT started yet — it is queued behind A. The tail- - // chain forces B to wait for A's resolution before its saveData runs. - expect(writeOrder).toEqual(['start:t1']) - - // Resolve A. - pendingResolvers[0]?.() - await vi.runAllTimersAsync() - await Promise.resolve() - await Promise.resolve() - // Now B has started. - expect(writeOrder).toContain('finish:t1') - expect(writeOrder.find((s) => s.startsWith('start:t1,t2'))).toBe('start:t1,t2') - - // Resolve B. - pendingResolvers[1]?.() - await vi.runAllTimersAsync() - await Promise.resolve() - await Promise.resolve() - - // Final order: A start → A finish → B start → B finish. Last-write-wins - // honoured: the persisted blob is B (the newer snapshot), not A. - expect(writeOrder).toEqual([ - 'start:t1', - 'finish:t1', - 'start:t1,t2', - 'finish:t1,t2', - ]) - const specorator = state.blob?.specorator as Record - const threads = specorator.chatThreads as Record - expect(Object.keys(threads).sort()).toEqual(['t1', 't2']) - }) -}) - -describe('getInitialChatThreads — read path (T-ASM-053)', () => { - it('returns the records hydrated by loadSettings()', async () => { - const blob = { - specorator: { - locale: 'en', - chatThreads: { - 't1': { - threadId: 't1', sessionId: 's1', feature: 'foo', - logPath: 'specs/foo/sessions/s1.md', transport: 'subscription', - createdAt: '2026-05-14T08:00:00.000Z', lastUsedAt: '2026-05-14T09:00:00.000Z', - }, - }, - }, - } - const { plugin } = makePlugin(blob) - await plugin.loadSettings() - - const records = plugin.getInitialChatThreads() - expect(records).toHaveLength(1) - expect(records[0].threadId).toBe('t1') - expect(records[0].sessionId).toBe('s1') - }) - - it('returns [] when no chatThreads key is present (first load)', async () => { - const { plugin } = makePlugin({ specorator: { locale: 'en' } }) - await plugin.loadSettings() - - expect(plugin.getInitialChatThreads()).toEqual([]) - }) -}) - -describe('updateSettings preserves sibling keys under specorator (Codex P1, PR #350)', () => { - it('does not clobber chatThreads when settings are saved', async () => { - // Pre-seed _storedData with both PluginSettings AND a chatThreads blob — - // exactly what `_persistChatThreads` writes alongside the settings. - const initialData = { - specorator: { - locale: 'en', - specsFolder: 'specs', - claudeCliPath: '', - transportKind: 'auto', - chatThreads: { - t1: { - threadId: 't1', - sessionId: 's1', - feature: 'foo', - logPath: 'specs/foo/sessions/s1.md', - transport: 'subscription', - createdAt: '2026-05-14T10:00:00.000Z', - lastUsedAt: '2026-05-14T10:00:00.000Z', - }, - }, - }, - } - const { plugin, state } = makePlugin(initialData) - await plugin.loadSettings() - // Pre-fix the seeded settings on the plugin so `updateSettings`'s base - // merge mirrors the production load path. - plugin.settings = { - ...(plugin.settings as unknown as Record), - ...(initialData.specorator as Record), - } as unknown as typeof plugin.settings - - // Save a settings change. No `core` is wired here, so `getModuleSettings` - // returns undefined and the function falls back to the merged settings — - // mirrors the production code path's validation fallback. - await plugin.updateSettings({ transportKind: 'api-key' }) - - // The saved blob must still carry the chatThreads sibling. - const saved = state.blob as { specorator?: Record } - expect(saved.specorator).toBeDefined() - expect(saved.specorator?.chatThreads).toBeDefined() - expect(saved.specorator?.chatThreads).toEqual(initialData.specorator.chatThreads) - // And the settings change actually landed. - expect(saved.specorator?.transportKind).toBe('api-key') - }) - - it('overwrites a settings key when partial updates it (still merging — settings keys win over the existing blob)', async () => { - const initialData = { - specorator: { - locale: 'en', - specsFolder: 'specs', - claudeCliPath: '', - transportKind: 'auto', - }, - } - const { plugin, state } = makePlugin(initialData) - await plugin.loadSettings() - plugin.settings = { - ...(plugin.settings as unknown as Record), - ...(initialData.specorator as Record), - } as unknown as typeof plugin.settings - - await plugin.updateSettings({ specsFolder: 'notes' }) - - const saved = state.blob as { specorator?: Record } - expect(saved.specorator?.specsFolder).toBe('notes') - // Other settings keys preserved. - expect(saved.specorator?.locale).toBe('en') - }) -})