Skip to content

feat(mcp-server): migrate prediction contract-status WebSocket stream to SDK (PREDICT-8823) - #70

Merged
SohumDesai27 merged 4 commits into
mainfrom
predict-8823-migrate-prediction-contract-status-websocket-stream-to-sdk
Sep 22, 2026
Merged

SohumDesai27 merged 4 commits into
mainfrom
predict-8823-migrate-prediction-contract-status-websocket-stream-to-sdk

Conversation

@SohumDesai27

@SohumDesai27 SohumDesai27 commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Linear Board: https://linear.app/gemini/issue/PREDICT-8823/migrate-prediction-contract-status-websocket-stream-to-sdk

What's Included

  • SDK-backed contractStatus stream: WebSocketManager.subscribeContractStatus() now subscribes via sdkClient.websocket.public.contractStatus() instead of the legacy hand-rolled client, mapping the SDK's typed payload (bigint contract/order IDs converted to exact strings) into the existing MarketDataStore.updateContractStatus().
  • SDK-backed orders@account stream: WebSocketManager.subscribeAccountOrders() now subscribes via sdkClient.websocket.private.orders({ scope: 'account' }), mapping into the existing MarketDataStore.updateOrder().
  • Legacy dispatch removed for these two channels only: the isContractStatusMessage/isOrderUpdateMessage branches in handleMessage are removed, since those channels are no longer subscribed via the legacy client. Trade/depth/bookTicker/ticker dispatch on the legacy client is untouched, per the ticket's scope (deferred to a later milestone).
  • SdkClient threaded into WebSocketManager: both production call sites — server.ts (main MCP server) and alerts/daemon/index.ts (alerts daemon) — now pass their already-constructed sdkClient into the manager's constructor.
  • Preserved, unchanged: the pendingSubscriptions dedup guard (generalized to wrap either backend, not duplicated), the OrderStreamSource interface shape, and the agent-facing behavior of gemini_get_order_updates / gemini_get_contract_status — no changes needed in tools/orderStream.ts or tools/marketStream.ts.

Testing

  • npm test — 315/315 passing (existing + new)
  • tsc strict typecheck clean
  • build clean
  • New/reworked coverage in manager.test.ts (fake SdkClient/WebSocketStream double, replacing the real-socket-server tests previously used for these two channels):
    • subscribeContractStatus() / subscribeAccountOrders() subscribe exactly once, and dedupe both sequential-idempotent and truly-concurrent calls
    • An 18-digit contract/order ID round-trips through the store as an exact string, using real bigint fixtures (the precision case that motivated this migration)
    • A fill-shaped orderUpdate frame lands in the order store, not the trade store
    • A canceled orderUpdate frame is captured with its reject reason
    • subscribeAccountOrders() throws synchronously, without touching the SDK, when credentials are unset
    • A rejected subscribe ack propagates as an error instead of being silently recorded as subscribed
    • disconnect() closes both the SDK contractStatus and orders@account streams
    • A bigint order timestamp within IEEE-754 rounding distance of a millisecond boundary is not shifted (regression test for a real precision bug found in review — see commit c5443b0)

Manual, against real production (used the gemini MCP connection, running this branch's build — not gemini-sandbox, which wasn't used for any of the verification below):

  • gemini_get_contract_status on a live production contract successfully subscribed via the new SDK path with no crash or auth error. Re-checked after the contract's real settlement and observed a genuine live lifecycle transition flow through correctly end-to-end: previousStatus: "Under Review" → newStatus: "Settled", with the contract's real numeric ID (668479) preserved exactly and an eventTimeMs matching the contract's actual settlement time to the second. This is the load-bearing live proof that the new mapping code (not just the subscribe/auth plumbing) works against genuine SDK data.
  • gemini_get_order_updates successfully authenticated and subscribed via the new SDK private stream against production across several real orders. Two separate attempts to also observe a live orderUpdate frame (canceling a never-filled resting order; placing a marketable order that filled synchronously within its own placement call) both came back empty rather than erroring — consistent with Gemini's real order feed only pushing for state changes that resolve asynchronously from the REST call that caused them, not a defect in this code. Given orderUpdate uses the identical subscribe → map → store pattern already proven live for contractStatus, plus its own dedicated fixture-based coverage above, this is considered adequately covered without forcing further real trades.
  • alerts/daemon/index.ts's call site was installed as a real macOS launchd service (not just run in the foreground) and ran unattended for ~104 minutes with zero errors, against production. Its prediction.settled poller — sharing this file's sdkClient — correctly detected and fired a real settlement alert end-to-end (BTC05M2609221605 settled at 86441.58330188681), confirming this call site's WebSocketManager wiring is sound under real, sustained operation. Uninstalled after testing.
