This commit is contained in:
Oleg Maslov
2026-09-02 10:14:22 +02:00
parent 0c3e2ead3b
commit b20b138fe4
771 changed files with 161561 additions and 9027 deletions

View File

@@ -35,6 +35,7 @@ import {
import { buildLocalServer } from '../../packages/server/src/local/index.js';
import type { AgentRunner } from '../../packages/server/src/local/routes/chat.js';
import type { AgentResponse } from '../../packages/agent/src/agent-loop.js';
import { chatSessionStateKey, loadSessionMessages } from '../../packages/server/src/local/routes/chat-persistence.js';
// ── Helpers ──────────────────────────────────────────────────────────────────
@@ -78,6 +79,7 @@ const echoRunner: AgentRunner = async (config): Promise<AgentResponse> => {
/** AgentRunner that exercises tool callbacks before returning. */
const toolRunner: AgentRunner = async (config): Promise<AgentResponse> => {
config.onToken?.('I will inspect the relevant memories first. ');
config.onToolUse?.('search_memory', { query: 'test query' });
config.onToolResult?.('search_memory', { query: 'test query' }, 'Found 2 memories');
config.onToken?.('Done.');
@@ -188,11 +190,14 @@ describe('POST /api/chat HTTP pipeline (live server)', () => {
let serverInst: Awaited<ReturnType<typeof buildLocalServer>>;
let baseUrl: string;
let authToken: string;
let activeWorkspaceId: string;
beforeAll(async () => {
const tmpDir = makeTmpDir();
serverInst = await buildLocalServer({ dataDir: tmpDir });
activeWorkspaceId = serverInst.agentState.activeWorkspaceId!;
expect(activeWorkspaceId).toBeTruthy();
// Inject the echo runner — bypasses LiteLLM health check and real LLM calls
serverInst.agentRunner = echoRunner;
@@ -278,6 +283,58 @@ describe('POST /api/chat HTTP pipeline (live server)', () => {
// ── SSE stream content ─────────────────────────────────────────────────
it('commits SSE headers before a slow first model token', async () => {
const originalRunner = serverInst.agentRunner;
let releaseRunner!: () => void;
let resolveStarted!: () => void;
const started = new Promise<void>((resolve) => { resolveStarted = resolve; });
const release = new Promise<void>((resolve) => { releaseRunner = resolve; });
serverInst.agentRunner = async (config): Promise<AgentResponse> => {
resolveStarted();
await release;
config.onToken?.('Delayed response');
return {
content: 'Delayed response',
toolsUsed: [],
usage: { inputTokens: 10, outputTokens: 2 },
};
};
try {
const responsePromise = fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
workspaceId: 'slow-first-token',
sessionId: 'slow-first-token',
persona: 'writer',
message: 'Rewrite this in fewer words and add no new claims: The launch is delayed.',
}),
});
await started;
const headersReady = await Promise.race([
responsePromise.then(() => true),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 1_000)),
]);
releaseRunner();
const res = await responsePromise;
const body = await res.text();
expect(headersReady).toBe(true);
expect(res.headers.get('content-type')).toContain('text/event-stream');
expect(parseSSE(body).some(event => event.type === 'done')).toBe(true);
} finally {
releaseRunner?.();
serverInst.agentRunner = originalRunner;
}
});
it('emits SSE content-type header', async () => {
const res = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
@@ -288,6 +345,7 @@ describe('POST /api/chat HTTP pipeline (live server)', () => {
body: JSON.stringify({ message: 'hello waggle', workspace: 'default' }),
});
expect(res.headers.get('content-type')).toContain('text/event-stream');
await res.text();
});
it('emits token events and a done event with correct content', async () => {
@@ -305,6 +363,10 @@ describe('POST /api/chat HTTP pipeline (live server)', () => {
// Must have at least one token event
const tokenEvents = events.filter(e => e.type === 'token');
expect(tokenEvents.length).toBeGreaterThanOrEqual(1);
const tokenChunks = tokenEvents.map(
e => (e.data as { content: string }).content,
);
expect(tokenChunks).toEqual(['Hello ', 'from ', 'Waggle!']);
// Must have exactly one done event
const doneEvents = events.filter(e => e.type === 'done');
@@ -313,6 +375,7 @@ describe('POST /api/chat HTTP pipeline (live server)', () => {
// Done event must include content, usage, and toolsUsed
const done = doneEvents[0].data as { content: string; usage: object; toolsUsed: string[] };
expect(done.content).toBe('Hello from Waggle!');
expect(tokenChunks.join('')).toBe(done.content);
expect(done.usage).toBeDefined();
expect(Array.isArray(done.toolsUsed)).toBe(true);
});
@@ -320,28 +383,140 @@ describe('POST /api/chat HTTP pipeline (live server)', () => {
it('emits step + tool + tool_result events when runner uses tool callbacks', async () => {
// Swap to the tool runner for this test
serverInst.agentRunner = toolRunner;
try {
const res = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: 'search my memory', workspace: 'default' }),
});
const body = await res.text();
const events = parseSSE(body);
const toolIndex = events.findIndex(e => e.type === 'tool');
const toolResultIndex = events.findIndex(e => e.type === 'tool_result');
const tokenIndex = events.findIndex(e => e.type === 'token');
const tokenContent = events
.filter(e => e.type === 'token')
.map(e => (e.data as { content: string }).content)
.join('');
const doneEvents = events.filter(e => e.type === 'done');
const res = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${authToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ message: 'search my memory', workspace: 'default' }),
});
const body = await res.text();
const events = parseSSE(body);
expect(toolIndex).toBeGreaterThanOrEqual(0);
expect(toolResultIndex).toBeGreaterThan(toolIndex);
expect(tokenIndex).toBeGreaterThan(toolResultIndex);
expect(doneEvents).toHaveLength(1);
expect(events.some(e => e.type === 'tool')).toBe(true);
expect(events.some(e => e.type === 'tool_result')).toBe(true);
expect(events.some(e => e.type === 'done')).toBe(true);
// Restore echo runner for subsequent tests
serverInst.agentRunner = echoRunner;
const done = doneEvents[0].data as { content: string };
expect(tokenContent).toBe('Done.');
expect(tokenContent).not.toContain('I will inspect');
expect(tokenContent).toBe(done.content);
} finally {
// Restore echo runner for subsequent tests even when an assertion fails.
serverInst.agentRunner = echoRunner;
}
});
// ── Session history ────────────────────────────────────────────────────
it('aborts active provider/tool work without persisting a partial assistant turn', async () => {
const originalRunner = serverInst.agentRunner;
const session = `session-abort-test-${Date.now()}`;
const message = 'stop this active tool run';
let releaseRunner: (() => void) | undefined;
let resolveStarted!: () => void;
const started = new Promise<void>((resolve) => { resolveStarted = resolve; });
let resolveStopped!: () => void;
const stopped = new Promise<void>((resolve) => { resolveStopped = resolve; });
let workTicks = 0;
serverInst.agentRunner = (config) => new Promise<AgentResponse>((resolve, reject) => {
config.onToken?.('partial answer that is not authoritative');
const interval = setInterval(() => { workTicks++; }, 5);
resolveStarted();
const stopWork = () => {
clearInterval(interval);
resolveStopped();
const error = new Error('chat aborted');
error.name = 'AbortError';
reject(error);
};
if (config.signal?.aborted) stopWork();
else config.signal?.addEventListener('abort', stopWork, { once: true });
releaseRunner = () => {
clearInterval(interval);
resolve({
content: 'fabricated completion after the client left',
toolsUsed: ['slow_tool'],
usage: { inputTokens: 1, outputTokens: 1 },
});
};
});
const previousLocalStorage = globalThis.localStorage;
const storageValues = new Map<string, string>();
const testStorage: Storage = {
get length() { return storageValues.size; },
clear: () => storageValues.clear(),
getItem: (key) => storageValues.get(key) ?? null,
key: (index) => [...storageValues.keys()][index] ?? null,
removeItem: (key) => { storageValues.delete(key); },
setItem: (key, value) => { storageValues.set(key, value); },
};
Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: testStorage });
let client: InstanceType<(typeof import('../../apps/web/src/lib/adapter.js'))['default']> | undefined;
let events: AsyncGenerator<import('../../apps/web/src/lib/types.js').StreamEvent> | undefined;
try {
const { default: LocalAdapter } = await import('../../apps/web/src/lib/adapter.js');
client = new LocalAdapter(baseUrl);
events = client.sendMessage('default', message, session);
const pendingEvent = events.next().then(
(result) => result,
(error: unknown) => error,
);
await started;
await client.abortAgent('default');
await Promise.race([
stopped,
new Promise<never>((_, reject) => setTimeout(
() => reject(new Error('agent work did not stop after client abort')),
1_000,
)),
]);
const ticksAtStop = workTicks;
await new Promise((resolve) => setTimeout(resolve, 30));
expect(workTicks).toBe(ticksAtStop);
await expect(pendingEvent).resolves.toMatchObject({ name: 'AbortError' });
// Let the route's abort catch/finally finish before inspecting both stores.
await new Promise((resolve) => setTimeout(resolve, 30));
expect(serverInst.agentState.sessionHistories.get(
chatSessionStateKey(activeWorkspaceId, session),
)).toEqual([
{ role: 'user', content: message },
]);
expect(loadSessionMessages(
serverInst.localConfig.dataDir,
activeWorkspaceId,
session,
)).toEqual([
expect.objectContaining({ role: 'user', content: message }),
]);
} finally {
await client?.abortAgent('default');
await events?.return(undefined);
releaseRunner?.();
serverInst.agentRunner = originalRunner;
if (previousLocalStorage === undefined) Reflect.deleteProperty(globalThis, 'localStorage');
else Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: previousLocalStorage });
}
});
it('accumulates session history across multiple turns in the same session', async () => {
const session = `session-history-test-${Date.now()}`;
@@ -366,7 +541,9 @@ describe('POST /api/chat HTTP pipeline (live server)', () => {
}).then(r => r.text());
// Verify server has accumulated 4 messages (user1, assistant1, user2, assistant2)
const history = serverInst.agentState.sessionHistories.get(session);
const history = serverInst.agentState.sessionHistories.get(
chatSessionStateKey(activeWorkspaceId, session),
);
expect(history).toBeDefined();
expect(history!.length).toBe(4);
expect(history![0]).toMatchObject({ role: 'user', content: 'first message' });
@@ -386,14 +563,18 @@ describe('POST /api/chat HTTP pipeline (live server)', () => {
body: JSON.stringify({ message: 'to be cleared', workspace: 'default', session }),
}).then(r => r.text());
expect(serverInst.agentState.sessionHistories.has(session)).toBe(true);
const stateKey = chatSessionStateKey(activeWorkspaceId, session);
expect(serverInst.agentState.sessionHistories.has(stateKey)).toBe(true);
// Clear it
const clearRes = await fetch(`${baseUrl}/api/chat/history?session=${session}`, {
const clearRes = await fetch(
`${baseUrl}/api/chat/history?workspace=${activeWorkspaceId}&session=${session}`,
{
method: 'DELETE',
headers: { 'Authorization': `Bearer ${authToken}` },
});
},
);
expect(clearRes.status).toBe(200);
expect(serverInst.agentState.sessionHistories.has(session)).toBe(false);
expect(serverInst.agentState.sessionHistories.has(stateKey)).toBe(false);
});
});