Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 2 additions & 17 deletions apps/studio/src/components/svm/explorer-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(() => {
Expand Down
29 changes: 4 additions & 25 deletions apps/studio/src/components/svm/scenario-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ interface ScenarioEditorProps {
scenarioId?: string;
scenarioName?: string;
scenarioDescription?: string;
scenarioTags?: string[];
initialSteps?: Array<{
id: string;
name: string;
Expand All @@ -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<string[]>([]);
const [mode, setMode] = useState<'read' | 'edit' | 'play'>('read');
const [searchQuery, setSearchQuery] = useState('');
const [actionSearchQuery, setActionSearchQuery] = useState('');
Expand All @@ -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;
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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(() => {
Expand Down
1 change: 1 addition & 0 deletions apps/studio/src/components/svm/scenarios-bento.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,7 @@ export default function ScenariosBento({
scenarioId={item.id}
scenarioName={item.name}
scenarioDescription={item.description}
scenarioTags={item.tags}
initialSteps={item.steps}
/>
</div>
Expand Down
1 change: 1 addition & 0 deletions apps/studio/src/components/svm/scenarios-bento.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface ScenarioBentoItem extends BentoItem {
created_at?: string;
updated_at?: string;
steps?: ScenarioStep[];
tags?: string[];
}

export interface ExampleScenario {
Expand Down
38 changes: 38 additions & 0 deletions apps/studio/src/lib/ai-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,42 @@ 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);
});

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);
});
});

Expand Down Expand Up @@ -518,6 +554,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);
});
});

Expand Down Expand Up @@ -563,5 +600,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);
});
});
14 changes: 13 additions & 1 deletion apps/studio/src/lib/ai-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,13 +662,25 @@ 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;
}
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;
Expand All @@ -680,6 +692,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 };
Expand Down Expand Up @@ -846,7 +859,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)
Expand Down
26 changes: 24 additions & 2 deletions apps/studio/src/lib/scenarios-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});

Expand Down Expand Up @@ -324,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<string, unknown>;
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();
Expand Down
20 changes: 15 additions & 5 deletions apps/studio/src/lib/scenarios-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>
| undefined;
let scenario: Record<string, unknown> | undefined;
if (Array.isArray(scenarios)) {
scenario = scenarios.find((entry) => (entry as { id?: unknown })?.id === scenarioId) as
| Record<string, unknown>
| undefined;
} else if (scenarios !== null && typeof scenarios === 'object') {
const entry = Object.entries(scenarios as Record<string, unknown>).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<string, unknown>;
scenario = typeof value.id === 'string' ? value : { ...value, id: entry[0] };
}
}
if (!scenario) return null;

const name = typeof scenario.name === 'string' ? scenario.name : '';
Expand Down Expand Up @@ -243,6 +252,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,
};
}
Expand Down
52 changes: 51 additions & 1 deletion apps/studio/src/lib/surfnet-clock.test.ts
Original file line number Diff line number Diff line change
@@ -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<Response> & { json?: () => Promise<unknown> }) => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, ...response });
Expand All @@ -9,6 +9,7 @@ const mockFetchResponse = (response: Partial<Response> & { json?: () => Promise<

describe('fetchSurfnetClockSeconds', () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});

Expand Down Expand Up @@ -52,4 +53,53 @@ 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();
});

it('does not start another poll while the current request is pending', async () => {
vi.useFakeTimers();
let resolveFirstRequest: ((response: Partial<Response>) => 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();
});
});
Loading