Files
asepharyana e709737df1
ci / check (macos-latest) (push) Canceled after 0s
ci / check (ubuntu-latest) (push) Canceled after 0s
ci / check (windows-latest) (push) Canceled after 0s
features: /changes, /search, /fork, web_search, prompt memoization, per-turn spend cap, workspace refresh
- /changes diffs the last turn's file snapshot (added/modified/deleted), reusing undo infra via SnapshotStack.peek()
- system prompt memoized behind version counters (notebook/memory/skills/plugins/tools/workspace); hit-rate in /cost, foundation for provider caching
- web_search: DuckDuckGo Lite, keyless, 5 results, SSRF-filtered, in the net set with ask permission
- /search <query>: full-text grep over saved sessions incl. tool-input JSON
- workspace file list re-walks at a turn boundary after writes
- maxSpendPerTurn: per-turn cap stops a runaway step with a notice
- /fork: branch the session at the last turn boundary, original untouched

821 tests pass, typecheck clean, build green
2026-09-09 18:06:31 +07:00

153 lines
5.0 KiB
TypeScript

import { afterEach, beforeEach, expect, test } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ModelMessage } from 'ai';
import * as store from '../src/store';
let home: string;
let origHome: string | undefined;
beforeEach(() => {
origHome = process.env['SHIRO_HOME'];
home = mkdtempSync(join(tmpdir(), 'shiro-home-'));
process.env['SHIRO_HOME'] = home;
});
afterEach(() => {
if (origHome === undefined) delete process.env['SHIRO_HOME'];
else process.env['SHIRO_HOME'] = origHome;
rmSync(home, { recursive: true, force: true });
});
const messages: ModelMessage[] = [
{ role: 'user', content: 'add pagination to /users' },
{ role: 'assistant', content: 'Done.' },
];
const rec = (id: string, cwd = '/repo'): store.SessionRecord => ({
id,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
cwd,
provider: 'openai',
model: 'gpt-5',
title: store.titleOf(messages),
inputTokens: 100,
outputTokens: 20,
messages,
});
test('list on a fresh install returns nothing instead of throwing', async () => {
expect(await store.list()).toEqual([]);
expect(await store.latest()).toBeUndefined();
expect(await store.resolveId('anything')).toBeUndefined();
});
test('save then load round-trips messages and metadata', async () => {
await store.save(rec('aaa'));
const back = await store.load('aaa');
expect(back?.messages).toEqual(messages);
expect(back?.model).toBe('gpt-5');
expect(back?.inputTokens).toBe(100);
});
test('load of an unknown id returns undefined instead of throwing', async () => {
expect(await store.load('nope')).toBeUndefined();
});
test('searchSessions finds a phrase inside transcripts', async () => {
await store.save(rec('aaa'));
await store.save({ ...rec('bbb'), messages: [{ role: 'user', content: 'fix the websocket reconnect' }] });
const hits = await store.searchSessions('websocket');
expect(hits.map((r) => r.id)).toEqual(['bbb']);
// case-insensitive
const lower = await store.searchSessions('WEBSOCKET');
expect(lower.map((r) => r.id)).toEqual(['bbb']);
});
test('searchSessions matches tool-input JSON, so a path is findable', async () => {
await store.save({
...rec('aaa'),
messages: [
{ role: 'user', content: 'update the config' },
{
role: 'assistant',
content: [
{
type: 'tool-call',
toolCallId: 'c1',
toolName: 'edit_file',
input: { path: 'src/config.ts', oldString: 'a', newString: 'b' },
},
],
},
],
});
const hits = await store.searchSessions('src/config.ts');
expect(hits.map((r) => r.id)).toEqual(['aaa']);
});
test('searchSessions on an empty query returns nothing', async () => {
expect(await store.searchSessions(' ')).toEqual([]);
});
test('save stamps updatedAt so list can order by recency', async () => {
await store.save(rec('aaa'));
const back = await store.load('aaa');
expect(back?.updatedAt).not.toBe('2026-01-01T00:00:00.000Z');
});
test('list returns newest first', async () => {
await store.save(rec('older'));
await Bun.sleep(5);
await store.save(rec('newer'));
const all = await store.list();
expect(all.map((r) => r.id)).toEqual(['newer', 'older']);
});
test('latest filters by working directory', async () => {
await store.save(rec('other', '/elsewhere'));
await Bun.sleep(5);
await store.save(rec('mine', '/repo'));
expect((await store.latest('/repo'))?.id).toBe('mine');
expect((await store.latest('/elsewhere'))?.id).toBe('other');
expect((await store.latest('/nothing-here'))).toBeUndefined();
});
test('resolveId accepts a full id or a unique prefix, rejects an ambiguous one', async () => {
await store.save(rec('0193aaaa-1'));
await store.save(rec('0193bbbb-2'));
expect(await store.resolveId('0193aaaa-1')).toBe('0193aaaa-1');
expect(await store.resolveId('0193a')).toBe('0193aaaa-1');
expect(await store.resolveId('0193')).toBeUndefined();
expect(await store.resolveId('zzz')).toBeUndefined();
});
test('a corrupt session file is skipped, not fatal', async () => {
await Bun.write(join(home, '.shiro-neko', 'sessions', 'broken.json'), '{ not json');
await store.save(rec('good'));
expect(await store.load('broken')).toBeUndefined();
expect((await store.list()).map((r) => r.id)).toEqual(['good']);
});
test('the notebook survives a save and load', async () => {
await store.save({
...rec('with-notebook'),
notebook: { todos: [{ content: 'finish the parser', status: 'in_progress' }] },
});
const back = await store.load('with-notebook');
expect(back?.notebook?.todos).toEqual([{ content: 'finish the parser', status: 'in_progress' }]);
});
test('titleOf uses the first user message and truncates', () => {
expect(store.titleOf(messages)).toBe('add pagination to /users');
expect(store.titleOf([])).toBe('untitled');
const long = store.titleOf([{ role: 'user', content: 'x'.repeat(100) }]);
expect(long.endsWith('...')).toBe(true);
expect(long.length).toBe(63);
});