Screenshot 2026-09-22 at 4 27 08 PM Screenshot 2026-09-22 at 4 31 52 PM

What Success Looks Like

A reviewer should be able to:

  • Confirm handleMessage no longer has isContractStatusMessage/isOrderUpdateMessage branches, and that trade/depth/bookTicker/ticker branches are byte-for-byte unchanged.
  • Confirm both server.ts and alerts/daemon/index.ts pass sdkClient as WebSocketManager's second constructor argument, matching the updated signature.
  • Run npm test and see the reworked manager.test.ts pass, including the 18-digit-precision and fill-vs-trade-routing assertions, without a real socket server.
  • Confirm tools/orderStream.ts and tools/marketStream.ts have zero diff — a false negative here would be either interface accidentally widening or a tool's agent-facing behavior changing.
  • No regression to existing tools: spot bookTicker/trade/depth/ticker subscriptions, gemini_get_order_updates, and gemini_get_contract_status all continue to work exactly as before from the agent's perspective.

🤖 Generated with Claude Code

… to SDK (PREDICT-8823)

Routes the contractStatus and orders@account WebSocket channels through
@gemini-markets/sdk/server's public/private streams instead of the legacy
hand-rolled client, threading the already-authenticated SdkClient into
WebSocketManager at both production call sites (server.ts, alerts daemon).
Trade/depth/bookTicker/ticker dispatch on the legacy client is untouched,
per the ticket's scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Sep 22, 2026

Copy link
Copy Markdown

PREDICT-8823

Comment thread packages/mcp-server/src/websocket/manager.test.ts Fixed
Comment thread packages/mcp-server/src/websocket/manager.test.ts Fixed
Comment thread packages/mcp-server/src/websocket/manager.test.ts Fixed
Comment thread packages/mcp-server/src/websocket/manager.test.ts Fixed
Comment thread packages/mcp-server/src/websocket/manager.test.ts Fixed
Comment thread packages/mcp-server/src/websocket/manager.test.ts Fixed
Comment thread packages/mcp-server/src/websocket/manager.test.ts Fixed
Comment thread packages/mcp-server/src/websocket/manager.test.ts Fixed
Comment thread packages/mcp-server/src/websocket/manager.test.ts Fixed
Comment thread packages/mcp-server/src/websocket/manager.test.ts Fixed
@nostradamus-bot

Copy link
Copy Markdown

Nostradamus Risk Rating — Low

This PR migrates the contractStatus and orders@account WebSocket subscriptions from a legacy hand-rolled wire client to the official Gemini TypeScript SDK; the authenticated credential guard for the private orders@account stream is preserved identically, no new external-facing endpoints are introduced, and the diff is a clean refactor with comprehensive behavioral tests covering bigint ID precision, fill/cancel routing, and dedup correctness.

The test-only 'ws://unused' placeholder passed to WebSocketManager's
constructor never opens a socket (these tests exercise only the
SDK-backed fake-client path), but Semgrep's detect-insecure-websocket
rule pattern-matches the literal string regardless. Renamed to
'unused' so it no longer looks like a URL.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@SohumDesai27

Copy link
Copy Markdown
Collaborator Author

Addressed the automated review findings:

  • Nostradamus risk rating (Low): informational, no action needed.
  • 11 Semgrep detect-insecure-websocket findings in manager.test.ts: all were the same false positive — a 'ws://unused' placeholder string passed to WebSocketManager's constructor in tests that only exercise the new SDK-backed fake-client path and never actually open a socket with it. Verified WebSocketManager/GeminiWebSocketClient never eagerly parses the URL (just stores it), so renamed the placeholder to 'unused' in 52f3c10 — no longer matches the rule, and the full suite (312/312) still passes.

@sdlc-pr-bot

sdlc-pr-bot Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Change Control Evidence Check

Authorization — ✅ Pass

