Initial import: open-design source for helix-mind.ai distribution
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
github-metrics / Generate repository metrics SVG (push) Failing after 2m6s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 12s
Some checks failed
ci / Validate workspace (push) Successful in 12m32s
landing-page-ci / Validate landing page (push) Successful in 9m41s
landing-page-deploy / Deploy landing page (push) Failing after 5m23s
github-metrics / Generate repository metrics SVG (push) Failing after 2m6s
refresh-contributors-wall / Refresh contributors wall cache bust (push) Failing after 12s
This repository contains the open-design daemon CLI source code, built and packaged at https://helix-mind.ai/cli/open-design/latest.tgz for use by the HelixMind /design slash command. Licenses: Apache-2.0 (root) + MIT (skills/*)
This commit is contained in:
27
apps/web/tests/providers/openai-compatible.test.ts
Normal file
27
apps/web/tests/providers/openai-compatible.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isOpenAICompatible } from '../../src/providers/openai-compatible';
|
||||
|
||||
describe('isOpenAICompatible', () => {
|
||||
it('preserves explicit OpenAI model routing when the URL contains anthropic', () => {
|
||||
expect(isOpenAICompatible('gpt-4o', 'https://anthropic-gateway.example.com/v1')).toBe(true);
|
||||
expect(isOpenAICompatible('gpt-4o', 'https://api.example.com/anthropic-named/chat/v1')).toBe(true);
|
||||
});
|
||||
|
||||
it('routes MiMo Anthropic-compatible endpoints away from OpenAI-compatible chat completions', () => {
|
||||
expect(isOpenAICompatible('mimo-v2.5-pro', 'https://token-plan-cn.xiaomimimo.com/anthropic')).toBe(false);
|
||||
expect(isOpenAICompatible('mimo-v2.5-pro', 'https://token-plan-cn.xiaomimimo.com/anthropic/v1')).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves MiMo OpenAI-compatible endpoint routing', () => {
|
||||
expect(isOpenAICompatible('mimo-v2.5-pro', 'https://token-plan-cn.xiaomimimo.com/v1')).toBe(true);
|
||||
});
|
||||
|
||||
it('routes MiniMax Anthropic endpoint paths away from OpenAI-compatible chat completions', () => {
|
||||
expect(isOpenAICompatible('MiniMax-M2.7-highspeed', 'https://api.minimaxi.com/v1/anthropic')).toBe(false);
|
||||
expect(isOpenAICompatible('MiniMax-M2.7-highspeed', 'https://api.minimaxi.com/anthropic/v1')).toBe(false);
|
||||
});
|
||||
|
||||
it('lets explicit OpenAI models win when only the host name contains anthropic', () => {
|
||||
expect(isOpenAICompatible('gpt-4o', 'https://anthropic-proxy.example.com/v1')).toBe(true);
|
||||
});
|
||||
});
|
||||
266
apps/web/tests/providers/project-events.test.ts
Normal file
266
apps/web/tests/providers/project-events.test.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
createProjectEventsConnection,
|
||||
projectEventsUrl,
|
||||
type ProjectEvent,
|
||||
} from '../../src/providers/project-events';
|
||||
|
||||
type Listener = (evt: unknown) => void;
|
||||
|
||||
class MockEventSource {
|
||||
static instances: MockEventSource[] = [];
|
||||
url: string;
|
||||
listeners: Map<string, Set<Listener>> = new Map();
|
||||
closed = false;
|
||||
constructor(url: string) {
|
||||
this.url = url;
|
||||
MockEventSource.instances.push(this);
|
||||
}
|
||||
addEventListener(name: string, cb: Listener): void {
|
||||
if (!this.listeners.has(name)) this.listeners.set(name, new Set());
|
||||
this.listeners.get(name)!.add(cb);
|
||||
}
|
||||
removeEventListener(name: string, cb: Listener): void {
|
||||
this.listeners.get(name)?.delete(cb);
|
||||
}
|
||||
dispatch(name: string, evt: unknown): void {
|
||||
for (const cb of this.listeners.get(name) ?? []) cb(evt);
|
||||
}
|
||||
close(): void {
|
||||
this.closed = true;
|
||||
}
|
||||
// EventSource type compat
|
||||
get readyState(): number { return this.closed ? 2 : 1; }
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
MockEventSource.instances = [];
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('projectEventsUrl', () => {
|
||||
it('encodes project id segment', () => {
|
||||
expect(projectEventsUrl('818cf7a8-839/9'))
|
||||
.toBe('/api/projects/818cf7a8-839%2F9/events');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createProjectEventsConnection', () => {
|
||||
it('opens an EventSource against the events URL on creation', () => {
|
||||
const conn = createProjectEventsConnection(
|
||||
'p1',
|
||||
() => {},
|
||||
{ EventSourceCtor: MockEventSource as unknown as typeof EventSource },
|
||||
);
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
expect(MockEventSource.instances[0]!.url).toBe('/api/projects/p1/events');
|
||||
conn.close();
|
||||
});
|
||||
|
||||
it('invokes onChange with parsed payload on file-changed events', () => {
|
||||
const seen: ProjectEvent[] = [];
|
||||
const conn = createProjectEventsConnection(
|
||||
'p1',
|
||||
(evt) => seen.push(evt),
|
||||
{ EventSourceCtor: MockEventSource as unknown as typeof EventSource },
|
||||
);
|
||||
const es = MockEventSource.instances[0]!;
|
||||
es.dispatch('file-changed', {
|
||||
data: JSON.stringify({ type: 'file-changed', path: 'a.html', kind: 'change' }),
|
||||
});
|
||||
es.dispatch('file-changed', {
|
||||
data: JSON.stringify({ type: 'file-changed', path: 'b.css', kind: 'add' }),
|
||||
});
|
||||
expect(seen).toEqual([
|
||||
{ type: 'file-changed', path: 'a.html', kind: 'change' },
|
||||
{ type: 'file-changed', path: 'b.css', kind: 'add' },
|
||||
]);
|
||||
conn.close();
|
||||
});
|
||||
|
||||
it('ignores malformed payloads instead of throwing', () => {
|
||||
const seen: ProjectEvent[] = [];
|
||||
const conn = createProjectEventsConnection(
|
||||
'p1',
|
||||
(evt) => seen.push(evt),
|
||||
{ EventSourceCtor: MockEventSource as unknown as typeof EventSource },
|
||||
);
|
||||
const es = MockEventSource.instances[0]!;
|
||||
expect(() => es.dispatch('file-changed', { data: '{not-json' })).not.toThrow();
|
||||
expect(seen).toEqual([]);
|
||||
conn.close();
|
||||
});
|
||||
|
||||
it('parses live_artifact events', () => {
|
||||
const seen: ProjectEvent[] = [];
|
||||
const conn = createProjectEventsConnection(
|
||||
'p1',
|
||||
(evt) => seen.push(evt),
|
||||
{ EventSourceCtor: MockEventSource as unknown as typeof EventSource },
|
||||
);
|
||||
const es = MockEventSource.instances[0]!;
|
||||
es.dispatch('live_artifact', {
|
||||
data: JSON.stringify({
|
||||
type: 'live_artifact',
|
||||
action: 'updated',
|
||||
projectId: 'p1',
|
||||
artifactId: 'artifact-1',
|
||||
title: 'Status Board',
|
||||
refreshStatus: 'running',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
type: 'live_artifact',
|
||||
action: 'updated',
|
||||
projectId: 'p1',
|
||||
artifactId: 'artifact-1',
|
||||
title: 'Status Board',
|
||||
refreshStatus: 'running',
|
||||
},
|
||||
]);
|
||||
conn.close();
|
||||
});
|
||||
|
||||
it('parses live_artifact_refresh events', () => {
|
||||
const seen: ProjectEvent[] = [];
|
||||
const conn = createProjectEventsConnection(
|
||||
'p1',
|
||||
(evt) => seen.push(evt),
|
||||
{ EventSourceCtor: MockEventSource as unknown as typeof EventSource },
|
||||
);
|
||||
const es = MockEventSource.instances[0]!;
|
||||
es.dispatch('live_artifact_refresh', {
|
||||
data: JSON.stringify({
|
||||
type: 'live_artifact_refresh',
|
||||
phase: 'succeeded',
|
||||
projectId: 'p1',
|
||||
artifactId: 'artifact-1',
|
||||
refreshId: 'refresh-000001',
|
||||
title: 'Status Board',
|
||||
refreshedSourceCount: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(seen).toEqual([
|
||||
{
|
||||
type: 'live_artifact_refresh',
|
||||
phase: 'succeeded',
|
||||
projectId: 'p1',
|
||||
artifactId: 'artifact-1',
|
||||
refreshId: 'refresh-000001',
|
||||
title: 'Status Board',
|
||||
refreshedSourceCount: 1,
|
||||
},
|
||||
]);
|
||||
conn.close();
|
||||
});
|
||||
|
||||
it('reconnects with exponential backoff on error', () => {
|
||||
let nextDelay = 0;
|
||||
const setTimeoutFn = vi.fn((cb: () => void, ms: number) => {
|
||||
nextDelay = ms;
|
||||
cb();
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
});
|
||||
const clearTimeoutFn = vi.fn();
|
||||
const conn = createProjectEventsConnection(
|
||||
'p1',
|
||||
() => {},
|
||||
{
|
||||
EventSourceCtor: MockEventSource as unknown as typeof EventSource,
|
||||
initialBackoffMs: 100,
|
||||
maxBackoffMs: 800,
|
||||
setTimeoutFn: setTimeoutFn as unknown as typeof setTimeout,
|
||||
clearTimeoutFn: clearTimeoutFn as unknown as typeof clearTimeout,
|
||||
},
|
||||
);
|
||||
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
MockEventSource.instances[0]!.dispatch('error', {});
|
||||
expect(nextDelay).toBe(100);
|
||||
expect(MockEventSource.instances).toHaveLength(2);
|
||||
|
||||
MockEventSource.instances[1]!.dispatch('error', {});
|
||||
expect(nextDelay).toBe(200);
|
||||
|
||||
MockEventSource.instances[2]!.dispatch('error', {});
|
||||
expect(nextDelay).toBe(400);
|
||||
|
||||
MockEventSource.instances[3]!.dispatch('error', {});
|
||||
expect(nextDelay).toBe(800);
|
||||
|
||||
MockEventSource.instances[4]!.dispatch('error', {});
|
||||
expect(nextDelay).toBe(800); // capped at maxBackoffMs
|
||||
|
||||
conn.close();
|
||||
});
|
||||
|
||||
it('resets backoff after a ready event', () => {
|
||||
let nextDelay = 0;
|
||||
const setTimeoutFn = vi.fn((cb: () => void, ms: number) => {
|
||||
nextDelay = ms;
|
||||
cb();
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
});
|
||||
const conn = createProjectEventsConnection(
|
||||
'p1',
|
||||
() => {},
|
||||
{
|
||||
EventSourceCtor: MockEventSource as unknown as typeof EventSource,
|
||||
initialBackoffMs: 100,
|
||||
setTimeoutFn: setTimeoutFn as unknown as typeof setTimeout,
|
||||
},
|
||||
);
|
||||
|
||||
MockEventSource.instances[0]!.dispatch('error', {});
|
||||
expect(nextDelay).toBe(100);
|
||||
MockEventSource.instances[1]!.dispatch('error', {});
|
||||
expect(nextDelay).toBe(200);
|
||||
// Ready arrives → reset
|
||||
MockEventSource.instances[2]!.dispatch('ready', { data: '{}' });
|
||||
MockEventSource.instances[2]!.dispatch('error', {});
|
||||
expect(nextDelay).toBe(100);
|
||||
|
||||
conn.close();
|
||||
});
|
||||
|
||||
it('close() prevents further reconnects and closes the active source', () => {
|
||||
let scheduled: (() => void) | null = null;
|
||||
const setTimeoutFn = vi.fn((cb: () => void) => {
|
||||
scheduled = cb;
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||
});
|
||||
const clearTimeoutFn = vi.fn();
|
||||
const conn = createProjectEventsConnection(
|
||||
'p1',
|
||||
() => {},
|
||||
{
|
||||
EventSourceCtor: MockEventSource as unknown as typeof EventSource,
|
||||
setTimeoutFn: setTimeoutFn as unknown as typeof setTimeout,
|
||||
clearTimeoutFn: clearTimeoutFn as unknown as typeof clearTimeout,
|
||||
},
|
||||
);
|
||||
|
||||
MockEventSource.instances[0]!.dispatch('error', {});
|
||||
expect(scheduled).toBeTypeOf('function');
|
||||
|
||||
conn.close();
|
||||
expect(clearTimeoutFn).toHaveBeenCalled();
|
||||
// even if a stale timer fired, the connect is a no-op
|
||||
(scheduled as (() => void) | null)?.();
|
||||
expect(MockEventSource.instances).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns a no-op connection when no EventSource constructor is available', () => {
|
||||
const conn = createProjectEventsConnection(
|
||||
'p1',
|
||||
() => {},
|
||||
{ EventSourceCtor: undefined },
|
||||
);
|
||||
expect(MockEventSource.instances).toHaveLength(0);
|
||||
expect(() => conn.close()).not.toThrow();
|
||||
});
|
||||
});
|
||||
190
apps/web/tests/providers/registry.test.ts
Normal file
190
apps/web/tests/providers/registry.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
fetchAppVersionInfo,
|
||||
fetchConnectorDiscovery,
|
||||
fetchProjectFileText,
|
||||
uploadProjectFiles,
|
||||
} from '../../src/providers/registry';
|
||||
|
||||
describe('fetchAppVersionInfo', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('returns version info from the daemon response', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(JSON.stringify({
|
||||
version: { version: '1.2.3', channel: 'beta', packaged: true, platform: 'darwin', arch: 'arm64' },
|
||||
}), { status: 200 })),
|
||||
);
|
||||
|
||||
await expect(fetchAppVersionInfo()).resolves.toEqual({
|
||||
version: '1.2.3',
|
||||
channel: 'beta',
|
||||
packaged: true,
|
||||
platform: 'darwin',
|
||||
arch: 'arm64',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when version info is unavailable or malformed', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(JSON.stringify({ version: { version: '1.2.3' } }), { status: 200 })),
|
||||
);
|
||||
|
||||
await expect(fetchAppVersionInfo()).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchProjectFileText', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('can bypass caches when fetching source text', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response('<svg />', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(
|
||||
fetchProjectFileText('project-1', 'diagram.svg', {
|
||||
cache: 'no-store',
|
||||
cacheBustKey: '1710000000-2',
|
||||
}),
|
||||
).resolves.toBe('<svg />');
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'/api/projects/project-1/raw/diagram.svg?cacheBust=1710000000-2',
|
||||
{ cache: 'no-store' },
|
||||
);
|
||||
});
|
||||
|
||||
it('logs HTTP failure context before returning null', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('missing', { status: 404, statusText: 'Not Found' })));
|
||||
|
||||
await expect(fetchProjectFileText('project-1', 'missing.svg')).resolves.toBeNull();
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[fetchProjectFileText] failed:',
|
||||
expect.objectContaining({
|
||||
name: 'missing.svg',
|
||||
projectId: 'project-1',
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
url: '/api/projects/project-1/raw/missing.svg',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('logs thrown fetch errors before returning null', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const error = new Error('network down');
|
||||
vi.stubGlobal('fetch', vi.fn(async () => {
|
||||
throw error;
|
||||
}));
|
||||
|
||||
await expect(fetchProjectFileText('project-1', 'diagram.svg')).resolves.toBeNull();
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'[fetchProjectFileText] failed:',
|
||||
expect.objectContaining({
|
||||
error,
|
||||
name: 'diagram.svg',
|
||||
projectId: 'project-1',
|
||||
url: '/api/projects/project-1/raw/diagram.svg',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchConnectorDiscovery', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('caches connector discovery after a successful fetch', async () => {
|
||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({
|
||||
connectors: [{ id: 'github', name: 'GitHub', tools: [{ name: 'issues' }] }],
|
||||
}), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await expect(fetchConnectorDiscovery({ refresh: true })).resolves.toEqual([
|
||||
{ id: 'github', name: 'GitHub', tools: [{ name: 'issues' }] },
|
||||
]);
|
||||
await expect(fetchConnectorDiscovery()).resolves.toEqual([
|
||||
{ id: 'github', name: 'GitHub', tools: [{ name: 'issues' }] },
|
||||
]);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/connectors/discovery?refresh=true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadProjectFiles', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('treats every response entry as a success regardless of originalName drift', async () => {
|
||||
// Simulates an encoding edge case: the browser File.name carries a
|
||||
// composed CJK name (NFC) but multer round-trips it through latin1 and
|
||||
// returns a slightly different decoded form. The old name-equality
|
||||
// matching marked these as failed even though the server stored them.
|
||||
const composed = '测试.pdf';
|
||||
const decomposed = '测试.pdf'; // pretend the server returned a normalized variant
|
||||
const file = new File(['hello'], composed, { type: 'application/pdf' });
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(JSON.stringify({
|
||||
files: [
|
||||
{
|
||||
name: 'mxk7-test.pdf',
|
||||
path: 'mxk7-test.pdf',
|
||||
size: 5,
|
||||
originalName: decomposed,
|
||||
},
|
||||
],
|
||||
}), { status: 200 })),
|
||||
);
|
||||
|
||||
const result = await uploadProjectFiles('project-1', [file]);
|
||||
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(result.uploaded).toHaveLength(1);
|
||||
expect(result.uploaded[0]).toMatchObject({
|
||||
path: 'mxk7-test.pdf',
|
||||
name: decomposed,
|
||||
size: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the unmatched tail as failed when the server drops files mid-flight', async () => {
|
||||
const a = new File(['a'], 'a.txt', { type: 'text/plain' });
|
||||
const b = new File(['b'], 'b.txt', { type: 'text/plain' });
|
||||
const c = new File(['c'], 'c.txt', { type: 'text/plain' });
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () => new Response(JSON.stringify({
|
||||
files: [
|
||||
{ name: 't1-a.txt', path: 't1-a.txt', size: 1, originalName: 'a.txt' },
|
||||
{ name: 't2-b.txt', path: 't2-b.txt', size: 1, originalName: 'b.txt' },
|
||||
],
|
||||
}), { status: 200 })),
|
||||
);
|
||||
|
||||
const result = await uploadProjectFiles('project-1', [a, b, c]);
|
||||
|
||||
expect(result.uploaded).toHaveLength(2);
|
||||
expect(result.failed).toHaveLength(1);
|
||||
expect(result.failed[0]).toMatchObject({ name: 'c.txt' });
|
||||
});
|
||||
});
|
||||
590
apps/web/tests/providers/sse.test.ts
Normal file
590
apps/web/tests/providers/sse.test.ts
Normal file
@@ -0,0 +1,590 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { reattachDaemonRun, streamViaDaemon } from '../../src/providers/daemon';
|
||||
import { streamMessageOpenAI } from '../../src/providers/openai-compatible';
|
||||
import { parseSseFrame } from '../../src/providers/sse';
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('parseSseFrame', () => {
|
||||
it('parses JSON event frames', () => {
|
||||
expect(parseSseFrame('id: 12\nevent: stdout\ndata: {"chunk":"hello"}')).toEqual({
|
||||
kind: 'event',
|
||||
id: '12',
|
||||
event: 'stdout',
|
||||
data: { chunk: 'hello' },
|
||||
});
|
||||
});
|
||||
|
||||
it('parses SSE comment frames', () => {
|
||||
expect(parseSseFrame(': keepalive')).toEqual({
|
||||
kind: 'comment',
|
||||
comment: 'keepalive',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty for frames without data or comments', () => {
|
||||
expect(parseSseFrame('')).toEqual({ kind: 'empty' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamViaDaemon', () => {
|
||||
it('ignores comment frames without notifying handlers', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
vi.stubGlobal('fetch', vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ runId: 'run-1' }))
|
||||
.mockResolvedValueOnce(sseResponse(': keepalive\n\nevent: end\ndata: {"code":0,"status":"succeeded"}\n\n')));
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(handlers.onDelta).not.toHaveBeenCalled();
|
||||
expect(handlers.onError).not.toHaveBeenCalled();
|
||||
expect(handlers.onAgentEvent).not.toHaveBeenCalled();
|
||||
expect(handlers.onDone).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('continues normal stdout and end handling around comments', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ runId: 'run-1' }))
|
||||
.mockResolvedValueOnce(
|
||||
sseResponse(
|
||||
[
|
||||
': keepalive',
|
||||
'',
|
||||
'event: start',
|
||||
'data: {"bin":"mock-agent"}',
|
||||
'',
|
||||
'event: stdout',
|
||||
'data: {"chunk":"hello"}',
|
||||
'',
|
||||
': keepalive',
|
||||
'',
|
||||
'event: end',
|
||||
'data: {"code":0}',
|
||||
'',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(handlers.onDelta).toHaveBeenCalledWith('hello');
|
||||
expect(handlers.onError).not.toHaveBeenCalled();
|
||||
expect(handlers.onDone).toHaveBeenCalledWith('hello');
|
||||
});
|
||||
|
||||
it('reads unified SSE error payload messages', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ runId: 'run-1' }))
|
||||
.mockResolvedValueOnce(
|
||||
sseResponse(
|
||||
[
|
||||
'event: error',
|
||||
'data: {"message":"legacy message","error":{"code":"AGENT_UNAVAILABLE","message":"typed message"}}',
|
||||
'',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(handlers.onError).toHaveBeenCalledWith(new Error('typed message'));
|
||||
expect(handlers.onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the daemon run alive when the browser-side stream aborts', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const controller = new AbortController();
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/runs') return jsonResponse({ runId: 'run-1' });
|
||||
if (url === '/api/runs/run-1/events') {
|
||||
controller.abort();
|
||||
throw new DOMException('aborted', 'AbortError');
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: controller.signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalledWith('/api/runs/run-1/cancel', { method: 'POST' });
|
||||
expect(handlers.onDone).not.toHaveBeenCalled();
|
||||
expect(handlers.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels the daemon run when the explicit cancel signal aborts', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const streamController = new AbortController();
|
||||
const cancelController = new AbortController();
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/runs') return jsonResponse({ runId: 'run-1' });
|
||||
if (url === '/api/runs/run-1/cancel') return jsonResponse({ ok: true });
|
||||
if (url === '/api/runs/run-1/events') {
|
||||
cancelController.abort();
|
||||
streamController.abort();
|
||||
throw new DOMException('aborted', 'AbortError');
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: streamController.signal,
|
||||
cancelSignal: cancelController.signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/runs', expect.objectContaining({
|
||||
method: 'POST',
|
||||
}));
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/runs/run-1/events', {
|
||||
method: 'GET',
|
||||
signal: streamController.signal,
|
||||
});
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(3, '/api/runs/run-1/cancel', { method: 'POST' });
|
||||
expect(handlers.onDone).not.toHaveBeenCalled();
|
||||
expect(handlers.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the create-run request alive across browser-side stream aborts', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const controller = new AbortController();
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/runs') {
|
||||
controller.abort();
|
||||
return jsonResponse({ runId: 'run-1' });
|
||||
}
|
||||
if (url === '/api/runs/run-1/events') throw new DOMException('aborted', 'AbortError');
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: controller.signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/runs', expect.objectContaining({
|
||||
method: 'POST',
|
||||
}));
|
||||
expect(handlers.onDone).not.toHaveBeenCalled();
|
||||
expect(handlers.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels an accepted daemon run when explicit cancel happens during create-run', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const streamController = new AbortController();
|
||||
const cancelController = new AbortController();
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/runs') {
|
||||
cancelController.abort();
|
||||
streamController.abort();
|
||||
return jsonResponse({ runId: 'run-1' });
|
||||
}
|
||||
if (url === '/api/runs/run-1/cancel') return jsonResponse({ ok: true });
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: streamController.signal,
|
||||
cancelSignal: cancelController.signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/runs', expect.objectContaining({ method: 'POST' }));
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(2, '/api/runs/run-1/cancel', { method: 'POST' });
|
||||
expect(handlers.onDone).not.toHaveBeenCalled();
|
||||
expect(handlers.onError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks create-run HTTP failures as failed', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const onRunStatus = vi.fn();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response('down', { status: 503 })));
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
onRunStatus,
|
||||
});
|
||||
|
||||
expect(onRunStatus).toHaveBeenCalledWith('failed');
|
||||
expect(handlers.onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'daemon 503: down' }));
|
||||
expect(handlers.onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks invalid create-run JSON as failed', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const onRunStatus = vi.fn();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response('not json', { status: 202 })));
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
onRunStatus,
|
||||
});
|
||||
|
||||
expect(onRunStatus).toHaveBeenCalledWith('failed');
|
||||
expect(handlers.onError).toHaveBeenCalledWith(expect.any(Error));
|
||||
expect(handlers.onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reconnects to a daemon run after an incomplete stream closes', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ runId: 'run-1' }))
|
||||
.mockResolvedValueOnce(sseResponse('id: 1\nevent: stdout\ndata: {"chunk":"he"}\n\n'))
|
||||
.mockResolvedValueOnce(sseResponse('id: 2\nevent: stdout\ndata: {"chunk":"llo"}\n\nid: 3\nevent: end\ndata: {"code":0,"status":"succeeded"}\n\n'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/runs/run-1/events?after=1', {
|
||||
method: 'GET',
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(handlers.onDone).toHaveBeenCalledWith('hello');
|
||||
});
|
||||
|
||||
it('posts run correlation fields and reports run metadata callbacks', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ runId: 'run-1' }))
|
||||
.mockResolvedValueOnce(sseResponse('id: 4\nevent: start\ndata: {"bin":"mock-agent"}\n\nid: 5\nevent: end\ndata: {"code":0,"status":"succeeded"}\n\n'));
|
||||
const onRunCreated = vi.fn();
|
||||
const onRunStatus = vi.fn();
|
||||
const onRunEventId = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
projectId: 'project-1',
|
||||
conversationId: 'conversation-1',
|
||||
assistantMessageId: 'assistant-1',
|
||||
clientRequestId: 'client-1',
|
||||
onRunCreated,
|
||||
onRunStatus,
|
||||
onRunEventId,
|
||||
});
|
||||
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[0]![1]!.body))).toMatchObject({
|
||||
projectId: 'project-1',
|
||||
conversationId: 'conversation-1',
|
||||
assistantMessageId: 'assistant-1',
|
||||
clientRequestId: 'client-1',
|
||||
});
|
||||
expect(onRunCreated).toHaveBeenCalledWith('run-1');
|
||||
expect(onRunStatus).toHaveBeenCalledWith('queued');
|
||||
expect(onRunStatus).toHaveBeenCalledWith('running');
|
||||
expect(onRunStatus).toHaveBeenCalledWith('succeeded');
|
||||
expect(onRunEventId).toHaveBeenCalledWith('4');
|
||||
expect(onRunEventId).toHaveBeenCalledWith('5');
|
||||
});
|
||||
|
||||
it('reattaches to an existing daemon run after the last stored event id', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(sseResponse('id: 8\nevent: stdout\ndata: {"chunk":"lo"}\n\nid: 9\nevent: end\ndata: {"code":0,"status":"succeeded"}\n\n'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await reattachDaemonRun({
|
||||
runId: 'run-1',
|
||||
signal: new AbortController().signal,
|
||||
initialLastEventId: '7',
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/runs/run-1/events?after=7', {
|
||||
method: 'GET',
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(handlers.onDelta).toHaveBeenCalledWith('lo');
|
||||
expect(handlers.onDone).toHaveBeenCalledWith('lo');
|
||||
});
|
||||
|
||||
it('keeps reconnecting when quiet resumed streams only receive keepalives', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({ runId: 'run-1' }))
|
||||
.mockResolvedValueOnce(sseResponse(': keepalive\n\n'))
|
||||
.mockResolvedValueOnce(sseResponse(': keepalive\n\n'))
|
||||
.mockResolvedValueOnce(sseResponse(': keepalive\n\n'))
|
||||
.mockResolvedValueOnce(sseResponse(': keepalive\n\n'))
|
||||
.mockResolvedValueOnce(sseResponse(': keepalive\n\n'))
|
||||
.mockResolvedValueOnce(sseResponse('event: end\ndata: {"code":0,"status":"succeeded"}\n\n'));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(7);
|
||||
expect(handlers.onError).not.toHaveBeenCalled();
|
||||
expect(handlers.onDone).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('reports an error when reconnects are exhausted before an end event', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/runs') return jsonResponse({ runId: 'run-1' });
|
||||
if (url === '/api/runs/run-1/events') return sseResponse('');
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: 'hello' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalledWith('/api/runs/run-1/cancel', { method: 'POST' });
|
||||
expect(handlers.onError).toHaveBeenCalledWith(new Error('daemon stream disconnected before run completed'));
|
||||
expect(handlers.onDone).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('includes selected preview comments without requiring visible draft text', async () => {
|
||||
const handlers = createDaemonHandlers();
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === '/api/runs') return jsonResponse({ runId: 'run-1' });
|
||||
if (url === '/api/runs/run-1/events') {
|
||||
return sseResponse('event: end\ndata: {"code":0,"status":"succeeded"}\n\n');
|
||||
}
|
||||
throw new Error(`unexpected fetch ${url}`);
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await streamViaDaemon({
|
||||
agentId: 'mock',
|
||||
history: [{ id: '1', role: 'user', content: '' }],
|
||||
systemPrompt: '',
|
||||
signal: new AbortController().signal,
|
||||
handlers,
|
||||
commentAttachments: [
|
||||
{
|
||||
id: 'c1',
|
||||
order: 1,
|
||||
filePath: 'index.html',
|
||||
elementId: 'hero-title',
|
||||
selector: '[data-od-id="hero-title"]',
|
||||
label: 'h1.hero-title',
|
||||
comment: 'Shorten the headline',
|
||||
currentText: 'A very long headline',
|
||||
pagePosition: { x: 12, y: 44, width: 500, height: 60 },
|
||||
htmlHint: '<h1 data-od-id="hero-title">',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const [, createRunInit] = fetchMock.mock.calls[0] as unknown as [RequestInfo | URL, RequestInit];
|
||||
const body = JSON.parse(String(createRunInit.body));
|
||||
expect(body.message).toBe('## user\n');
|
||||
expect(body.commentAttachments).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'c1',
|
||||
elementId: 'hero-title',
|
||||
comment: 'Shorten the headline',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamMessageOpenAI', () => {
|
||||
it('ignores comments and keeps delta/end behavior unchanged', async () => {
|
||||
const handlers = createStreamHandlers();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async () =>
|
||||
sseResponse(
|
||||
[
|
||||
': keepalive',
|
||||
'',
|
||||
'event: delta',
|
||||
'data: {"text":"hi"}',
|
||||
'',
|
||||
': keepalive',
|
||||
'',
|
||||
'event: end',
|
||||
'data: {}',
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await streamMessageOpenAI(
|
||||
{
|
||||
mode: 'api',
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://example.test',
|
||||
model: 'gpt-test',
|
||||
agentId: null,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
},
|
||||
'',
|
||||
[{ id: '1', role: 'user', content: 'hello' }],
|
||||
new AbortController().signal,
|
||||
handlers,
|
||||
);
|
||||
|
||||
expect(handlers.onDelta).toHaveBeenCalledTimes(1);
|
||||
expect(handlers.onDelta).toHaveBeenCalledWith('hi');
|
||||
expect(handlers.onError).not.toHaveBeenCalled();
|
||||
expect(handlers.onDone).toHaveBeenCalledWith('hi');
|
||||
});
|
||||
|
||||
it('routes through the OpenAI-specific proxy endpoint and handles CRLF frames', async () => {
|
||||
const handlers = createStreamHandlers();
|
||||
const fetchMock = vi.fn(async () =>
|
||||
sseResponse(
|
||||
[
|
||||
'event: delta',
|
||||
'data: {"delta":"hi"}',
|
||||
'',
|
||||
'event: end',
|
||||
'data: {}',
|
||||
'',
|
||||
].join('\r\n'),
|
||||
),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
await streamMessageOpenAI(
|
||||
{
|
||||
mode: 'api',
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'https://example.test',
|
||||
model: 'gpt-test',
|
||||
agentId: null,
|
||||
skillId: null,
|
||||
designSystemId: null,
|
||||
},
|
||||
'',
|
||||
[{ id: '1', role: 'user', content: 'hello' }],
|
||||
new AbortController().signal,
|
||||
handlers,
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/proxy/openai/stream', expect.any(Object));
|
||||
expect(handlers.onDelta).toHaveBeenCalledWith('hi');
|
||||
expect(handlers.onDone).toHaveBeenCalledWith('hi');
|
||||
});
|
||||
});
|
||||
|
||||
function createStreamHandlers() {
|
||||
return {
|
||||
onDelta: vi.fn(),
|
||||
onDone: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createDaemonHandlers() {
|
||||
return {
|
||||
...createStreamHandlers(),
|
||||
onAgentEvent: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function sseResponse(text: string): Response {
|
||||
const encoder = new TextEncoder();
|
||||
return new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(text));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown): Response {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status: 202,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user