From db3e572a45cc0befdfd5a28984e16c2418999d8f Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Wed, 12 Aug 2026 17:41:59 +0300 Subject: [PATCH 1/6] fix(studio): reject null surfnet clock timestamps (cherry picked from commit 228884d60749dc5725643bbbbef33b5fce08208f) --- apps/studio/src/lib/surfnet-clock.test.ts | 9 +++++++++ apps/studio/src/lib/surfnet-clock.ts | 4 ++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/studio/src/lib/surfnet-clock.test.ts b/apps/studio/src/lib/surfnet-clock.test.ts index a560e73..a5e3be7 100644 --- a/apps/studio/src/lib/surfnet-clock.test.ts +++ b/apps/studio/src/lib/surfnet-clock.test.ts @@ -52,4 +52,13 @@ describe('fetchSurfnetClockSeconds', () => { }); await expect(fetchSurfnetClockSeconds('http://127.0.0.1:8899')).resolves.toBeNull(); }); + + it('returns null when the timestamp is null', async () => { + mockFetchResponse({ + json: async () => ({ + result: { value: { data: { parsed: { info: { unixTimestamp: null } } } } }, + }), + }); + await expect(fetchSurfnetClockSeconds('http://127.0.0.1:8899')).resolves.toBeNull(); + }); }); diff --git a/apps/studio/src/lib/surfnet-clock.ts b/apps/studio/src/lib/surfnet-clock.ts index 61a3a11..f7552f3 100644 --- a/apps/studio/src/lib/surfnet-clock.ts +++ b/apps/studio/src/lib/surfnet-clock.ts @@ -17,8 +17,8 @@ export async function fetchSurfnetClockSeconds(rpcUrl: string): Promise Date: Wed, 12 Aug 2026 17:59:20 +0300 Subject: [PATCH 2/6] fix(studio): prevent overlapping clock polls (cherry picked from commit cbd25a69c491f9d9f27807809b781996a44c7ffb) --- .../src/components/svm/explorer-header.tsx | 19 +------- apps/studio/src/lib/surfnet-clock.test.ts | 43 ++++++++++++++++++- apps/studio/src/lib/surfnet-clock.ts | 24 +++++++++++ 3 files changed, 68 insertions(+), 18 deletions(-) diff --git a/apps/studio/src/components/svm/explorer-header.tsx b/apps/studio/src/components/svm/explorer-header.tsx index b3e7fe0..426a94c 100644 --- a/apps/studio/src/components/svm/explorer-header.tsx +++ b/apps/studio/src/components/svm/explorer-header.tsx @@ -2,7 +2,7 @@ import TransactionInspector from '@/components/svm/transaction-inspector'; import { useAppConfig } from '@/hooks/use-app-config'; import { S3Credentials, uploadToS3 } from '@/lib/s3-upload'; import { solanaWebSocketService } from '@/lib/solana-websocket-service'; -import { fetchSurfnetClockSeconds } from '@/lib/surfnet-clock'; +import { fetchSurfnetClockSeconds, startSurfnetClockPolling } from '@/lib/surfnet-clock'; import { CalendarIcon, PauseIcon, PlayIcon } from '@heroicons/react/24/outline'; import { ArchiveBoxArrowDownIcon, CloudArrowUpIcon } from '@heroicons/react/24/solid'; import { CheckoutModal, MoneyMQProvider } from '@moneymq/react'; @@ -86,22 +86,7 @@ const ExplorerHeader = ({ initialTransactionSignature }: ExplorerHeaderProps) => // drifts from the wall clock (pauses, jumps, its own tick rate) useEffect(() => { if (!showTimeTravel) return; - let cancelled = false; - const readClock = () => { - fetchSurfnetClockSeconds(rpcUrl) - .then((seconds) => { - if (!cancelled) setSimnetClockSeconds(seconds); - }) - .catch(() => { - if (!cancelled) setSimnetClockSeconds(null); - }); - }; - readClock(); - const interval = setInterval(readClock, 1000); - return () => { - cancelled = true; - clearInterval(interval); - }; + return startSurfnetClockPolling(rpcUrl, setSimnetClockSeconds); }, [showTimeTravel, rpcUrl]); useEffect(() => { diff --git a/apps/studio/src/lib/surfnet-clock.test.ts b/apps/studio/src/lib/surfnet-clock.test.ts index a5e3be7..d2c1656 100644 --- a/apps/studio/src/lib/surfnet-clock.test.ts +++ b/apps/studio/src/lib/surfnet-clock.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { fetchSurfnetClockSeconds } from './surfnet-clock'; +import { fetchSurfnetClockSeconds, startSurfnetClockPolling } from './surfnet-clock'; const mockFetchResponse = (response: Partial & { json?: () => Promise }) => { const fetchMock = vi.fn().mockResolvedValue({ ok: true, ...response }); @@ -9,6 +9,7 @@ const mockFetchResponse = (response: Partial & { json?: () => Promise< describe('fetchSurfnetClockSeconds', () => { afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); }); @@ -61,4 +62,44 @@ describe('fetchSurfnetClockSeconds', () => { }); await expect(fetchSurfnetClockSeconds('http://127.0.0.1:8899')).resolves.toBeNull(); }); + + it('does not start another poll while the current request is pending', async () => { + vi.useFakeTimers(); + let resolveFirstRequest: ((response: Partial) => void) | undefined; + const fetchMock = vi + .fn() + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirstRequest = resolve; + }) + ) + .mockResolvedValue({ + ok: true, + json: async () => ({ + result: { value: { data: { parsed: { info: { unixTimestamp: 1785758171 } } } } }, + }), + }); + vi.stubGlobal('fetch', fetchMock); + const onUpdate = vi.fn(); + + const stop = startSurfnetClockPolling('http://127.0.0.1:8899', onUpdate); + await vi.advanceTimersByTimeAsync(1000); + expect(fetchMock).toHaveBeenCalledTimes(1); + + resolveFirstRequest?.({ + ok: true, + json: async () => ({ + result: { value: { data: { parsed: { info: { unixTimestamp: 1785758170 } } } } }, + }), + }); + await vi.advanceTimersByTimeAsync(0); + expect(onUpdate).toHaveBeenCalledWith(1785758170); + + await vi.advanceTimersByTimeAsync(999); + expect(fetchMock).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + stop(); + }); }); diff --git a/apps/studio/src/lib/surfnet-clock.ts b/apps/studio/src/lib/surfnet-clock.ts index f7552f3..e97ac4d 100644 --- a/apps/studio/src/lib/surfnet-clock.ts +++ b/apps/studio/src/lib/surfnet-clock.ts @@ -23,3 +23,27 @@ export async function fetchSurfnetClockSeconds(rpcUrl: string): Promise void, + intervalMs = 1000 +): () => void { + let stopped = false; + let timeout: ReturnType | undefined; + + const poll = async () => { + const seconds = await fetchSurfnetClockSeconds(rpcUrl); + if (stopped) return; + + onUpdate(seconds); + timeout = setTimeout(poll, intervalMs); + }; + + void poll(); + + return () => { + stopped = true; + if (timeout !== undefined) clearTimeout(timeout); + }; +} From bae10c8c99eedc6596e91016fa8b3ffb3456d893 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Wed, 12 Aug 2026 17:46:57 +0300 Subject: [PATCH 3/6] fix(studio): stop completion after generation errors (cherry picked from commit f402e29d6f8e1e7b1cdde36444e0eed22409e9f0) --- apps/studio/src/lib/ai-client.test.ts | 3 +++ apps/studio/src/lib/ai-client.ts | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/studio/src/lib/ai-client.test.ts b/apps/studio/src/lib/ai-client.test.ts index 923932d..382c0fe 100644 --- a/apps/studio/src/lib/ai-client.test.ts +++ b/apps/studio/src/lib/ai-client.test.ts @@ -364,6 +364,7 @@ describe('streamClaudeResponse thinking round-trip', () => { expect(events.filter((e) => e.type === 'error').map((e) => e.content)).toEqual([ 'The model ran out of output budget before finishing. Try again, or narrow the request.', ]); + expect(events.some((e) => e.type === 'done')).toBe(false); }); }); @@ -518,6 +519,7 @@ describe('streamOpenAIResponse (Responses API)', () => { const events = await runOpenAITurn(); expect(requests).toHaveLength(8); expect(events.find((e) => e.type === 'error')?.content).toContain('Stopped after 8 tool rounds'); + expect(events.some((e) => e.type === 'done')).toBe(false); }); }); @@ -563,5 +565,6 @@ describe('streamClaudeResponse thinking toggle', () => { ]); const events = await runClaudeTurn('claude-opus-5', true); expect(events.find((e) => e.type === 'error')?.content).toContain('context window'); + expect(events.some((e) => e.type === 'done')).toBe(false); }); }); diff --git a/apps/studio/src/lib/ai-client.ts b/apps/studio/src/lib/ai-client.ts index 47614f8..2e0b05b 100644 --- a/apps/studio/src/lib/ai-client.ts +++ b/apps/studio/src/lib/ai-client.ts @@ -662,13 +662,16 @@ export async function* streamClaudeResponse( type: 'error', content: 'The model ran out of output budget before finishing. Try again, or narrow the request.', }; + return; } else if (stopReason === 'refusal') { yield { type: 'error', content: 'The model declined to answer this request.' }; + return; } else if (stopReason === 'model_context_window_exceeded') { yield { type: 'error', content: 'The conversation is too long for the model context window. Start a new generation.', }; + return; } finished = true; break; @@ -680,6 +683,7 @@ export async function* streamClaudeResponse( type: 'error', content: `Stopped after ${MAX_ITERATIONS} tool rounds without a final answer. Try a more specific request.`, }; + return; } yield { type: 'done', content: null }; @@ -846,7 +850,6 @@ export async function* streamOpenAIResponse( type: 'error', content: `Stopped after ${MAX_ITERATIONS} tool rounds without a final answer. Try a more specific request.`, }; - yield { type: 'done', content: null }; } // Stream response from Groq (OpenAI-compatible API) From e03092db8c23d7dca676dae93a77bd59fe3f3b00 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Wed, 12 Aug 2026 18:07:04 +0300 Subject: [PATCH 4/6] fix(studio): reject incomplete Claude streams (cherry picked from commit 23bd2be2b108088f40abfc81c0f4508f60a19a58) --- apps/studio/src/lib/ai-client.test.ts | 35 +++++++++++++++++++++++++++ apps/studio/src/lib/ai-client.ts | 9 +++++++ 2 files changed, 44 insertions(+) diff --git a/apps/studio/src/lib/ai-client.test.ts b/apps/studio/src/lib/ai-client.test.ts index 382c0fe..b8d195c 100644 --- a/apps/studio/src/lib/ai-client.test.ts +++ b/apps/studio/src/lib/ai-client.test.ts @@ -366,6 +366,41 @@ describe('streamClaudeResponse thinking round-trip', () => { ]); expect(events.some((e) => e.type === 'done')).toBe(false); }); + + it('reports a stream that closes without a terminal stop reason', async () => { + mockAnthropicRounds([ + [ + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'partial' } }, + { type: 'content_block_stop', index: 0 }, + ], + ]); + + const events = await runTurn(); + + expect(events.find((e) => e.type === 'error')?.content).toBe( + 'Claude stream ended before completing. Try again.' + ); + expect(events.some((e) => e.type === 'done')).toBe(false); + }); + + it('reports an unrecognized stop reason instead of completing', async () => { + mockAnthropicRounds([[{ type: 'message_delta', delta: { stop_reason: 'unexpected_reason' } }]]); + + const events = await runTurn(); + + expect(events.find((e) => e.type === 'error')?.content).toContain('unexpected_reason'); + expect(events.some((e) => e.type === 'done')).toBe(false); + }); + + it('accepts stop_sequence as a successful terminal reason', async () => { + mockAnthropicRounds([[{ type: 'message_delta', delta: { stop_reason: 'stop_sequence' } }]]); + + const events = await runTurn(); + + expect(events.some((e) => e.type === 'error')).toBe(false); + expect(events.filter((e) => e.type === 'done')).toHaveLength(1); + }); }); const OA_TOOLCALL = (callId: string, args: string, respId: string): SSEEvent[] => [ diff --git a/apps/studio/src/lib/ai-client.ts b/apps/studio/src/lib/ai-client.ts index 2e0b05b..9fad911 100644 --- a/apps/studio/src/lib/ai-client.ts +++ b/apps/studio/src/lib/ai-client.ts @@ -673,6 +673,15 @@ export async function* streamClaudeResponse( }; return; } + if (stopReason !== 'end_turn' && stopReason !== 'stop_sequence') { + yield { + type: 'error', + content: stopReason + ? `Claude stopped before completing (${stopReason}). Try again.` + : 'Claude stream ended before completing. Try again.', + }; + return; + } finished = true; break; } From e851c375e5da5ac3be33aa28a891327d77c4adc1 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Wed, 12 Aug 2026 17:50:29 +0300 Subject: [PATCH 5/6] fix(studio): carry scenario tags into the editor (cherry picked from commit 5b330cc82148f52b89fa2c5ee1af4d1f4698d32f) --- .../src/components/svm/scenario-editor.tsx | 29 +++---------------- .../src/components/svm/scenarios-bento.tsx | 1 + .../components/svm/scenarios-bento.types.ts | 1 + apps/studio/src/lib/scenarios-api.test.ts | 3 +- apps/studio/src/lib/scenarios-api.ts | 1 + 5 files changed, 9 insertions(+), 26 deletions(-) diff --git a/apps/studio/src/components/svm/scenario-editor.tsx b/apps/studio/src/components/svm/scenario-editor.tsx index 811dff9..99bb412 100644 --- a/apps/studio/src/components/svm/scenario-editor.tsx +++ b/apps/studio/src/components/svm/scenario-editor.tsx @@ -65,6 +65,7 @@ interface ScenarioEditorProps { scenarioId?: string; scenarioName?: string; scenarioDescription?: string; + scenarioTags?: string[]; initialSteps?: Array<{ id: string; name: string; @@ -89,10 +90,10 @@ export default function ScenarioEditor({ scenarioId = 'default', scenarioName = 'Scenario', scenarioDescription = 'Scenario created from editor', + scenarioTags, initialSteps, }: ScenarioEditorProps) { const { rpcUrl, studioUrl } = useAppConfig(); - const [scenarioTags, setScenarioTags] = React.useState([]); const [mode, setMode] = useState<'read' | 'edit' | 'play'>('read'); const [searchQuery, setSearchQuery] = useState(''); const [actionSearchQuery, setActionSearchQuery] = useState(''); @@ -119,28 +120,6 @@ export default function ScenarioEditor({ isFirstSlotsChangeRef.current = true; }, [scenarioId]); - // Load scenario tags from backend - React.useEffect(() => { - const loadScenarioTags = async () => { - try { - // There is no GET /v1/scenarios/{id} endpoint; the CLI's SPA fallback answers - // it with index.html and HTTP 200, so the tags come from the list endpoint - const response = await fetch(`${studioUrl}/v1/scenarios`); - if (response.ok) { - const data = await response.json(); - const scenario = Array.isArray(data) ? data.find((s) => s.id === scenarioId) : undefined; - if (scenario?.tags) { - setScenarioTags(scenario.tags); - } - } - } catch (error) { - console.error('Error loading scenario tags:', error); - } - }; - - loadScenarioTags(); - }, [scenarioId, studioUrl]); - // Load scenario from initialSteps (backend data) - always prioritize fresh data React.useEffect(() => { if (initializedRef.current || typeof window === 'undefined') return; @@ -278,7 +257,7 @@ export default function ScenarioEditor({ name: scenarioName, description: scenarioDescription, overrides: overrides, - tags: scenarioTags, // Preserve existing tags + tags: scenarioTags ?? [], }; logger.log('🔍 PATCH request data:', JSON.stringify(patchData, null, 2)); @@ -311,7 +290,7 @@ export default function ScenarioEditor({ } else { isFirstSlotsChangeRef.current = false; } - }, [slots, scenarioId, scenarioName, scenarioDescription, studioUrl]); + }, [slots, scenarioId, scenarioName, scenarioDescription, scenarioTags, studioUrl]); // Handle ESC key to exit Edit mode React.useEffect(() => { diff --git a/apps/studio/src/components/svm/scenarios-bento.tsx b/apps/studio/src/components/svm/scenarios-bento.tsx index 7474943..714e84c 100644 --- a/apps/studio/src/components/svm/scenarios-bento.tsx +++ b/apps/studio/src/components/svm/scenarios-bento.tsx @@ -272,6 +272,7 @@ export default function ScenariosBento({ scenarioId={item.id} scenarioName={item.name} scenarioDescription={item.description} + scenarioTags={item.tags} initialSteps={item.steps} /> diff --git a/apps/studio/src/components/svm/scenarios-bento.types.ts b/apps/studio/src/components/svm/scenarios-bento.types.ts index 8b95371..6670900 100644 --- a/apps/studio/src/components/svm/scenarios-bento.types.ts +++ b/apps/studio/src/components/svm/scenarios-bento.types.ts @@ -25,6 +25,7 @@ export interface ScenarioBentoItem extends BentoItem { created_at?: string; updated_at?: string; steps?: ScenarioStep[]; + tags?: string[]; } export interface ExampleScenario { diff --git a/apps/studio/src/lib/scenarios-api.test.ts b/apps/studio/src/lib/scenarios-api.test.ts index 85c7100..fb3a47b 100644 --- a/apps/studio/src/lib/scenarios-api.test.ts +++ b/apps/studio/src/lib/scenarios-api.test.ts @@ -189,13 +189,14 @@ describe('buildUpdatePayload', () => { describe('scenarioToBentoItem', () => { it('maps all fields correctly', () => { - const result = scenarioToBentoItem(baseScenario); + const result = scenarioToBentoItem({ ...baseScenario, tags: ['pyth', 'oracle'] }); expect(result.id).toBe('test-123'); expect(result.name).toBe('Test Scenario'); expect(result.description).toBe('A test scenario'); expect(result.created_at).toBe('2025-01-01T00:00:00Z'); expect(result.updated_at).toBe('2025-01-02T00:00:00Z'); expect(result.steps).toBe(baseScenario.steps); + expect(result.tags).toEqual(['pyth', 'oracle']); expect(result.metadata).toBeUndefined(); }); diff --git a/apps/studio/src/lib/scenarios-api.ts b/apps/studio/src/lib/scenarios-api.ts index 91aaa3a..0536de0 100644 --- a/apps/studio/src/lib/scenarios-api.ts +++ b/apps/studio/src/lib/scenarios-api.ts @@ -243,6 +243,7 @@ export function scenarioToBentoItem(scenario: Scenario): ScenarioBentoItem { created_at: scenario.created_at, updated_at: scenario.updated_at, steps: scenario.steps, + tags: scenario.tags, metadata: scenario.metadata, }; } From fc1de34e700d7af8237d2bcbd7d3392332b688f2 Mon Sep 17 00:00:00 2001 From: 92Infinitus92 <92georgepetroff92@gmail.com> Date: Wed, 12 Aug 2026 18:02:00 +0300 Subject: [PATCH 6/6] fix(studio): export object-map scenarios (cherry picked from commit 065a407a5b512edd8db32450b36bf08cee24d1ca) --- apps/studio/src/lib/scenarios-api.test.ts | 23 ++++++++++++++++++++++- apps/studio/src/lib/scenarios-api.ts | 19 ++++++++++++++----- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/apps/studio/src/lib/scenarios-api.test.ts b/apps/studio/src/lib/scenarios-api.test.ts index fb3a47b..666fd66 100644 --- a/apps/studio/src/lib/scenarios-api.test.ts +++ b/apps/studio/src/lib/scenarios-api.test.ts @@ -325,7 +325,28 @@ describe('scenarioDownloadFile', () => { expect(scenarioDownloadFile(huge, 'big')!.contents).toContain('9223372036854775807'); }); - it('returns null for an unknown id, a non-array body, or invalid JSON', () => { + it('round-trips an object-map scenario without losing a large integer', () => { + const exact = '10103697788335729001'; + const objectMap = + `{"wanted":{"name":"Object map","tags":["pyth"],` + + `"overrides":[{"id":"ov","values":{"sqrt_price":${exact}}}]}}`; + + const file = scenarioDownloadFile(objectMap, 'wanted'); + expect(file).not.toBeNull(); + expect(file!.contents).toContain(`"id": "wanted"`); + expect(file!.contents).toContain(exact); + + const imported = scenarioImportPayload(file!.contents, 'fresh-id'); + expect('error' in imported).toBe(false); + const payload = (imported as { payload: string }).payload; + expect(payload).toContain(exact); + + const scenario = parseScenariosJson(payload) as Record; + expect(scenario.id).toBe('fresh-id'); + expect(scenario.tags).toEqual(['pyth']); + }); + + it('returns null for an unknown id, an invalid object body, or invalid JSON', () => { expect(scenarioDownloadFile(response, 'missing')).toBeNull(); expect(scenarioDownloadFile('{"id":"wanted"}', 'wanted')).toBeNull(); expect(scenarioDownloadFile('not json', 'wanted')).toBeNull(); diff --git a/apps/studio/src/lib/scenarios-api.ts b/apps/studio/src/lib/scenarios-api.ts index 0536de0..420ed48 100644 --- a/apps/studio/src/lib/scenarios-api.ts +++ b/apps/studio/src/lib/scenarios-api.ts @@ -93,11 +93,20 @@ export function scenarioDownloadFile( } catch { return null; } - if (!Array.isArray(scenarios)) return null; - - const scenario = scenarios.find((entry) => (entry as { id?: unknown })?.id === scenarioId) as - | Record - | undefined; + let scenario: Record | undefined; + if (Array.isArray(scenarios)) { + scenario = scenarios.find((entry) => (entry as { id?: unknown })?.id === scenarioId) as + | Record + | undefined; + } else if (scenarios !== null && typeof scenarios === 'object') { + const entry = Object.entries(scenarios as Record).find( + ([id, value]) => id === scenarioId || (value as { id?: unknown })?.id === scenarioId + ); + if (entry && entry[1] !== null && typeof entry[1] === 'object' && !Array.isArray(entry[1])) { + const value = entry[1] as Record; + scenario = typeof value.id === 'string' ? value : { ...value, id: entry[0] }; + } + } if (!scenario) return null; const name = typeof scenario.name === 'string' ? scenario.name : '';