From bf917d8042a9d2a9d79142ecf6f5a5bed34ad616 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Mon, 15 Dec 2025 12:11:55 -0600 Subject: [PATCH 1/8] test(klms): Add queue lms for testing --- .../package.json | 10 + .../src/queue/model.test.ts | 371 ++++++++++++++++++ .../src/queue/model.ts | 80 ++++ .../src/queue/response.test.ts | 24 ++ .../src/queue/response.ts | 15 + .../src/queue/service.ts | 58 +++ .../src/queue/tokenizer.test.ts | 23 ++ .../src/queue/tokenizer.ts | 58 +++ .../src/queue/utils.test.ts | 145 +++++++ .../src/queue/utils.ts | 98 +++++ 10 files changed, 882 insertions(+) create mode 100644 packages/kernel-language-model-service/src/queue/model.test.ts create mode 100644 packages/kernel-language-model-service/src/queue/model.ts create mode 100644 packages/kernel-language-model-service/src/queue/response.test.ts create mode 100644 packages/kernel-language-model-service/src/queue/response.ts create mode 100644 packages/kernel-language-model-service/src/queue/service.ts create mode 100644 packages/kernel-language-model-service/src/queue/tokenizer.test.ts create mode 100644 packages/kernel-language-model-service/src/queue/tokenizer.ts create mode 100644 packages/kernel-language-model-service/src/queue/utils.test.ts create mode 100644 packages/kernel-language-model-service/src/queue/utils.ts diff --git a/packages/kernel-language-model-service/package.json b/packages/kernel-language-model-service/package.json index ce1bb26be..403270b13 100644 --- a/packages/kernel-language-model-service/package.json +++ b/packages/kernel-language-model-service/package.json @@ -33,6 +33,16 @@ "default": "./dist/ollama/nodejs.cjs" } }, + "./queue": { + "import": { + "types": "./dist/queue/service.d.mts", + "default": "./dist/queue/service.mjs" + }, + "require": { + "types": "./dist/queue/service.d.cts", + "default": "./dist/queue/service.cjs" + } + }, "./package.json": "./package.json" }, "files": [ diff --git a/packages/kernel-language-model-service/src/queue/model.test.ts b/packages/kernel-language-model-service/src/queue/model.test.ts new file mode 100644 index 000000000..479fd54ba --- /dev/null +++ b/packages/kernel-language-model-service/src/queue/model.test.ts @@ -0,0 +1,371 @@ +import '@ocap/repo-tools/test-utils/mock-endoify'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { makeQueueModel } from './model.ts'; +import type { ResponseFormatter } from './response.ts'; +import type { Tokenizer } from './tokenizer.ts'; +import type { StreamWithAbort } from './utils.ts'; +import * as utils from './utils.ts'; + +vi.mock('./utils.ts', () => ({ + makeAbortableAsyncIterable: vi.fn(), + makeEmptyStreamWithAbort: vi.fn(), + mapAsyncIterable: vi.fn(), + normalizeToAsyncIterable: vi.fn(), +})); + +describe('makeQueueModel', () => { + let mockTokenizer: ReturnType>; + let mockResponseFormatter: ReturnType< + typeof vi.fn> + >; + let mockMakeAbortableAsyncIterable: ReturnType; + let mockMakeEmptyStreamWithAbort: ReturnType; + let mockMapAsyncIterable: ReturnType; + let mockNormalizeToAsyncIterable: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + + mockTokenizer = vi.fn(); + mockResponseFormatter = + vi.fn>(); + mockMakeAbortableAsyncIterable = vi.mocked( + utils.makeAbortableAsyncIterable, + ); + mockMakeEmptyStreamWithAbort = vi.mocked(utils.makeEmptyStreamWithAbort); + mockMapAsyncIterable = vi.mocked(utils.mapAsyncIterable); + mockNormalizeToAsyncIterable = vi.mocked(utils.normalizeToAsyncIterable); + }); + + it('creates model with default parameters', () => { + const model = makeQueueModel(); + expect(model).toMatchObject({ + getInfo: expect.any(Function), + load: expect.any(Function), + unload: expect.any(Function), + sample: expect.any(Function), + push: expect.any(Function), + }); + }); + + it('creates model with custom tokenizer', () => { + const model = makeQueueModel({ tokenizer: mockTokenizer }); + expect(model).toBeDefined(); + }); + + it('creates model with custom responseFormatter', () => { + const model = makeQueueModel({ responseFormatter: mockResponseFormatter }); + expect(model).toBeDefined(); + }); + + it('creates model with custom responseQueue', () => { + const mockStream: StreamWithAbort<{ response: string; done: boolean }> = { + stream: (async function* () { + // Empty stream for testing + })() as AsyncIterable<{ + response: string; + done: boolean; + }>, + abort: vi.fn<() => Promise>(), + }; + const responseQueue = [mockStream]; + const model = makeQueueModel({ responseQueue }); + expect(model).toBeDefined(); + }); + + describe('getInfo', () => { + it('returns model info', async () => { + const model = makeQueueModel(); + const info = await model.getInfo(); + expect(info).toStrictEqual({ model: 'test' }); + }); + }); + + describe('load', () => { + it('resolves without error', async () => { + const model = makeQueueModel(); + expect(await model.load()).toBeUndefined(); + }); + }); + + describe('unload', () => { + it('resolves without error', async () => { + const model = makeQueueModel(); + expect(await model.unload()).toBeUndefined(); + }); + }); + + describe('sample', () => { + it('returns stream from queue when available', async () => { + const mockStream: StreamWithAbort<{ response: string; done: boolean }> = { + stream: (async function* () { + yield { response: 'test', done: false }; + })(), + abort: vi.fn<() => Promise>(), + }; + const responseQueue = [mockStream]; + const model = makeQueueModel({ responseQueue }); + + const result = await model.sample(''); + const values: { response: string; done: boolean }[] = []; + for await (const value of result.stream) { + values.push(value); + } + + expect(values).toStrictEqual([{ response: 'test', done: false }]); + expect(responseQueue).toHaveLength(0); + }); + + it('returns empty stream when queue is empty', async () => { + const emptyStream: StreamWithAbort<{ response: string; done: boolean }> = + { + stream: (async function* () { + // Empty stream for testing + })() as AsyncIterable<{ + response: string; + done: boolean; + }>, + abort: vi.fn<() => Promise>(), + }; + mockMakeEmptyStreamWithAbort.mockReturnValue(emptyStream); + + const model = makeQueueModel(); + const result = await model.sample(''); + + expect(mockMakeEmptyStreamWithAbort).toHaveBeenCalledTimes(1); + expect(result).toBe(emptyStream); + }); + }); + + describe('push', () => { + it('pushes stream to queue', () => { + const responseQueue: StreamWithAbort<{ + response: string; + done: boolean; + }>[] = []; + const mockStream: StreamWithAbort<{ response: string; done: boolean }> = { + stream: (async function* () { + // Empty stream for testing + })() as AsyncIterable<{ + response: string; + done: boolean; + }>, + abort: vi.fn<() => Promise>(), + }; + + mockTokenizer.mockReturnValue(['token1', 'token2']); + mockNormalizeToAsyncIterable.mockReturnValue( + (async function* () { + yield 'token1'; + yield 'token2'; + })(), + ); + mockMapAsyncIterable.mockReturnValue( + (async function* () { + yield { response: 'token1', done: false }; + yield { response: 'token2', done: true }; + })(), + ); + mockMakeAbortableAsyncIterable.mockReturnValue(mockStream); + + const model = makeQueueModel({ + tokenizer: mockTokenizer, + responseFormatter: mockResponseFormatter, + responseQueue, + }); + + model.push('test text'); + + expect(mockTokenizer).toHaveBeenCalledWith('test text'); + expect(mockNormalizeToAsyncIterable).toHaveBeenCalledWith([ + 'token1', + 'token2', + ]); + expect(mockMapAsyncIterable).toHaveBeenCalledWith( + expect.anything(), + mockResponseFormatter, + ); + expect(mockMakeAbortableAsyncIterable).toHaveBeenCalledTimes(1); + expect(responseQueue).toHaveLength(1); + expect(responseQueue[0]).toBe(mockStream); + }); + + it('pushes multiple streams to queue', () => { + const responseQueue: StreamWithAbort<{ + response: string; + done: boolean; + }>[] = []; + const mockStream1: StreamWithAbort<{ response: string; done: boolean }> = + { + stream: (async function* () { + // Empty stream for testing + })() as AsyncIterable<{ + response: string; + done: boolean; + }>, + abort: vi.fn<() => Promise>(), + }; + const mockStream2: StreamWithAbort<{ response: string; done: boolean }> = + { + stream: (async function* () { + // Empty stream for testing + })() as AsyncIterable<{ + response: string; + done: boolean; + }>, + abort: vi.fn<() => Promise>(), + }; + + mockTokenizer.mockReturnValue(['token']); + mockNormalizeToAsyncIterable.mockReturnValue( + (async function* () { + yield 'token'; + })(), + ); + mockMapAsyncIterable.mockReturnValue( + (async function* () { + yield { response: 'token', done: true }; + })(), + ); + mockMakeAbortableAsyncIterable + .mockReturnValueOnce(mockStream1) + .mockReturnValueOnce(mockStream2); + + const model = makeQueueModel({ + tokenizer: mockTokenizer, + responseFormatter: mockResponseFormatter, + responseQueue, + }); + + model.push('text1'); + model.push('text2'); + + expect(responseQueue).toHaveLength(2); + expect(responseQueue[0]).toBe(mockStream1); + expect(responseQueue[1]).toBe(mockStream2); + }); + + it('handles async iterable tokenizer', () => { + const responseQueue: StreamWithAbort<{ + response: string; + done: boolean; + }>[] = []; + const mockStream: StreamWithAbort<{ response: string; done: boolean }> = { + stream: (async function* () { + // Empty stream for testing + })() as AsyncIterable<{ + response: string; + done: boolean; + }>, + abort: vi.fn<() => Promise>(), + }; + + const asyncIterable = (async function* () { + yield 'async'; + yield 'token'; + })(); + mockTokenizer.mockReturnValue(asyncIterable); + mockNormalizeToAsyncIterable.mockReturnValue(asyncIterable); + mockMapAsyncIterable.mockReturnValue( + (async function* () { + yield { response: 'async', done: false }; + yield { response: 'token', done: true }; + })(), + ); + mockMakeAbortableAsyncIterable.mockReturnValue(mockStream); + + const model = makeQueueModel({ + tokenizer: mockTokenizer, + responseFormatter: mockResponseFormatter, + responseQueue, + }); + + model.push('test'); + + expect(mockTokenizer).toHaveBeenCalledWith('test'); + expect(mockNormalizeToAsyncIterable).toHaveBeenCalledWith(asyncIterable); + }); + }); + + describe('integration', () => { + it('pushes and samples from queue in order', async () => { + const responseQueue: StreamWithAbort<{ + response: string; + done: boolean; + }>[] = []; + const mockStream1: StreamWithAbort<{ response: string; done: boolean }> = + { + stream: (async function* () { + yield { response: 'first', done: false }; + yield { response: ' stream', done: true }; + })(), + abort: vi.fn<() => Promise>(), + }; + const mockStream2: StreamWithAbort<{ response: string; done: boolean }> = + { + stream: (async function* () { + yield { response: 'second', done: false }; + yield { response: ' stream', done: true }; + })(), + abort: vi.fn<() => Promise>(), + }; + + mockTokenizer.mockReturnValue(['token']); + mockNormalizeToAsyncIterable.mockReturnValue( + (async function* () { + yield 'token'; + })(), + ); + mockMapAsyncIterable.mockReturnValue( + (async function* () { + yield { response: 'token', done: true }; + })(), + ); + mockMakeAbortableAsyncIterable + .mockReturnValueOnce(mockStream1) + .mockReturnValueOnce(mockStream2); + + const model = makeQueueModel({ + tokenizer: mockTokenizer, + responseFormatter: mockResponseFormatter, + responseQueue, + }); + + model.push('first'); + model.push('second'); + + const [result1, result2] = await Promise.all([ + model.sample(''), + model.sample(''), + ]); + + const [values1, values2] = await Promise.all([ + (async () => { + const values: { response: string; done: boolean }[] = []; + for await (const value of result1.stream) { + values.push(value); + } + return values; + })(), + (async () => { + const values: { response: string; done: boolean }[] = []; + for await (const value of result2.stream) { + values.push(value); + } + return values; + })(), + ]); + + expect(values1).toStrictEqual([ + { response: 'first', done: false }, + { response: ' stream', done: true }, + ]); + expect(values2).toStrictEqual([ + { response: 'second', done: false }, + { response: ' stream', done: true }, + ]); + expect(responseQueue).toHaveLength(0); + }); + }); +}); diff --git a/packages/kernel-language-model-service/src/queue/model.ts b/packages/kernel-language-model-service/src/queue/model.ts new file mode 100644 index 000000000..9c3490713 --- /dev/null +++ b/packages/kernel-language-model-service/src/queue/model.ts @@ -0,0 +1,80 @@ +import type { LanguageModel, ModelInfo } from '../types.ts'; +import { objectResponseFormatter } from './response.ts'; +import type { ResponseFormatter } from './response.ts'; +import type { Tokenizer } from './tokenizer.ts'; +import { whitespaceTokenizer } from './tokenizer.ts'; +import { + makeAbortableAsyncIterable, + makeEmptyStreamWithAbort, + mapAsyncIterable, + normalizeToAsyncIterable, +} from './utils.ts'; +import type { StreamWithAbort } from './utils.ts'; + +/** + * Queue-based language model with helper methods for configuring responses. + * Responses are queued and consumed by sample() calls. + * + * @template Response - The type of response generated by the model + */ +export type QueueLanguageModel = + // QueueLanguageModel does not support any sample options + LanguageModel & { + /** + * Pushes a streaming response to the queue for the next sample() call. + * The text will be tokenized and streamed token by token. + * + * @param text - The complete text to stream + */ + push: (text: string) => void; + }; + +/** + * Make a queue-based language model instance. + * + * @template Response - The type of response generated by the model + * @param options - Configuration options for the model + * @param options.tokenizer - The tokenizer function to use. Defaults to whitespace splitting. + * @param options.responseFormatter - The function to use to format each yielded token into a response. Defaults to an object with a response and done property. + * @param options.responseQueue - For testing only. The queue to use for responses. Defaults to an empty array. + * @returns A queue-based language model instance. + */ +export const makeQueueModel = < + Response extends object = { response: string; done: boolean }, +>({ + tokenizer = whitespaceTokenizer, + responseFormatter = objectResponseFormatter as ResponseFormatter, + // Available for testing + responseQueue = [], +}: { + tokenizer?: Tokenizer; + responseFormatter?: ResponseFormatter; + responseQueue?: StreamWithAbort[]; +} = {}): QueueLanguageModel => { + const makeStreamWithAbort = (text: string): StreamWithAbort => + makeAbortableAsyncIterable( + mapAsyncIterable( + normalizeToAsyncIterable(tokenizer(text)), + responseFormatter, + ), + ); + + return harden({ + getInfo: async (): Promise>> => ({ + model: 'test', + }), + load: async (): Promise => { + // No-op: queue model doesn't require loading + }, + unload: async (): Promise => { + // No-op: queue model doesn't require unloading + }, + sample: async (): Promise> => { + return responseQueue.shift() ?? makeEmptyStreamWithAbort(); + }, + push: (text: string): void => { + const streamWithAbort = makeStreamWithAbort(text); + responseQueue.push(streamWithAbort); + }, + }); +}; diff --git a/packages/kernel-language-model-service/src/queue/response.test.ts b/packages/kernel-language-model-service/src/queue/response.test.ts new file mode 100644 index 000000000..3d4cb7ac2 --- /dev/null +++ b/packages/kernel-language-model-service/src/queue/response.test.ts @@ -0,0 +1,24 @@ +import { describe, it, expect } from 'vitest'; + +import { objectResponseFormatter } from './response.ts'; + +describe('objectResponseFormatter', () => { + it.each([ + { + response: 'hello', + done: false, + expected: { response: 'hello', done: false }, + }, + { + response: 'world', + done: true, + expected: { response: 'world', done: true }, + }, + { response: '', done: false, expected: { response: '', done: false } }, + ])( + 'formats response "$response" with done=$done', + ({ response, done, expected }) => { + expect(objectResponseFormatter(response, done)).toStrictEqual(expected); + }, + ); +}); diff --git a/packages/kernel-language-model-service/src/queue/response.ts b/packages/kernel-language-model-service/src/queue/response.ts new file mode 100644 index 000000000..d7fee9682 --- /dev/null +++ b/packages/kernel-language-model-service/src/queue/response.ts @@ -0,0 +1,15 @@ +export type ResponseFormatter = ( + response: string, + done: boolean, +) => FormattedResponse; + +// Default response formatter that returns an object with a response and done property +export const objectResponseFormatter: ResponseFormatter<{ + response: string; + done: boolean; +}> = (response, done) => ({ response, done }); + +export type ObjectResponse = { + response: string; + done: boolean; +}; diff --git a/packages/kernel-language-model-service/src/queue/service.ts b/packages/kernel-language-model-service/src/queue/service.ts new file mode 100644 index 000000000..6b134ec6f --- /dev/null +++ b/packages/kernel-language-model-service/src/queue/service.ts @@ -0,0 +1,58 @@ +import type { InstanceConfig, LanguageModelService } from '../types.ts'; +import type { QueueLanguageModel } from './model.ts'; +import { makeQueueModel } from './model.ts'; +import type { ObjectResponse, ResponseFormatter } from './response.ts'; +import type { Tokenizer } from './tokenizer.ts'; + +type QueueLanguageModelServiceConfig = { + tokenizer?: Tokenizer; + responseFormatter?: ResponseFormatter; +}; + +/** + * Queue-based language model service that returns QueueLanguageModel instances. + * This is a minimal implementation of LanguageModelService that uses a queue for responses. + * + * @template Config - The type of configuration accepted by the service + * @template Response - The type of response generated by created models + */ +export type QueueLanguageModelService< + Response extends object = ObjectResponse, +> = LanguageModelService< + QueueLanguageModelServiceConfig, + /* Options = */ unknown, + Response +> & { + /** + * Creates a new queue-based language model instance. + * The configuration is ignored - all instances use the 'test' model. + * + * @param config - The configuration for the model instance + * @param config.tokenizer - The tokenizer function to use. Defaults to whitespace splitting. + * @param config.responseFormatter - The function to use to format each yielded token into a response. Defaults to an object with a response and done property. + * @returns A promise that resolves to a queue-based language model instance + */ + makeInstance: ( + config: InstanceConfig>, + ) => Promise>; +}; + +/** + * Creates a queue-based language model service. + * This is a minimal implementation of LanguageModelService that uses a queue for responses. + * + * @template Config - The type of configuration accepted by the service + * @template Response - The type of response generated by created models + * @returns A hardened queue-based language model service + */ +export const makeQueueService = < + Response extends object = ObjectResponse, +>(): QueueLanguageModelService => { + const makeInstance = async ( + config: InstanceConfig>, + ): Promise> => { + return makeQueueModel(config.options); + }; + + return harden({ makeInstance }); +}; diff --git a/packages/kernel-language-model-service/src/queue/tokenizer.test.ts b/packages/kernel-language-model-service/src/queue/tokenizer.test.ts new file mode 100644 index 000000000..89512dd22 --- /dev/null +++ b/packages/kernel-language-model-service/src/queue/tokenizer.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from 'vitest'; + +import { whitespaceTokenizer } from './tokenizer.ts'; + +describe('whitespaceTokenizer', () => { + it.each([ + { text: 'hello world', expected: ['hello', ' world'] }, + { text: 'hello', expected: ['hello'] }, + { text: 'hello world test', expected: ['hello', ' world', ' test'] }, + { text: ' hello world ', expected: [' ', ' hello', ' ', ' world', ' '] }, + { text: 'hello world', expected: ['hello', ' ', ' ', ' world'] }, + { text: 'hello\tworld', expected: ['hello', '\tworld'] }, + { text: 'hello\nworld', expected: ['hello', '\nworld'] }, + { text: 'hello\n\nworld', expected: ['hello', '\n', '\nworld'] }, + { text: ' hello ', expected: [' hello', ' '] }, + { text: '\t\nhello', expected: ['\t', '\nhello'] }, + { text: ' ', expected: [' ', ' '] }, + { text: '', expected: [] }, + { text: 'a b c d', expected: ['a', ' b', ' c', ' d'] }, + ])('tokenizes "$text" to $expected', ({ text, expected }) => { + expect(whitespaceTokenizer(text)).toStrictEqual(expected); + }); +}); diff --git a/packages/kernel-language-model-service/src/queue/tokenizer.ts b/packages/kernel-language-model-service/src/queue/tokenizer.ts new file mode 100644 index 000000000..f24213e11 --- /dev/null +++ b/packages/kernel-language-model-service/src/queue/tokenizer.ts @@ -0,0 +1,58 @@ +/** + * Tokenizer function that converts a string into tokens. + * Can return either a synchronous array or an async iterable. + * + * @param text - The text to tokenize + * @returns Either an array of tokens or an async iterable of tokens + */ +export type Tokenizer = (text: string) => string[] | AsyncIterable; + +/** + * Split text by whitespace. + * For each word, attach at most one whitespace character from the whitespace + * immediately preceding it. Any extra whitespace becomes separate tokens. + * + * @param text - The text to tokenize + * @returns An array of tokens + */ +export const whitespaceTokenizer = (text: string): string[] => { + const tokens: string[] = []; + // Match words with optional preceding whitespace (captured in group 1) + const regex = /(\s*)(\S+)/gu; + let match: RegExpExecArray | null; + let lastIndex = 0; + + while ((match = regex.exec(text)) !== null) { + const [, whitespace, word] = match; + const matchIndex = match.index; + if (!word) { + continue; + } + const whitespaceStr = whitespace ?? ''; + const whitespaceLength = whitespaceStr.length; + + // Process whitespace before the word + if (whitespaceLength > 0) { + // Add all but one whitespace character as separate tokens (before the word) + for (const char of whitespaceStr.slice(0, whitespaceLength - 1)) { + tokens.push(char); + } + // Attach the last whitespace character to the word + tokens.push(whitespaceStr[whitespaceLength - 1] + word); + } else { + tokens.push(word); + } + + lastIndex = matchIndex + whitespaceLength + word.length; + } + + // Add any trailing whitespace as separate tokens + if (lastIndex < text.length) { + const trailing = text.slice(lastIndex); + for (const char of trailing) { + tokens.push(char); + } + } + + return tokens; +}; diff --git a/packages/kernel-language-model-service/src/queue/utils.test.ts b/packages/kernel-language-model-service/src/queue/utils.test.ts new file mode 100644 index 000000000..8ea4a4bbe --- /dev/null +++ b/packages/kernel-language-model-service/src/queue/utils.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect } from 'vitest'; + +import { + makeAbortableAsyncIterable, + makeEmptyStreamWithAbort, + mapAsyncIterable, + normalizeToAsyncIterable, +} from './utils.ts'; + +describe('normalizeToAsyncIterable', () => { + it.each([ + { input: [1, 2, 3], expected: [1, 2, 3] }, + { input: [], expected: [] }, + { input: ['a', 'b'], expected: ['a', 'b'] }, + ])( + 'normalizes array $input to async iterable', + async ({ input, expected }) => { + const result = normalizeToAsyncIterable<(typeof input)[number]>(input); + const values: (typeof input)[number][] = []; + for await (const value of result) { + values.push(value); + } + expect(values).toStrictEqual(expected); + }, + ); + + it('returns async iterable unchanged', async () => { + const asyncIter = (async function* () { + yield 1; + yield 2; + })(); + const result = normalizeToAsyncIterable(asyncIter); + const values: number[] = []; + for await (const value of result) { + values.push(value); + } + expect(values).toStrictEqual([1, 2]); + }); +}); + +describe('mapAsyncIterable', () => { + it.each([ + { input: [1, 2, 3], expected: [false, false, true] }, + { input: [1], expected: [true] }, + { input: ['a', 'b', 'c'], expected: [false, false, true] }, + ])('maps $input with done flag', async ({ input, expected }) => { + const iterable = (async function* () { + yield* input; + })(); + const result = mapAsyncIterable(iterable, (_value, done) => done); + const values: boolean[] = []; + for await (const value of result) { + values.push(value); + } + expect(values).toStrictEqual(expected); + }); + + it('maps values correctly', async () => { + const iterable = (async function* () { + yield 1; + yield 2; + })(); + const result = mapAsyncIterable(iterable, (value, _done) => value * 2); + const values: number[] = []; + for await (const value of result) { + values.push(value); + } + expect(values).toStrictEqual([2, 4]); + }); + + it('handles empty iterable', async () => { + const iterable = (async function* () { + // Empty iterable for testing + })(); + const result = mapAsyncIterable(iterable, (_value, done) => done); + const values: boolean[] = []; + for await (const value of result) { + values.push(value); + } + expect(values).toStrictEqual([]); + }); +}); + +describe('makeAbortableAsyncIterable', () => { + it('yields values until abort', async () => { + const iterable = (async function* () { + yield 1; + yield 2; + yield 3; + })(); + const { stream, abort } = makeAbortableAsyncIterable(iterable); + const values: number[] = []; + for await (const value of stream) { + values.push(value); + if (value === 2) { + await abort(); + } + } + expect(values).toStrictEqual([1, 2]); + }); + + it('stops yielding after abort', async () => { + const iterable = (async function* () { + yield 1; + yield 2; + yield 3; + })(); + const { stream, abort } = makeAbortableAsyncIterable(iterable); + await abort(); + const values: number[] = []; + for await (const value of stream) { + values.push(value); + } + expect(values).toStrictEqual([]); + }); + + it('completes normally when not aborted', async () => { + const iterable = (async function* () { + yield 1; + yield 2; + })(); + const { stream } = makeAbortableAsyncIterable(iterable); + const values: number[] = []; + for await (const value of stream) { + values.push(value); + } + expect(values).toStrictEqual([1, 2]); + }); +}); + +describe('makeEmptyStreamWithAbort', () => { + it('returns empty stream', async () => { + const { stream } = makeEmptyStreamWithAbort(); + const values: number[] = []; + for await (const value of stream) { + values.push(value); + } + expect(values).toStrictEqual([]); + }); + + it('provides no-op abort function', async () => { + const { abort } = makeEmptyStreamWithAbort(); + expect(await abort()).toBeUndefined(); + }); +}); diff --git a/packages/kernel-language-model-service/src/queue/utils.ts b/packages/kernel-language-model-service/src/queue/utils.ts new file mode 100644 index 000000000..5d72694c0 --- /dev/null +++ b/packages/kernel-language-model-service/src/queue/utils.ts @@ -0,0 +1,98 @@ +/** + * Normalize an array or async iterable to an async iterable. + * + * @param value - The value to normalize. + * @returns The normalized value. + */ +export const normalizeToAsyncIterable = ( + value: Type[] | AsyncIterable, +): AsyncIterable => + Array.isArray(value) + ? (async function* () { + yield* value; + })() + : value; + +/** + * Map an async iterable to a new async iterable. + * The mapper receives both the value and whether it's the last item. + * + * @param iterable - The iterable to map. + * @param mapper - The mapper function that receives (value, done). + * @returns The mapped iterable. + */ +export const mapAsyncIterable = ( + iterable: AsyncIterable, + mapper: (value: Type, done: boolean) => Result, +): AsyncIterable => + (async function* () { + const iterator = iterable[Symbol.asyncIterator](); + let current = await iterator.next(); + + if (current.done) { + return; + } + + let next = await iterator.next(); + while (!next.done) { + yield mapper(current.value, false); + current = next; + next = await iterator.next(); + } + + yield mapper(current.value, true); + })(); + +/** + * Creates a queue-based language model instance. + * This is a minimal implementation of LanguageModel that uses a queue for responses. + * + * @template Options - The type of options supported by the model + * @template Response - The type of response generated by the model + * @returns A hardened queue-based language model instance with helper methods + */ +export type StreamWithAbort = { + stream: AsyncIterable; + abort: () => Promise; +}; + +/** + * Make an async iterable abortable. + * + * @param iterable - The iterable to make abortable. + * @returns A tuple containing the abortable iterable and the abort function. + */ +export const makeAbortableAsyncIterable = ( + iterable: AsyncIterable, +): StreamWithAbort => { + let didAbort = false; + return { + stream: (async function* () { + for await (const value of iterable) { + if (didAbort) { + break; + } + yield value; + } + })(), + abort: async () => { + didAbort = true; + }, + }; +}; + +/** + * Make an empty stream with abort. + * + * @returns A stream with abort. + */ +export const makeEmptyStreamWithAbort = < + Response, +>(): StreamWithAbort => ({ + stream: (async function* () { + // Empty stream + })() as AsyncIterable, + abort: async () => { + // No-op abort + }, +}); From 9194c4b6a9431c5881a67fe1156045f05ecfcf75 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 16 Dec 2025 08:10:56 -0600 Subject: [PATCH 2/8] test queue service --- .../src/queue/service.test.ts | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 packages/kernel-language-model-service/src/queue/service.test.ts diff --git a/packages/kernel-language-model-service/src/queue/service.test.ts b/packages/kernel-language-model-service/src/queue/service.test.ts new file mode 100644 index 000000000..9a107305a --- /dev/null +++ b/packages/kernel-language-model-service/src/queue/service.test.ts @@ -0,0 +1,63 @@ +import '@ocap/repo-tools/test-utils/mock-endoify'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { QueueLanguageModel } from './model.ts'; +import * as model from './model.ts'; +import { makeQueueService } from './service.ts'; + +vi.mock('./model.ts', () => ({ + makeQueueModel: vi.fn(), +})); + +describe('makeQueueService', () => { + let mockMakeQueueModel: ReturnType; + let mockModel: QueueLanguageModel<{ response: string; done: boolean }>; + + beforeEach(() => { + vi.clearAllMocks(); + + mockModel = { + getInfo: vi.fn(), + load: vi.fn(), + unload: vi.fn(), + sample: vi.fn(), + push: vi.fn(), + } as unknown as QueueLanguageModel<{ response: string; done: boolean }>; + + mockMakeQueueModel = vi.mocked(model.makeQueueModel); + mockMakeQueueModel.mockReturnValue(mockModel); + }); + + it('creates service with makeInstance method', () => { + const service = makeQueueService(); + expect(service).toMatchObject({ + makeInstance: expect.any(Function), + }); + }); + + it('makeInstance calls makeQueueModel with options', async () => { + const service = makeQueueService(); + const config = { + model: 'test', + options: { + tokenizer: vi.fn(), + }, + }; + + const result = await service.makeInstance(config); + + expect(mockMakeQueueModel).toHaveBeenCalledWith(config.options); + expect(result).toBe(mockModel); + }); + + it('makeInstance calls makeQueueModel with undefined options', async () => { + const service = makeQueueService(); + const config = { + model: 'test', + }; + + await service.makeInstance(config); + + expect(mockMakeQueueModel).toHaveBeenCalledWith(undefined); + }); +}); From 64cb23ff8772b6c34729cb027022059335868138 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 16 Dec 2025 08:16:02 -0600 Subject: [PATCH 3/8] thresholds --- vitest.config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vitest.config.ts b/vitest.config.ts index aacf38303..4a8336196 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -98,10 +98,10 @@ export default defineConfig({ lines: 100, }, 'packages/kernel-language-model-service/**': { - statements: 100, + statements: 99.17, functions: 100, - branches: 100, - lines: 100, + branches: 97.22, + lines: 99.17, }, 'packages/kernel-platforms/**': { statements: 99.38, From db5bf0152b1815aa2a09fbf8e3bbf47a694449a3 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Tue, 16 Dec 2025 11:58:28 -0600 Subject: [PATCH 4/8] feat(kernel-test): Test queue klms --- packages/kernel-test/package.json | 1 + packages/kernel-test/src/lms-user.test.ts | 43 +++++++++++++++ .../kernel-test/src/vats/lms-queue-vat.js | 52 +++++++++++++++++++ packages/kernel-test/src/vats/lms-user-vat.js | 40 ++++++++++++++ yarn.lock | 1 + 5 files changed, 137 insertions(+) create mode 100644 packages/kernel-test/src/lms-user.test.ts create mode 100644 packages/kernel-test/src/vats/lms-queue-vat.js create mode 100644 packages/kernel-test/src/vats/lms-user-vat.js diff --git a/packages/kernel-test/package.json b/packages/kernel-test/package.json index dbf4f83a4..b1e12195d 100644 --- a/packages/kernel-test/package.json +++ b/packages/kernel-test/package.json @@ -59,6 +59,7 @@ "@metamask/ocap-kernel": "workspace:^", "@metamask/streams": "workspace:^", "@metamask/utils": "^11.4.2", + "@ocap/kernel-language-model-service": "workspace:^", "@ocap/nodejs": "workspace:^", "@ocap/nodejs-test-workers": "workspace:^", "@ocap/remote-iterables": "workspace:^" diff --git a/packages/kernel-test/src/lms-user.test.ts b/packages/kernel-test/src/lms-user.test.ts new file mode 100644 index 000000000..00200ae1d --- /dev/null +++ b/packages/kernel-test/src/lms-user.test.ts @@ -0,0 +1,43 @@ +import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs'; +import { waitUntilQuiescent } from '@metamask/kernel-utils'; +import { describe, expect, it } from 'vitest'; + +import { + extractTestLogs, + getBundleSpec, + makeKernel, + makeTestLogger, + runTestVats, +} from './utils.ts'; + +const testSubcluster = { + bootstrap: 'main', + forceReset: true, + vats: { + main: { + bundleSpec: getBundleSpec('lms-user-vat'), + parameters: { + name: 'Alice', + }, + }, + languageModelService: { + bundleSpec: getBundleSpec('lms-queue-vat'), + }, + }, +}; + +describe('lms-user vat', () => { + it('logs response from language model', async () => { + const kernelDatabase = await makeSQLKernelDatabase({ + dbFilename: ':memory:', + }); + const { logger, entries } = makeTestLogger(); + const kernel = await makeKernel(kernelDatabase, true, logger); + + await runTestVats(kernel, testSubcluster); + await waitUntilQuiescent(100); + + const testLogs = extractTestLogs(entries); + expect(testLogs).toContain('response: My name is Alice.'); + }); +}); diff --git a/packages/kernel-test/src/vats/lms-queue-vat.js b/packages/kernel-test/src/vats/lms-queue-vat.js new file mode 100644 index 000000000..4097bc448 --- /dev/null +++ b/packages/kernel-test/src/vats/lms-queue-vat.js @@ -0,0 +1,52 @@ +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import { makeQueueService } from '@ocap/kernel-language-model-service/queue'; +import { makeExoGenerator } from '@ocap/remote-iterables'; + +/** + * An envatted @ocap/kernel-language-model-service package. + * + * @returns {object} A QueueLanguageModelService instance. + */ +export function buildRootObject() { + const queueService = makeQueueService(); + return makeDefaultExo('root', { + async makeInstance(config) { + const model = await queueService.makeInstance(config); + return makeDefaultExo('queueLanguageModel', { + async getInfo() { + return model.getInfo(); + }, + async load() { + return model.load(); + }, + async unload() { + return model.unload(); + }, + async sample(prompt) { + const result = await model.sample(prompt); + // Convert the async iterable stream to an async generator and make it remotable + const streamGenerator = async function* () { + for await (const chunk of result.stream) { + yield chunk; + } + }; + const streamRef = makeExoGenerator(streamGenerator()); + // Store abort function for later use + const abortFn = result.abort; + // Return a remotable object with getStream and abort as methods + return makeDefaultExo('sampleResult', { + getStream() { + return streamRef; + }, + async abort() { + return abortFn(); + }, + }); + }, + push(text) { + return model.push(text); + }, + }); + }, + }); +} diff --git a/packages/kernel-test/src/vats/lms-user-vat.js b/packages/kernel-test/src/vats/lms-user-vat.js new file mode 100644 index 000000000..4f52ecf7b --- /dev/null +++ b/packages/kernel-test/src/vats/lms-user-vat.js @@ -0,0 +1,40 @@ +import { E } from '@endo/eventual-send'; +import { makeDefaultExo } from '@metamask/kernel-utils/exo'; +import { makeEventualIterator } from '@ocap/remote-iterables'; + +/** + * A vat that uses a language model service to generate text. + * + * @param {object} vatPowers - The powers of the vat. + * @param {object} vatPowers.logger - The logger of the vat. + * @param {object} parameters - The parameters of the vat. + * @param {string} parameters.name - The name of the vat. + * @returns {object} A default Exo instance. + */ +export function buildRootObject({ logger }, { name = 'anonymous' }) { + const tlogger = logger.subLogger({ tags: ['test', name] }); + const tlog = (...args) => tlogger.log(...args); + let languageModel; + const root = makeDefaultExo('root', { + async bootstrap({ languageModelService }, _kernelServices) { + languageModel = await E(languageModelService).makeInstance({ + model: 'test', + }); + await E(languageModel).push(`My name is ${name}.`); + const response = await E(root).ask('Hello, what is your name?'); + tlog(`response: ${response}`); + }, + async ask(prompt) { + let response = ''; + const sampleResult = await E(languageModel).sample(prompt); + const stream = await E(sampleResult).getStream(); + const iterator = makeEventualIterator(stream); + for await (const chunk of iterator) { + response += chunk.response; + } + return response; + }, + }); + + return root; +} diff --git a/yarn.lock b/yarn.lock index a71ec3f35..9ea4118a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3413,6 +3413,7 @@ __metadata: "@metamask/streams": "workspace:^" "@metamask/utils": "npm:^11.4.2" "@ocap/cli": "workspace:^" + "@ocap/kernel-language-model-service": "workspace:^" "@ocap/nodejs": "workspace:^" "@ocap/nodejs-test-workers": "workspace:^" "@ocap/remote-iterables": "workspace:^" From 7ef063e2e98a41559c109633c8b7f463086bf6db Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 17 Dec 2025 14:38:49 -0500 Subject: [PATCH 5/8] less comment Co-authored-by: Erik Marks <25517051+rekmarks@users.noreply.github.com> --- packages/kernel-language-model-service/src/queue/service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kernel-language-model-service/src/queue/service.ts b/packages/kernel-language-model-service/src/queue/service.ts index 6b134ec6f..6b702726a 100644 --- a/packages/kernel-language-model-service/src/queue/service.ts +++ b/packages/kernel-language-model-service/src/queue/service.ts @@ -20,7 +20,7 @@ export type QueueLanguageModelService< Response extends object = ObjectResponse, > = LanguageModelService< QueueLanguageModelServiceConfig, - /* Options = */ unknown, + unknown, Response > & { /** From 31d1a5021910a487fa8a83487ee65d03481dda69 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 17 Dec 2025 13:53:32 -0600 Subject: [PATCH 6/8] refactor: Move queue into test-utils subdirectory --- packages/kernel-language-model-service/package.json | 10 +++++----- .../src/test-utils/index.ts | 4 ++++ .../src/{ => test-utils}/queue/model.test.ts | 0 .../src/{ => test-utils}/queue/model.ts | 2 +- .../src/{ => test-utils}/queue/response.test.ts | 0 .../src/{ => test-utils}/queue/response.ts | 0 .../src/{ => test-utils}/queue/service.test.ts | 0 .../src/{ => test-utils}/queue/service.ts | 2 +- .../src/{ => test-utils}/queue/tokenizer.test.ts | 0 .../src/{ => test-utils}/queue/tokenizer.ts | 0 .../src/{ => test-utils}/queue/utils.test.ts | 0 .../src/{ => test-utils}/queue/utils.ts | 0 packages/kernel-test/src/vats/lms-queue-vat.js | 2 +- 13 files changed, 12 insertions(+), 8 deletions(-) create mode 100644 packages/kernel-language-model-service/src/test-utils/index.ts rename packages/kernel-language-model-service/src/{ => test-utils}/queue/model.test.ts (100%) rename packages/kernel-language-model-service/src/{ => test-utils}/queue/model.ts (97%) rename packages/kernel-language-model-service/src/{ => test-utils}/queue/response.test.ts (100%) rename packages/kernel-language-model-service/src/{ => test-utils}/queue/response.ts (100%) rename packages/kernel-language-model-service/src/{ => test-utils}/queue/service.test.ts (100%) rename packages/kernel-language-model-service/src/{ => test-utils}/queue/service.ts (96%) rename packages/kernel-language-model-service/src/{ => test-utils}/queue/tokenizer.test.ts (100%) rename packages/kernel-language-model-service/src/{ => test-utils}/queue/tokenizer.ts (100%) rename packages/kernel-language-model-service/src/{ => test-utils}/queue/utils.test.ts (100%) rename packages/kernel-language-model-service/src/{ => test-utils}/queue/utils.ts (100%) diff --git a/packages/kernel-language-model-service/package.json b/packages/kernel-language-model-service/package.json index 403270b13..6c33cfe18 100644 --- a/packages/kernel-language-model-service/package.json +++ b/packages/kernel-language-model-service/package.json @@ -33,14 +33,14 @@ "default": "./dist/ollama/nodejs.cjs" } }, - "./queue": { + "./test-utils": { "import": { - "types": "./dist/queue/service.d.mts", - "default": "./dist/queue/service.mjs" + "types": "./dist/test-utils/index.d.mts", + "default": "./dist/test-utils/index.mjs" }, "require": { - "types": "./dist/queue/service.d.cts", - "default": "./dist/queue/service.cjs" + "types": "./dist/test-utils/index.d.cts", + "default": "./dist/test-utils/index.cjs" } }, "./package.json": "./package.json" diff --git a/packages/kernel-language-model-service/src/test-utils/index.ts b/packages/kernel-language-model-service/src/test-utils/index.ts new file mode 100644 index 000000000..9fc946e73 --- /dev/null +++ b/packages/kernel-language-model-service/src/test-utils/index.ts @@ -0,0 +1,4 @@ +export { makeQueueService } from './queue/service.ts'; +export { makeQueueModel } from './queue/model.ts'; +export type { QueueLanguageModel } from './queue/model.ts'; +export type { QueueLanguageModelService } from './queue/service.ts'; diff --git a/packages/kernel-language-model-service/src/queue/model.test.ts b/packages/kernel-language-model-service/src/test-utils/queue/model.test.ts similarity index 100% rename from packages/kernel-language-model-service/src/queue/model.test.ts rename to packages/kernel-language-model-service/src/test-utils/queue/model.test.ts diff --git a/packages/kernel-language-model-service/src/queue/model.ts b/packages/kernel-language-model-service/src/test-utils/queue/model.ts similarity index 97% rename from packages/kernel-language-model-service/src/queue/model.ts rename to packages/kernel-language-model-service/src/test-utils/queue/model.ts index 9c3490713..c30ebe017 100644 --- a/packages/kernel-language-model-service/src/queue/model.ts +++ b/packages/kernel-language-model-service/src/test-utils/queue/model.ts @@ -1,4 +1,3 @@ -import type { LanguageModel, ModelInfo } from '../types.ts'; import { objectResponseFormatter } from './response.ts'; import type { ResponseFormatter } from './response.ts'; import type { Tokenizer } from './tokenizer.ts'; @@ -10,6 +9,7 @@ import { normalizeToAsyncIterable, } from './utils.ts'; import type { StreamWithAbort } from './utils.ts'; +import type { LanguageModel, ModelInfo } from '../../types.ts'; /** * Queue-based language model with helper methods for configuring responses. diff --git a/packages/kernel-language-model-service/src/queue/response.test.ts b/packages/kernel-language-model-service/src/test-utils/queue/response.test.ts similarity index 100% rename from packages/kernel-language-model-service/src/queue/response.test.ts rename to packages/kernel-language-model-service/src/test-utils/queue/response.test.ts diff --git a/packages/kernel-language-model-service/src/queue/response.ts b/packages/kernel-language-model-service/src/test-utils/queue/response.ts similarity index 100% rename from packages/kernel-language-model-service/src/queue/response.ts rename to packages/kernel-language-model-service/src/test-utils/queue/response.ts diff --git a/packages/kernel-language-model-service/src/queue/service.test.ts b/packages/kernel-language-model-service/src/test-utils/queue/service.test.ts similarity index 100% rename from packages/kernel-language-model-service/src/queue/service.test.ts rename to packages/kernel-language-model-service/src/test-utils/queue/service.test.ts diff --git a/packages/kernel-language-model-service/src/queue/service.ts b/packages/kernel-language-model-service/src/test-utils/queue/service.ts similarity index 96% rename from packages/kernel-language-model-service/src/queue/service.ts rename to packages/kernel-language-model-service/src/test-utils/queue/service.ts index 6b702726a..c2ec5269a 100644 --- a/packages/kernel-language-model-service/src/queue/service.ts +++ b/packages/kernel-language-model-service/src/test-utils/queue/service.ts @@ -1,8 +1,8 @@ -import type { InstanceConfig, LanguageModelService } from '../types.ts'; import type { QueueLanguageModel } from './model.ts'; import { makeQueueModel } from './model.ts'; import type { ObjectResponse, ResponseFormatter } from './response.ts'; import type { Tokenizer } from './tokenizer.ts'; +import type { InstanceConfig, LanguageModelService } from '../../types.ts'; type QueueLanguageModelServiceConfig = { tokenizer?: Tokenizer; diff --git a/packages/kernel-language-model-service/src/queue/tokenizer.test.ts b/packages/kernel-language-model-service/src/test-utils/queue/tokenizer.test.ts similarity index 100% rename from packages/kernel-language-model-service/src/queue/tokenizer.test.ts rename to packages/kernel-language-model-service/src/test-utils/queue/tokenizer.test.ts diff --git a/packages/kernel-language-model-service/src/queue/tokenizer.ts b/packages/kernel-language-model-service/src/test-utils/queue/tokenizer.ts similarity index 100% rename from packages/kernel-language-model-service/src/queue/tokenizer.ts rename to packages/kernel-language-model-service/src/test-utils/queue/tokenizer.ts diff --git a/packages/kernel-language-model-service/src/queue/utils.test.ts b/packages/kernel-language-model-service/src/test-utils/queue/utils.test.ts similarity index 100% rename from packages/kernel-language-model-service/src/queue/utils.test.ts rename to packages/kernel-language-model-service/src/test-utils/queue/utils.test.ts diff --git a/packages/kernel-language-model-service/src/queue/utils.ts b/packages/kernel-language-model-service/src/test-utils/queue/utils.ts similarity index 100% rename from packages/kernel-language-model-service/src/queue/utils.ts rename to packages/kernel-language-model-service/src/test-utils/queue/utils.ts diff --git a/packages/kernel-test/src/vats/lms-queue-vat.js b/packages/kernel-test/src/vats/lms-queue-vat.js index 4097bc448..770e995fe 100644 --- a/packages/kernel-test/src/vats/lms-queue-vat.js +++ b/packages/kernel-test/src/vats/lms-queue-vat.js @@ -1,5 +1,5 @@ import { makeDefaultExo } from '@metamask/kernel-utils/exo'; -import { makeQueueService } from '@ocap/kernel-language-model-service/queue'; +import { makeQueueService } from '@ocap/kernel-language-model-service/test-utils'; import { makeExoGenerator } from '@ocap/remote-iterables'; /** From 81a330560330656360d4449eafb0bb22a6bec3e4 Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 17 Dec 2025 14:04:06 -0600 Subject: [PATCH 7/8] thresholds --- vitest.config.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/vitest.config.ts b/vitest.config.ts index 4a8336196..607ab8890 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -98,10 +98,10 @@ export default defineConfig({ lines: 100, }, 'packages/kernel-language-model-service/**': { - statements: 99.17, - functions: 100, - branches: 97.22, - lines: 99.17, + statements: 98.35, + functions: 96.96, + branches: 95.89, + lines: 98.35, }, 'packages/kernel-platforms/**': { statements: 99.38, From 92770054e3da0158c16b6703020ada54c43a2a3c Mon Sep 17 00:00:00 2001 From: grypez <143971198+grypez@users.noreply.github.com> Date: Wed, 17 Dec 2025 14:39:56 -0600 Subject: [PATCH 8/8] docs: Add README --- .../src/test-utils/queue/README.md | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 packages/kernel-language-model-service/src/test-utils/queue/README.md diff --git a/packages/kernel-language-model-service/src/test-utils/queue/README.md b/packages/kernel-language-model-service/src/test-utils/queue/README.md new file mode 100644 index 000000000..9b16967a8 --- /dev/null +++ b/packages/kernel-language-model-service/src/test-utils/queue/README.md @@ -0,0 +1,49 @@ +# Queue-based Language Model Service (Testing Utility) + +[`makeQueueService`](./service.ts) is a testing utility that creates a `LanguageModelService` implementation for use in tests. It provides a queue-based language model where responses are manually queued using the `push()` method and consumed by `sample()` calls. + +## Usage + +1. Create a service using `makeQueueService()` +2. Create a model instance using `makeInstance()` +3. Queue responses using `push()` on the model instance +4. Consume responses by calling `sample()` + +Note that `makeInstance` and `sample` ignore their arguments, but expect them nonetheless. + +## Examples + +### Basic Example + +```typescript +import { makeQueueService } from '@ocap/kernel-language-model-service/test-utils'; + +const service = makeQueueService(); +const model = await service.makeInstance({ model: 'test' }); + +// Queue a response +model.push('Hello, world!'); + +// Consume the response +const result = await model.sample({ prompt: 'Say hello' }); +for await (const chunk of result.stream) { + console.log(chunk.response); // 'Hello, world!' +} +``` + +### Multiple Queued Responses + +```typescript +const service = makeQueueService(); +const model = await service.makeInstance({ model: 'test' }); + +// Queue multiple responses +model.push('First response'); +model.push('Second response'); + +// Each sample() call consumes the next queued response +const first = await model.sample({ prompt: 'test' }); +const second = await model.sample({ prompt: 'test' }); + +// Process streams... +```