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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Newman
2026-04-27 19:40:02 -07:00
committed by GitHub
parent 5458dd2322
commit ff0793f7df
2 changed files with 318 additions and 5 deletions
+14 -5
View File
@@ -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')) {