Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions packages/insomnia-data/src/models/request-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ export interface BaseRequestMeta {
downloadPath: string | null;
expandedAccordionKeys: Partial<Record<RequestAccordionKeys, boolean>>;
activeMcpPrimitive?: string | null;
streamSummaryPath: string | null;
streamSummaryRenderMarkdown: boolean;
}

export type RequestMeta = BaseModel & BaseRequestMeta;
Expand All @@ -41,5 +43,7 @@ export function init() {
downloadPath: null,
expandedAccordionKeys: {},
activeMcpPrimitive: null,
streamSummaryPath: null,
streamSummaryRenderMarkdown: false,
};
}
163 changes: 163 additions & 0 deletions packages/insomnia/src/common/stream-summary.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest';

import {
candidateJsonPayloadsFromSseText,
computeStreamSummary,
extractStreamValueAtPath,
getCandidatePayloadsFromEvents,
inferStreamSummaryPath,
} from './stream-summary';

describe('extractStreamValueAtPath', () => {
it('extracts a string field', () => {
const payload = JSON.stringify({ choices: [{ delta: { content: 'hello' } }] });
expect(extractStreamValueAtPath(payload, '$.choices[0].delta.content')).toBe('hello');
});

it('stringifies a numeric field instead of dropping it', () => {
const payload = JSON.stringify({ value: 42 });
expect(extractStreamValueAtPath(payload, '$.value')).toBe('42');
});

it('stringifies an object field instead of dropping it', () => {
const payload = JSON.stringify({ value: { foo: 'bar' } });
expect(extractStreamValueAtPath(payload, '$.value')).toBe(JSON.stringify({ foo: 'bar' }));
});

it('joins array results', () => {
const payload = JSON.stringify({ items: ['a', 'b', 'c'] });
expect(extractStreamValueAtPath(payload, '$.items[*]')).toBe('abc');
});

it('returns null for invalid JSON payload', () => {
expect(extractStreamValueAtPath('not json', '$.value')).toBeNull();
});

it('returns null for invalid JSONPath', () => {
const payload = JSON.stringify({ value: 'hello' });
expect(extractStreamValueAtPath(payload, '$[?(]')).toBeNull();
});

it('returns null when the path matches nothing', () => {
const payload = JSON.stringify({ value: 'hello' });
expect(extractStreamValueAtPath(payload, '$.missing')).toBeNull();
});
});

describe('computeStreamSummary', () => {
it('joins multiple payloads and reports fragmentCount', () => {
const payloads = [
JSON.stringify({ choices: [{ delta: { content: 'Hel' } }] }),
JSON.stringify({ choices: [{ delta: { content: 'lo ' } }] }),
JSON.stringify({ choices: [{ delta: {} }] }),
JSON.stringify({ choices: [{ delta: { content: 'world' } }] }),
];
const result = computeStreamSummary(payloads, '$.choices[0].delta.content');
expect(result).toEqual({ fragmentCount: 3, summary: 'Hello world' });
});
});

describe('inferStreamSummaryPath', () => {
it('matches OpenAI chat completions', () => {
expect(inferStreamSummaryPath('https://api.openai.com/v1/chat/completions')).toBe('$.choices[0].delta.content');
});

it('matches OpenAI responses API on a proxy host', () => {
expect(inferStreamSummaryPath('https://my-proxy.example.com/v1/responses')).toBe('$.delta');
});

it('matches Anthropic messages API', () => {
expect(inferStreamSummaryPath('https://api.anthropic.com/v1/messages')).toBe('$.delta.text');
});

it('matches Google Gemini streamGenerateContent', () => {
expect(
inferStreamSummaryPath('https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:streamGenerateContent'),
).toBe('$.candidates[0].content.parts[0].text');
});

it('returns null when no pathname matches', () => {
expect(inferStreamSummaryPath('https://example.com/v1/unknown')).toBeNull();
});

it('returns null for an invalid URL', () => {
expect(inferStreamSummaryPath('not a url')).toBeNull();
});
});

