SnowLuma v2.4.2 source snapshot
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
This commit is contained in:
commit
fa9c73363d
1367 files changed
+355735
No files matched your search
@@ -0,0 +1,275 @@
|
||||
// Phase 1 — Stream API plumbing (#163). Verifies the dispatch seam:
|
||||
// defineStreamAction registers a streaming handler, processStreamRequest
|
||||
// emits N intermediate frames + a terminal frame (all echo-tagged, marked
|
||||
// `stream:'stream-action'`), normal actions stay single-frame, and failures
|
||||
// terminate with an error frame. Wire format mirrors NapCat's Stream API.
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { ApiHandler, ApiActionContext } from '../src/api-handler';
|
||||
import { defineAction, defineStreamAction, f, type RegisteredActionSpec } from '../src/action-kit';
|
||||
import { okResponse, failedResponse } from '../src/types';
|
||||
import { wrapStreamFrame, wrapStreamTerminal, STREAM_MARK } from '../src/streaming';
|
||||
import { createCompiledTestHandler } from './helpers/compiled-action-handler';
|
||||
|
||||
function newHandler(...specs: RegisteredActionSpec[]): ApiHandler {
|
||||
// ActionSpecs bind during the constructor but call ctx only when dispatched.
|
||||
return createCompiledTestHandler({} as ApiActionContext, specs);
|
||||
}
|
||||
|
||||
const parse = (frames: string[]) => frames.map((f) => JSON.parse(f) as Record<string, unknown>);
|
||||
|
||||
describe('Stream API plumbing', () => {
|
||||
it('streams N intermediate frames + a terminal frame, all echo-tagged', async () => {
|
||||
const action = defineStreamAction({
|
||||
name: 'demo_stream',
|
||||
params: { n: f.uint() },
|
||||
run: async (p, _ctx, sink) => {
|
||||
for (let i = 0; i < p.n; i++) {
|
||||
await sink.send({ type: 'stream', index: i });
|
||||
}
|
||||
|
||||
return okResponse({ type: 'response', total: p.n });
|
||||
},
|
||||
});
|
||||
const h = newHandler(action);
|
||||
|
||||
const frames: string[] = [];
|
||||
await h.processStreamRequest(
|
||||
JSON.stringify({ action: 'demo_stream', params: { n: 2 }, echo: 'e1' }),
|
||||
(j) => frames.push(j),
|
||||
);
|
||||
|
||||
const got = parse(frames);
|
||||
expect(got).toHaveLength(3);
|
||||
expect(got[0]).toMatchObject({
|
||||
status: 'ok',
|
||||
retcode: 0,
|
||||
stream: STREAM_MARK,
|
||||
echo: 'e1',
|
||||
data: { type: 'stream', index: 0 },
|
||||
});
|
||||
expect(got[1]).toMatchObject({
|
||||
stream: STREAM_MARK,
|
||||
echo: 'e1',
|
||||
data: { type: 'stream', index: 1 },
|
||||
});
|
||||
expect(got[2]).toMatchObject({
|
||||
status: 'ok',
|
||||
stream: STREAM_MARK,
|
||||
echo: 'e1',
|
||||
data: { type: 'response', total: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('marks the action as a stream action; normal actions are not', () => {
|
||||
const streamAction = defineStreamAction({
|
||||
name: 'demo_stream',
|
||||
params: {},
|
||||
run: () => okResponse({ type: 'response' }),
|
||||
});
|
||||
const normalAction = defineAction({
|
||||
name: 'demo_normal',
|
||||
params: {},
|
||||
run: () => okResponse({ ok: true }),
|
||||
});
|
||||
const h = newHandler(streamAction, normalAction);
|
||||
expect(h.isStreamAction('demo_stream')).toBe(true);
|
||||
expect(h.isStreamAction('demo_normal')).toBe(false);
|
||||
expect(h.isStreamAction('nope')).toBe(false);
|
||||
});
|
||||
|
||||
it('a normal action through processStreamRequest emits exactly one un-marked frame', async () => {
|
||||
const action = defineAction({
|
||||
name: 'demo_normal',
|
||||
params: {},
|
||||
run: () => okResponse({ ok: true }),
|
||||
});
|
||||
const h = newHandler(action);
|
||||
|
||||
const frames: string[] = [];
|
||||
await h.processStreamRequest(
|
||||
JSON.stringify({ action: 'demo_normal', params: {}, echo: 'e2' }),
|
||||
(j) => frames.push(j),
|
||||
);
|
||||
|
||||
expect(frames).toHaveLength(1);
|
||||
const r = parse(frames)[0];
|
||||
expect(r).toMatchObject({ status: 'ok', echo: 'e2', data: { ok: true } });
|
||||
expect(r.stream).toBeUndefined();
|
||||
});
|
||||
|
||||
it('a throwing stream action terminates with a single error frame', async () => {
|
||||
const action = defineStreamAction({
|
||||
name: 'demo_boom',
|
||||
params: {},
|
||||
run: async () => {
|
||||
throw new Error('boom');
|
||||
},
|
||||
});
|
||||
const h = newHandler(action);
|
||||
|
||||
const frames: string[] = [];
|
||||
await h.processStreamRequest(
|
||||
JSON.stringify({ action: 'demo_boom', params: {}, echo: 'e3' }),
|
||||
(j) => frames.push(j),
|
||||
);
|
||||
|
||||
expect(frames).toHaveLength(1);
|
||||
expect(parse(frames)[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
stream: STREAM_MARK,
|
||||
echo: 'e3',
|
||||
wording: 'boom',
|
||||
data: { type: 'error', data_type: 'error' },
|
||||
});
|
||||
});
|
||||
|
||||
it('emits already-sent frames before the terminal error when a stream throws mid-way', async () => {
|
||||
const action = defineStreamAction({
|
||||
name: 'demo_partial',
|
||||
params: {},
|
||||
run: async (_p, _ctx, sink) => {
|
||||
await sink.send({ type: 'stream', index: 0 });
|
||||
throw new Error('mid');
|
||||
},
|
||||
});
|
||||
const h = newHandler(action);
|
||||
|
||||
const frames: string[] = [];
|
||||
await h.processStreamRequest(JSON.stringify({ action: 'demo_partial', params: {} }), (j) =>
|
||||
frames.push(j),
|
||||
);
|
||||
|
||||
const got = parse(frames);
|
||||
expect(got).toHaveLength(2);
|
||||
expect(got[0]).toMatchObject({ stream: STREAM_MARK, data: { type: 'stream', index: 0 } });
|
||||
expect(got[1]).toMatchObject({ status: 'failed', data: { type: 'error' } });
|
||||
});
|
||||
|
||||
it('stream param validation fails terminate with an error frame', async () => {
|
||||
const action = defineStreamAction({
|
||||
name: 'demo_needs',
|
||||
params: { n: f.uint() },
|
||||
run: () => okResponse({ type: 'response' }),
|
||||
});
|
||||
const h = newHandler(action);
|
||||
|
||||
const frames: string[] = [];
|
||||
await h.processStreamRequest(JSON.stringify({ action: 'demo_needs', params: {} }), (j) =>
|
||||
frames.push(j),
|
||||
);
|
||||
expect(frames).toHaveLength(1);
|
||||
expect(parse(frames)[0]).toMatchObject({
|
||||
status: 'failed',
|
||||
stream: STREAM_MARK,
|
||||
data: { type: 'error' },
|
||||
});
|
||||
});
|
||||
|
||||
it('aborts a stream when isAlive turns false (client disconnect)', async () => {
|
||||
let sent = 0;
|
||||
const action = defineStreamAction({
|
||||
name: 'demo_abort',
|
||||
params: {},
|
||||
run: async (_p, _ctx, sink) => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await sink.send({ type: 'stream', index: i });
|
||||
sent++;
|
||||
}
|
||||
|
||||
return okResponse({ type: 'response' });
|
||||
},
|
||||
});
|
||||
const h = newHandler(action);
|
||||
|
||||
const frames: string[] = [];
|
||||
let alive = true;
|
||||
await h.processStreamRequest(
|
||||
JSON.stringify({ action: 'demo_abort', params: {} }),
|
||||
(j) => {
|
||||
frames.push(j);
|
||||
|
||||
if (frames.length >= 2) {
|
||||
alive = false;
|
||||
}
|
||||
},
|
||||
() => alive,
|
||||
);
|
||||
|
||||
expect(sent).toBeLessThan(5); // loop aborted once the client "left"
|
||||
const got = parse(frames);
|
||||
expect(got[got.length - 1]).toMatchObject({ status: 'failed', data: { type: 'error' } });
|
||||
});
|
||||
|
||||
it('wrapStreamFrame / wrapStreamTerminal shape (NapCat message+wording mirror)', () => {
|
||||
expect(wrapStreamFrame({ type: 'stream', x: 1 }, 'e')).toEqual({
|
||||
status: 'ok',
|
||||
retcode: 0,
|
||||
data: { type: 'stream', x: 1 },
|
||||
message: '',
|
||||
wording: '',
|
||||
stream: STREAM_MARK,
|
||||
echo: 'e',
|
||||
});
|
||||
// no echo → no echo key
|
||||
expect(wrapStreamFrame({ type: 'stream' }, undefined)).toEqual({
|
||||
status: 'ok',
|
||||
retcode: 0,
|
||||
data: { type: 'stream' },
|
||||
message: '',
|
||||
wording: '',
|
||||
stream: STREAM_MARK,
|
||||
});
|
||||
// failed terminal → data normalised to an error packet; reason in BOTH message + wording
|
||||
expect(wrapStreamTerminal(failedResponse(1404, 'nope'), 'e')).toMatchObject({
|
||||
status: 'failed',
|
||||
retcode: 1404,
|
||||
message: 'nope',
|
||||
wording: 'nope',
|
||||
stream: STREAM_MARK,
|
||||
echo: 'e',
|
||||
data: { type: 'error', data_type: 'error' },
|
||||
});
|
||||
// ok terminal → data passes through, message/wording empty
|
||||
expect(wrapStreamTerminal(okResponse({ type: 'response', a: 1 }), undefined)).toMatchObject(
|
||||
{
|
||||
status: 'ok',
|
||||
stream: STREAM_MARK,
|
||||
message: '',
|
||||
data: { type: 'response', a: 1 },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('malformed stream requests emit one bad-request frame', async () => {
|
||||
const h = newHandler();
|
||||
const frames: string[] = [];
|
||||
await h.processStreamRequest('not json', (j) => frames.push(j));
|
||||
await h.processStreamRequest('', (j) => frames.push(j));
|
||||
await h.processStreamRequest(JSON.stringify({ params: {} }), (j) => frames.push(j)); // no action
|
||||
expect(frames).toHaveLength(3);
|
||||
|
||||
for (const f of parse(frames)) {
|
||||
expect(f).toMatchObject({ status: 'failed' });
|
||||
}
|
||||
});
|
||||
|
||||
it('waits for an async bad-request frame to flush', async () => {
|
||||
const h = newHandler();
|
||||
let release!: () => void;
|
||||
const flushed = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let completed = false;
|
||||
|
||||
const request = h.processStreamRequest('', async () => flushed);
|
||||
void request.then(() => {
|
||||
completed = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
expect(completed).toBe(false);
|
||||
|
||||
release();
|
||||
await request;
|
||||
expect(completed).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user