Merge remote-tracking branch 'refs/remotes/upstream/main'
# Conflicts: # ROADMAP.md # TODO.md # src/cli.tsx # src/config.ts # src/mcp.ts # src/permission.ts # src/prompt.ts # src/session.ts # src/snapshot.ts # src/subagent.ts # src/tools-extra.ts # src/tools.ts # src/ui/App.tsx # test/mcp.test.ts # test/prune.test.ts # test/session.test.ts # test/tools.test.ts
This commit is contained in:
+104
-22
@@ -1,25 +1,19 @@
|
||||
import { usageOf } from './helpers';
|
||||
import { generateResult, streamOf, textChunks, usageOf } from './helpers';
|
||||
import { expect, test } from 'bun:test';
|
||||
import { MockLanguageModelV4, simulateReadableStream } from 'ai/test';
|
||||
import { MockLanguageModelV4 } from 'ai/test';
|
||||
import type { LanguageModelV4CallOptions, LanguageModelV4StreamPart } from '@ai-sdk/provider';
|
||||
import { APICallError, type ModelMessage } from 'ai';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Session, type AgentEvent } from '../src/session';
|
||||
import { isPrunedSpanSummary, PRUNED_SPAN_PREFIX } from '../src/prune';
|
||||
|
||||
const usage = usageOf(10);
|
||||
|
||||
const stream = (parts: LanguageModelV4StreamPart[]) => ({
|
||||
stream: simulateReadableStream({ chunks: parts, chunkDelayInMs: null, initialDelayInMs: null }),
|
||||
});
|
||||
const stream = (parts: LanguageModelV4StreamPart[]) => streamOf(parts);
|
||||
|
||||
const text = (body: string): LanguageModelV4StreamPart[] => [
|
||||
{ type: 'text-start', id: '0' },
|
||||
{ type: 'text-delta', id: '0', delta: body },
|
||||
{ type: 'text-end', id: '0' },
|
||||
{ type: 'finish', finishReason: { unified: 'stop', raw: 'stop' }, usage },
|
||||
];
|
||||
const text = (body: string) => textChunks(body, usage);
|
||||
|
||||
/** One assistant turn carrying a bulky tool call plus its result. */
|
||||
function bulkyExchange(i: number): ModelMessage[] {
|
||||
@@ -86,7 +80,9 @@ test('history over the threshold is pruned before reaching the model', async ()
|
||||
const sentSize = JSON.stringify(seen[0]?.prompt).length;
|
||||
expect(sentSize).toBeLessThan(JSON.stringify(messages).length);
|
||||
|
||||
// Pruning is for the wire only; the local history keeps every message.
|
||||
// Pruning is for the wire only; the local history keeps every message. A
|
||||
// history left at its full size pins the context meter, but re-pruning from
|
||||
// scratch every turn is the cost of keeping a complete local record.
|
||||
expect(session.messages.length).toBeGreaterThan(messages.length);
|
||||
expect(events).toContain('compacted');
|
||||
});
|
||||
@@ -132,12 +128,7 @@ test('summarize replaces the whole history with one summary message', async () =
|
||||
model: new MockLanguageModelV4({
|
||||
doGenerate: async () => {
|
||||
generateCalls++;
|
||||
return {
|
||||
content: [{ type: 'text', text: '- goal: add pagination\n- touched: src/users.ts\n- todo: add tests' }],
|
||||
finishReason: { unified: 'stop', raw: 'stop' },
|
||||
usage,
|
||||
warnings: [],
|
||||
} as any;
|
||||
return generateResult('- goal: add pagination\n- touched: src/users.ts\n- todo: add tests', usage);
|
||||
},
|
||||
}),
|
||||
askApproval: async () => 'deny',
|
||||
@@ -156,7 +147,7 @@ test('summarize replaces the whole history with one summary message', async () =
|
||||
|
||||
test('summarize on an empty session is a no-op', async () => {
|
||||
const session = new Session({
|
||||
model: new MockLanguageModelV4({ doGenerate: async () => ({}) as any }),
|
||||
model: new MockLanguageModelV4({ doGenerate: async () => generateResult('') }),
|
||||
askApproval: async () => 'deny',
|
||||
});
|
||||
expect(await session.summarize()).toEqual({ before: 0, after: 0 });
|
||||
@@ -379,7 +370,7 @@ test('lossless compaction appends a retained note when tool content was dropped'
|
||||
for await (const ev of session.send('next')) events.push(ev);
|
||||
expect(generateCalls).toBe(1);
|
||||
expect(events.some((e) => e.type === 'compacted')).toBe(true);
|
||||
expect(session.messages.some((m) => String(m.content).includes('retained from compacted'))).toBe(true);
|
||||
expect(session.messages.some((m) => m.role === 'user' && String(m.content).startsWith('Earlier in this session, now compacted away:'))).toBe(true);
|
||||
});
|
||||
|
||||
test('no retained note when history fits', async () => {
|
||||
@@ -399,7 +390,7 @@ test('no retained note when history fits', async () => {
|
||||
});
|
||||
for await (const _ of session.send('next')) void _;
|
||||
expect(generateCalls).toBe(0);
|
||||
expect(session.messages.some((m) => String(m.content).includes('retained from compacted'))).toBe(false);
|
||||
expect(session.messages.some((m) => m.role === 'user' && String(m.content).startsWith('Earlier in this session, now compacted away:'))).toBe(false);
|
||||
});
|
||||
|
||||
test('a failing retained-note model does not break the turn', async () => {
|
||||
@@ -418,7 +409,8 @@ test('a failing retained-note model does not break the turn', async () => {
|
||||
for await (const ev of session.send('next')) events.push(ev);
|
||||
expect(events.map((e) => e.type)).toContain('done');
|
||||
expect(events.map((e) => e.type)).not.toContain('error');
|
||||
expect(session.messages.some((m) => String(m.content).includes('retained from compacted'))).toBe(false);
|
||||
// A failing summarizer still leaves the digest fallback — the turn is not broken.
|
||||
expect(session.messages.some((m) => m.role === 'user' && String(m.content).startsWith('Earlier in this session, now compacted away:'))).toBe(true);
|
||||
});
|
||||
|
||||
const staleItem = (id: string) =>
|
||||
@@ -514,3 +506,93 @@ test('a stale item arriving after text was streamed is reported, not silently re
|
||||
expect(call).toBe(1);
|
||||
});
|
||||
|
||||
|
||||
test('a pruned span is replaced by a summary of what was dropped', async () => {
|
||||
const messages = [
|
||||
{ role: 'user' as const, content: 'we decided on the ladder approach' },
|
||||
...bulkyExchange(0),
|
||||
...bulkyExchange(1),
|
||||
...bulkyExchange(2),
|
||||
...bulkyExchange(3),
|
||||
];
|
||||
const session = new Session({
|
||||
model: new MockLanguageModelV4({
|
||||
doStream: async () => stream(text('ok')),
|
||||
doGenerate: async () => generateResult('chose the ladder; touched f0-f3.ts'),
|
||||
}),
|
||||
askApproval: async () => 'deny',
|
||||
messages: [...messages],
|
||||
compactThreshold: 1000,
|
||||
});
|
||||
|
||||
for await (const _ of session.send('next')) void _;
|
||||
|
||||
const span = session.messages.filter((m) => isPrunedSpanSummary(m));
|
||||
expect(span).toHaveLength(1);
|
||||
expect(String(span[0]?.content)).toContain('chose the ladder');
|
||||
});
|
||||
|
||||
test('the span summary is injected before the surviving history, not after', async () => {
|
||||
const messages = [...bulkyExchange(0), ...bulkyExchange(1), ...bulkyExchange(2), ...bulkyExchange(3)];
|
||||
const session = new Session({
|
||||
model: new MockLanguageModelV4({
|
||||
doStream: async () => stream(text('ok')),
|
||||
doGenerate: async () => generateResult('summary of the dropped span'),
|
||||
}),
|
||||
askApproval: async () => 'deny',
|
||||
messages: [...messages],
|
||||
compactThreshold: 1000,
|
||||
});
|
||||
|
||||
for await (const _ of session.send('next')) void _;
|
||||
|
||||
const markerAt = session.messages.findIndex((m) => isPrunedSpanSummary(m));
|
||||
expect(markerAt).toBe(0);
|
||||
});
|
||||
|
||||
test('a summarizer that throws still leaves a digest rather than nothing', async () => {
|
||||
const messages = [...bulkyExchange(0), ...bulkyExchange(1), ...bulkyExchange(2), ...bulkyExchange(3)];
|
||||
const session = new Session({
|
||||
model: new MockLanguageModelV4({
|
||||
doStream: async () => stream(text('ok')),
|
||||
doGenerate: async () => {
|
||||
throw new Error('summarizer is down');
|
||||
},
|
||||
}),
|
||||
askApproval: async () => 'deny',
|
||||
messages: [...messages],
|
||||
compactThreshold: 1000,
|
||||
});
|
||||
|
||||
for await (const _ of session.send('next')) void _;
|
||||
|
||||
const span = session.messages.find((m) => isPrunedSpanSummary(m));
|
||||
expect(span).toBeDefined();
|
||||
expect(String(span?.content)).toContain('f0.ts');
|
||||
});
|
||||
|
||||
test('repeated compaction does not nest one span summary inside another', async () => {
|
||||
const messages = [...bulkyExchange(0), ...bulkyExchange(1), ...bulkyExchange(2), ...bulkyExchange(3)];
|
||||
const session = new Session({
|
||||
model: new MockLanguageModelV4({
|
||||
doStream: async () => stream(text('ok')),
|
||||
doGenerate: async () => generateResult('span notes'),
|
||||
}),
|
||||
askApproval: async () => 'deny',
|
||||
messages: [...messages],
|
||||
compactThreshold: 1000,
|
||||
});
|
||||
|
||||
for await (const _ of session.send('next')) void _;
|
||||
for await (const _ of session.send('again')) void _;
|
||||
for await (const _ of session.send('and again')) void _;
|
||||
|
||||
const spans = session.messages.filter((m) => isPrunedSpanSummary(m));
|
||||
// One summary is kept from each compaction, but no summary may contain the
|
||||
// marker text, which would mean a summary of a summary.
|
||||
for (const s of spans) {
|
||||
const body = String(s.content).slice(PRUNED_SPAN_PREFIX.length);
|
||||
expect(body).not.toContain(PRUNED_SPAN_PREFIX);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user