From ff0793f7df6a413060a2ddbcbac3b56b59399412 Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Mon, 27 Apr 2026 19:40:02 -0700 Subject: [PATCH] fix: coerce stringified numeric anchor in timeline() MCP tool (#2176) * fix: coerce stringified numeric anchor in timeline() to repair MCP anchor routing HTTP query params arrive as strings, so the typeof anchor === 'number' dispatch always missed the observation-ID branch, falling through to ISO-timestamp parsing and silently returning a wrong-epoch window with the correct anchor echoed in the header. Closes the timeline regression reported on cut-guardian. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor: extract parseNumericAnchor helper and expand timeline tests Address CodeRabbit review nitpicks on PR #2176: - Extract anchor coercion into private parseNumericAnchor helper - Add whitespace-padded numeric-string anchor test case - Add explicit numeric-anchor-not-found regression test Co-Authored-By: Claude Opus 4.7 (1M context) * test: assert exact ordering and rendered anchor header in timeline tests Address CodeRabbit nitpick on PR #2176: drop sort to verify chronological ordering, and assert that the rendered anchor/header text echoes the requested numeric ID and marks the anchor row. Co-Authored-By: Claude Opus 4.7 (1M context) * test: extract anchor-render helper and tighten garbage-anchor assertion Address CodeRabbit nitpicks: DRY-up the three repeated anchor header/row assertions into expectAnchorRendered(), and assert the exact "Invalid timestamp: 123abc" error in the garbage-anchor branch instead of a generic non-empty-string check. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- src/services/worker/SearchManager.ts | 19 +- .../SearchManager.timeline-anchor.test.ts | 304 ++++++++++++++++++ 2 files changed, 318 insertions(+), 5 deletions(-) create mode 100644 tests/worker/SearchManager.timeline-anchor.test.ts diff --git a/src/services/worker/SearchManager.ts b/src/services/worker/SearchManager.ts index f93340e0..9d644cfb 100644 --- a/src/services/worker/SearchManager.ts +++ b/src/services/worker/SearchManager.ts @@ -471,6 +471,14 @@ export class SearchManager { }; } + private parseNumericAnchor(anchor: unknown): number | null { + if (typeof anchor === 'number') return anchor; + if (typeof anchor === 'string' && /^\d+$/.test(anchor.trim())) { + return Number(anchor.trim()); + } + return null; + } + /** * Tool handler: timeline */ @@ -478,6 +486,7 @@ export class SearchManager { const { anchor, query, depth_before, depth_after, project } = args; const depthBefore = depth_before != null ? Number(depth_before) : 10; const depthAfter = depth_after != null ? Number(depth_after) : 10; + const anchorAsNumber = this.parseNumericAnchor(anchor); const cwd = process.cwd(); // Validate: must provide either anchor or query, not both @@ -550,21 +559,21 @@ export class SearchManager { timelineData = this.sessionStore.getTimelineAroundObservation(topResult.id, topResult.created_at_epoch, depthBefore, depthAfter, project); } // MODE 2: Anchor-based timeline - else if (typeof anchor === 'number') { + else if (anchorAsNumber !== null) { // Observation ID - const obs = this.sessionStore.getObservationById(anchor); + const obs = this.sessionStore.getObservationById(anchorAsNumber); if (!obs) { return { content: [{ type: 'text' as const, - text: `Observation #${anchor} not found` + text: `Observation #${anchorAsNumber} not found` }], isError: true }; } - anchorId = anchor; + anchorId = anchorAsNumber; anchorEpoch = obs.created_at_epoch; - timelineData = this.sessionStore.getTimelineAroundObservation(anchor, anchorEpoch, depthBefore, depthAfter, project); + timelineData = this.sessionStore.getTimelineAroundObservation(anchorAsNumber, anchorEpoch, depthBefore, depthAfter, project); } else if (typeof anchor === 'string') { // Session ID or ISO timestamp if (anchor.startsWith('S') || anchor.startsWith('#S')) { diff --git a/tests/worker/SearchManager.timeline-anchor.test.ts b/tests/worker/SearchManager.timeline-anchor.test.ts new file mode 100644 index 00000000..bb39899d --- /dev/null +++ b/tests/worker/SearchManager.timeline-anchor.test.ts @@ -0,0 +1,304 @@ +/** + * Regression coverage for SearchManager.timeline() anchor dispatch. + * + * Bug history: HTTP query params arrive as strings, so the + * `typeof anchor === 'number'` dispatch missed the observation-ID branch + * and silently fell through to ISO-timestamp parsing — returning a + * wrong-epoch window with the correct anchor still echoed in the header. + * + * The fix coerces stringified numerics in `SearchManager.timeline()` via + * `anchorAsNumber`. These tests guard that fix by exercising: + * (a) numeric anchor as JS number + * (b) numeric anchor as string (THE bug case) + * (c) session-ID string anchor "S" + * (d) ISO-timestamp anchor + * (e) garbage anchor (must return isError: true) + * + * Pattern source: tests/session_store.test.ts uses real SessionStore + * against ':memory:' SQLite. We follow the same approach (no SessionStore + * mocks) and additionally instantiate real SessionSearch over the same DB + * handle, plus real FormattingService and TimelineService. ChromaSync is + * passed as null (the timeline anchor branch does not require Chroma). + */ +import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'; + +// ModeManager is a global singleton that requires `loadMode()` to be +// called before use. The formatter path inside `SearchManager.timeline()` +// calls `ModeManager.getInstance().getTypeIcon(...)`, which throws if no +// mode is loaded. Existing worker tests (e.g. tests/worker/search/ +// result-formatter.test.ts) follow the same pattern: stub ModeManager +// so the unrelated config singleton does not blow up the unit under +// test. We deliberately do NOT mock SessionStore — that's the data +// layer the bug travelled through, and faking it would defeat the +// regression coverage. +mock.module('../../src/services/domain/ModeManager.js', () => ({ + ModeManager: { + getInstance: () => ({ + getActiveMode: () => ({ + name: 'code', + prompts: {}, + observation_types: [ + { id: 'discovery', icon: 'I' }, + ], + observation_concepts: [], + }), + getObservationTypes: () => [{ id: 'discovery', icon: 'I' }], + getTypeIcon: (_type: string) => 'I', + getWorkEmoji: () => 'W', + }), + }, +})); + +import { Database } from 'bun:sqlite'; +import { SessionStore } from '../../src/services/sqlite/SessionStore.js'; +import { SessionSearch } from '../../src/services/sqlite/SessionSearch.js'; +import { FormattingService } from '../../src/services/worker/FormattingService.js'; +import { TimelineService } from '../../src/services/worker/TimelineService.js'; +import { SearchManager } from '../../src/services/worker/SearchManager.js'; + +const PROJECT = 'timeline-anchor-test'; +const MEMORY_SESSION_ID = 'mem-session-timeline-anchor'; +const CONTENT_SESSION_ID = 'content-timeline-anchor'; + +interface SeededObservation { + id: number; + epoch: number; +} + +function seedObservations(store: SessionStore, count: number): SeededObservation[] { + const sdkId = store.createSDKSession(CONTENT_SESSION_ID, PROJECT, 'initial prompt'); + store.updateMemorySessionId(sdkId, MEMORY_SESSION_ID); + + // Anchor the synthetic timeline well in the past so it cannot collide with + // any "recent rows" the buggy code path would otherwise return. + const baseEpoch = Date.UTC(2024, 0, 1, 0, 0, 0); // 2024-01-01T00:00:00Z + const stepMs = 60_000; // 1 minute apart, deterministic ordering + + const seeded: SeededObservation[] = []; + for (let i = 0; i < count; i++) { + const epoch = baseEpoch + i * stepMs; + const result = store.storeObservation( + MEMORY_SESSION_ID, + PROJECT, + { + type: 'discovery', + title: `Synthetic observation #${i + 1}`, + subtitle: null, + facts: [], + narrative: `Narrative for synthetic observation ${i + 1}`, + concepts: [], + files_read: [], + files_modified: [], + }, + i + 1, + 0, + epoch + ); + seeded.push({ id: result.id, epoch: result.createdAtEpoch }); + } + return seeded; +} + +/** + * Pull the observation IDs out of the timeline's formatted markdown. + * Each observation row renders as `| # |