Evidence:

  • Linear ticket: PREDICT-8823
  • Change owner: Sohum Desai
  • Type of change: Normal
Testing — ✅ Pass

Evidence:

  • Tests from CI checks: ➖ No tests ran
  • Security scans: ✅ Passed
  • Testing summary: ✅ Found in PR description
    • Summary: All 315 unit tests passed, TypeScript strict type checking passed, and build completed successfully. New test coverage was added for subscription deduplication and 18-digit ID handling using mock doubles in place of real socket server tests.
  • Evidence link: View run
Approval — ✅ Pass

Evidence:

  • Approver: Kevin Nguy
  • Approval source: GitHub PR Review
  • Approval timestamp: 2026-09-22 21:18 UTC
Segregation of Duties — ✅ Pass

Validated:

  • PR author: SohumDesai27
  • Commit author(s): SohumDesai27
  • Linear assignee: Sohum Desai
  • Approver: Kevin Nguy
  • Result: Implementer and approver are different people ✅

Last checked: 2026-09-22 23:10 UTC

↩ Re-run Clarissa if tickets, CI, or approvals have changed.

@svc-grace svc-grace left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agentic Review

Nice migration overall, and the precision and live-path coverage make the SDK mapping feel well covered. The remaining suggestions are both around the same testing gap: the account-orders subscription passes a scope, but the fake and assertions don't verify that it is specifically account, so a wrong scope could slip through without failing tests. This is non-blocking follow-up; the rest reads clean.

Review process

Grace version: v0.0.210
Files reviewed (4): index.ts, manager.test.ts, manager.ts, server.ts
Guidelines: none discovered
Verification: 2 of 3 findings verified
Findings: 0 critical, 0 important, 2 suggestions
Linked tickets: PREDICT-8823
LLM usage: 59 calls — gpt-5.6-luna: 42 calls, 1958533 tokens, us.anthropic.claude-opus-4-6-v1: 17 calls, 544347 tokens

assert.deepStrictEqual(receivedParams, ['orders@account']);
})
);
test('subscribeAccountOrders() subscribes exactly once through the SDK private stream', async () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Suggestion: The account stream scope is not asserted

subscribeAccountOrders() tests only count returned streams; the fake orders() ignores its arguments, so a regression that passes { scope: 'session' } instead of { scope: 'account' } still passes while subscribing to the wrong feed. Record and assert the options passed to the SDK method.

},
},
private: {
orders: () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Suggestion: Private stream fake does not validate the account scope

The fake private stream ignores its arguments, so these tests never verify the required { scope: 'account' } contract. A regression to scope: 'session' still creates one stream and passes every private-subscription test while subscribing the wrong feed. Record the argument and assert that the manager passes the account scope.

…am fake

svc-grace flagged that the private-stream fake ignored its arguments,
so a regression to { scope: 'session' } would still pass every
subscribeAccountOrders() test while subscribing to the wrong feed.
Records the options passed to private.orders() and asserts { scope:
'account' } explicitly. Verified by temporarily breaking the real
scope arg to 'session' and confirming this test fails before
reverting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@SohumDesai27

Copy link
Copy Markdown
Collaborator Author

Addressed the latest round of automated review:

svc-grace (2 comments, same underlying gap): the private-stream test fake ignored the options passed to `private.orders()`, so a regression to `{ scope: 'session' }` would still pass every `subscribeAccountOrders()` test while subscribing to the wrong feed. Fixed in 30bdc3e by recording the passed options and asserting `{ scope: 'account' }` explicitly. Verified the fix actually catches the regression Grace described: temporarily changed the real code to `scope: 'session'`, confirmed this exact test failed with a clear diff, then reverted — full suite (312/312) and typecheck still clean.

Semgrep (11 findings): confirmed via the code-scanning API that all 11 now show `most_recent_instance.state: "fixed"` as of 52f3c10.

Nostradamus / sdlc-pr-bot evidence check: informational, no code changes needed from this PR. The evidence bot's "Testing: Incomplete" note (CI security-scan workflows not found/configured, e.g. Socket Security/scan_for_secrets) looks like a repo-level CI configuration gap rather than anything specific to this diff — flagging for visibility rather than attempting to reconfigure CI pipelines from within this PR.

@svc-grace svc-grace left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agentic Review

Good progress — both account-scope gaps are resolved, so the migrated streams now validate the intended scope in the fakes and assertions. This pass only found non-blocking follow-ups: the SDK stream cleanup path could use coverage, and bigint order timestamps should be converted without rounding first. The inline comments have the details; neither blocks this PR.

Review process

Grace version: v0.0.210
Files reviewed (1): manager.test.ts
Files skipped: 6
Guidelines: none discovered
Verification: 2 of 2 findings verified
Findings: 0 critical, 0 important, 2 suggestions
Linked tickets: PREDICT-8823
LLM usage: 34 calls — gpt-5.6-luna: 27 calls, 1197991 tokens, us.anthropic.claude-opus-4-6-v1: 7 calls, 186400 tokens

return this;
}

