Merge remote-tracking branch 'refs/remotes/upstream/main'
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s

# 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:
asepharyana
2026-09-21 20:43:35 +07:00
59 changed files with 3481 additions and 463 deletions
+104 -22
View File
@@ -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);
}
});