describe('candidateJsonPayloadsFromSseText', () => {
it('extracts a single data frame', () => {
const text = 'data: {"choices":[{"delta":{"content":"hi"}}]}\n\n';
expect(candidateJsonPayloadsFromSseText(text)).toEqual([
'{"choices":[{"delta":{"content":"hi"}}]}',
]);
});

it('extracts multiple frames separated by blank lines', () => {
const text = 'data: {"a":1}\n\ndata: {"b":2}\n\n';
expect(candidateJsonPayloadsFromSseText(text)).toEqual([
'{"a":1}',
'{"b":2}',
]);
});

it('extracts a frame with event:/id: fields mixed in', () => {
const text = 'event: message\nid: 42\ndata: {"a":1}\n\n';
expect(candidateJsonPayloadsFromSseText(text)).toEqual(['{"a":1}']);
});

it('joins a multi-line data: continuation frame', () => {
const text = 'data: {"a":1,\ndata: "b":2}\n\n';
expect(candidateJsonPayloadsFromSseText(text)).toEqual(['{"a":1,\n"b":2}']);
});

it('falls back to a bare JSON blob with no data: prefix', () => {
const text = '{"a":1}';
expect(candidateJsonPayloadsFromSseText(text)).toEqual(['{"a":1}']);
});
});

describe('getCandidatePayloadsFromEvents', () => {
it('reconstructs curl SSE chunks in chronological order given a newest-first input array', () => {
const chunkA = 'data: {"choices":[{"delta":{"content":"Hel"}}]}\n\n';
const chunkB = 'data: {"choices":[{"delta":{"content":"lo"}}]}\n\n';
// findMany() returns newest-first, so the true arrival order (A then B) appears reversed here.
const newestFirst = [
{ type: 'message', direction: 'INCOMING', data: chunkB },
{ type: 'message', direction: 'INCOMING', data: chunkA },
];
expect(getCandidatePayloadsFromEvents(newestFirst, 'curl')).toEqual([
'{"choices":[{"delta":{"content":"Hel"}}]}',
'{"choices":[{"delta":{"content":"lo"}}]}',
]);
});

it('reconstructs curl SSE frames split across multiple chunks, given a newest-first input array', () => {
// True arrival order is 'data: {"a"' then ':1}\n\n'; findMany() returns newest-first.
const newestFirst = [
{ type: 'message', direction: 'INCOMING', data: ':1}\n\n' },
{ type: 'message', direction: 'INCOMING', data: 'data: {"a"' },
];
expect(getCandidatePayloadsFromEvents(newestFirst, 'curl')).toEqual(['{"a":1}']);
});

it('orders WebSocket messages chronologically given a newest-first input array', () => {
const newestFirst = [
{ type: 'message', direction: 'INCOMING', data: '{"delta":"lo"}' },
{ type: 'message', direction: 'INCOMING', data: '{"delta":"Hel"}' },
];
expect(getCandidatePayloadsFromEvents(newestFirst, 'webSocket')).toEqual([
'{"delta":"Hel"}',
'{"delta":"lo"}',
]);
});

it('ignores outgoing and non-message events', () => {
const events = [
{ type: 'message', direction: 'INCOMING', data: '{"delta":"kept"}' },
{ type: 'message', direction: 'OUTGOING', data: '{"delta":"ignored"}' },
{ type: 'open', direction: '', data: '' },
];
expect(getCandidatePayloadsFromEvents(events, 'webSocket')).toEqual(['{"delta":"kept"}']);
});
});
147 changes: 147 additions & 0 deletions packages/insomnia/src/common/stream-summary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import { JSONPath } from 'jsonpath-plus';

const stringifyValue = (value: unknown): string | null => {
if (value === null || value === undefined) {
return null;
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
return String(value);
}
return JSON.stringify(value);
};

export const extractStreamValueAtPath = (payload: string, keyPath: string): string | null => {
try {
const parsed = JSON.parse(payload);
const path = keyPath.trim();
const result = JSONPath({ path, json: parsed });
if (Array.isArray(result)) {
// An empty array means the path matched nothing at all (as opposed to matching a
// field whose value happens to be an empty string), so it's treated the same as
// a null/undefined result rather than joined into an empty-but-present fragment.
if (result.length === 0) {
return null;
}
return result
.map(stringifyValue)
.filter((value): value is string => value !== null)
.join('');
}
return stringifyValue(result);
} catch {
return null;
}
};

export const computeStreamSummary = (
payloads: string[],
keyPath: string,
): { fragmentCount: number; summary: string } => {
const fragments = payloads
.map(payload => extractStreamValueAtPath(payload, keyPath))
.filter((value): value is string => value !== null);
return { fragmentCount: fragments.length, summary: fragments.join('') };
};

const STANDARD_SSE_FIELD = /^(event|id|retry):/i;

export function candidateJsonPayloadsFromSseText(text: string): string[] {
const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
const blocks = normalized.split(/\n{2,}/);
const candidates: string[] = [];

for (const block of blocks) {
const lines = block.split('\n');
const dataLines = lines
.map(line => /^data:(?: ?)(.*)$/.exec(line)?.[1])
.filter((line): line is string => line != null);

if (dataLines.length > 0) {
const payload = dataLines.join('\n').trim();
if (payload) {
candidates.push(payload);
}
continue;
}

const trimmedBlock = block.trim();
if (!trimmedBlock) {
continue;
}

if (isParsableJson(trimmedBlock)) {
candidates.push(trimmedBlock);
continue;
}

for (const line of lines) {
const trimmedLine = line.trim();
if (
!trimmedLine ||
trimmedLine.startsWith(':') ||
STANDARD_SSE_FIELD.test(trimmedLine) ||
!isParsableJson(trimmedLine)
) {
continue;
}
candidates.push(trimmedLine);
}
}

return candidates;
}

function isParsableJson(value: string): boolean {
try {
JSON.parse(value);
return true;
} catch {
return false;
}
}

export interface StreamMessageEvent {
type: string;
direction: string;
data: string;
}

export function getCandidatePayloadsFromEvents(events: StreamMessageEvent[], protocol: 'curl' | 'webSocket'): string[] {
// Both curl's and websocket's findMany() reverse the event log for "latest event
// first" display, so undo that exact reversal here (rather than sort by timestamp,
// which has only millisecond resolution and can't disambiguate SSE chunks that arrive
// within the same millisecond) to get back true chronological order.
const incoming = events.filter(event => event.type === 'message' && event.direction === 'INCOMING').reverse();

if (protocol === 'curl') {
return candidateJsonPayloadsFromSseText(incoming.map(event => event.data).join(''));
}
return incoming.map(event => event.data);
}

const PATH_TO_JSONPATH: { pathname: string; jsonPath: string }[] = [
// OpenAI: Chat Completions API
{ pathname: '/v1/chat/completions', jsonPath: '$.choices[0].delta.content' },
// OpenAI: Completions API, Legacy
{ pathname: '/v1/completions', jsonPath: '$.choices[0].text' },
// OpenAI Responses API
{ pathname: '/v1/responses', jsonPath: '$.delta' },
// Anthropic Claude Messages API
{ pathname: '/v1/messages', jsonPath: '$.delta.text' },
];

export const inferStreamSummaryPath = (url: string): string | null => {
try {
const { pathname } = new URL(url);
if (pathname.toLowerCase().includes(':streamgeneratecontent')) {
return '$.candidates[0].content.parts[0].text';
}
const match = PATH_TO_JSONPATH.find(entry => entry.pathname === pathname);
return match ? match.jsonPath : null;
} catch {
return null;
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export const MessageEventView: FC<Props<CurlMessageEvent | WebSocketMessageEvent
}}
/>
</div>
<div className="grow p-4">
<div className="grow p-4 pb-0">

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

To fix a ui issue.

Image

<CodeEditor
id="websocket-body-preview"
hideLineNumbers
Expand Down
Loading
Loading