close(): Promise<void> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Suggestion: SDK stream cleanup is not tested

The fake exposes close() and records closed, but no test calls manager.disconnect() after either SDK subscription or asserts that flag. A regression in the new SDK cleanup path would leave the stream and its reconnect loop alive after manager shutdown while the suite still passes. Add a disconnect assertion for both stream types.

rejectReason: msg.r,
// Same magnitude-detection treatment as the legacy orderUpdate wire
// handler applied to this field — see toEventTimeMs's doc comment.
eventTimeMs: toEventTimeMs(Number(msg.E)),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Suggestion: Bigint order timestamps are rounded before conversion

The adapter converts a lossless nanosecond bigint timestamp to number before dividing, so rounding can change the millisecond bucket. When E is within the roughly 256ns IEEE-754 spacing below a millisecond boundary, Number(msg.E) rounds up and eventTimeMs is one millisecond too high. Preserve bigint arithmetic through the division and only convert the quotient to number.

…vision

svc-grace flagged that orderUpdateFromSdk narrowed the SDK's nanosecond
bigint E field to Number before dividing by 1e6, which can round the
value across a millisecond boundary (doubles only have ~256ns of
spacing at this magnitude). Added sdkEventTimeMs, which divides in
bigint space first and only narrows the much smaller millisecond
quotient. Reproduced the exact failure with a real bigint 50ns below a
boundary, confirmed the new test fails against the old formula and
passes against the fix.

Also covers Grace's SDK-stream-cleanup finding: added tests asserting
disconnect() closes both the contractStatus and orders@account SDK
streams, which the fakes already tracked but nothing asserted.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@SohumDesai27

Copy link
Copy Markdown
Collaborator Author

Addressed this round of svc-grace findings:

Bigint order timestamp rounding (real bug, not just a test gap): confirmed by reproducing it directly — `Number(1789420240479000000n - 50n)` rounds up to exactly `1789420240479000000`, so the old `toEventTimeMs(Number(msg.E))` produced `eventTimeMs: 1789420240479` for a value that should floor to `...478`. Fixed in c5443b0 with `sdkEventTimeMs`, which divides in bigint space first and only narrows the (much smaller, safely-representable) millisecond quotient to `Number`. Added a regression test using that exact near-boundary bigint, confirmed it fails against the old formula and passes against the fix.

SDK stream cleanup not asserted: the fakes already tracked `closed`, nothing asserted it. Added two tests confirming `disconnect()` closes both the `contractStatus` and `orders@account` SDK streams.

Full suite now 315/315, typecheck and build clean.

@svc-grace svc-grace left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agentic Review

Good progress — all four carried-over concerns are resolved: the stream cleanup coverage, account-scope validation, scope assertion, and bigint timestamp handling have landed. This pass found no new issues. The migration keeps the existing tool behavior intact while moving both streams onto the SDK, with the exact-ID mapping and subscription deduplication being the key correctness points.

Review process

Grace version: v0.0.210
Files reviewed (2): manager.test.ts, manager.ts
Files skipped: 4
Suggestions damped: 1 (pass 3 with nothing blocking — new suggestion-level findings are withheld so the review converges, TOOLS-6878)
Guidelines: none discovered
Verification: 1 of 2 findings verified
Findings: 0 critical, 0 important, 0 suggestions
Linked tickets: PREDICT-8823
LLM usage: 95 calls — gpt-5.6-luna: 73 calls, 4850504 tokens, us.anthropic.claude-opus-4-6-v1: 22 calls, 839825 tokens

@SohumDesai27
SohumDesai27 merged commit ba97c95 into main Sep 22, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants