The panel stored notes, todos and plans inside one shared JSON file that six unrelated domains also wrote to, synchronised itself through window CustomEvents, and could only read plans. It is now Project knowledge: server-owned storage with explicit routes, a store with rollback, a section sidebar, plans that open and edit in place, and search across all of it. Notes and plans the user pins travel with every message sent in that project. Pinning is project state, not an attachment to one message, so it holds until unpinned and the work status panel names what is riding along and can detach it. Agent memory is added alongside, in two scopes: what is true about the user, and what is true about this codebase. The split is not cosmetic — a wrong project fact costs one project and is noticed, while a wrong global fact quietly shapes every session everywhere and the user has no code to check it against. It stays separate from notes so an agent mistake cannot land in what the user wrote. Sessions receive an index of titles only; bodies are read on demand, because an index carrying full text grows until it crowds out the conversation. Deciding what a session must be told, and whether it has been told, now lives on the server. The client owned it before, which meant sessions started without a UI — scheduled tasks, sessions the agent dispatches — received nothing at all, and a tab's record of what it had sent outlived the conversation: after compaction the agent no longer held the block while the tab went on believing it did. What was delivered is recorded in the session's own metadata, and compaction restores it through the runtime that already restores pinned messages, in the same turn. Agent memory ships dark behind OPENCHAMBER_MEMORY_ENABLE: unset, there is no tool, no routes, no session index, no settings row and no panel tab. Absent rather than switched off, so nothing invites turning on a feature that has not been announced. Pinned notes and plans are unaffected and ship as normal.
499 lines
20 KiB
JavaScript
499 lines
20 KiB
JavaScript
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
import fsPromises from 'node:fs/promises';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
|
|
import { createProjectContextRuntime, parsePlanMarkdown } from './runtime.js';
|
|
|
|
const PROJECT_ID = 'path_dGVzdA';
|
|
|
|
let projectsDirPath;
|
|
let runtime;
|
|
let idCounter;
|
|
|
|
const legacyConfigPath = () => path.join(projectsDirPath, `${PROJECT_ID}.json`);
|
|
const contextPath = () => path.join(projectsDirPath, PROJECT_ID, 'context.json');
|
|
const plansDir = () => path.join(projectsDirPath, PROJECT_ID, 'plans');
|
|
|
|
const writeJson = async (filePath, value) => {
|
|
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
|
|
await fsPromises.writeFile(filePath, JSON.stringify(value, null, 2), 'utf8');
|
|
};
|
|
|
|
const readJson = async (filePath) => JSON.parse(await fsPromises.readFile(filePath, 'utf8'));
|
|
|
|
beforeEach(async () => {
|
|
projectsDirPath = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-project-context-'));
|
|
idCounter = 0;
|
|
runtime = createProjectContextRuntime({
|
|
fsPromises,
|
|
path,
|
|
projectsDirPath,
|
|
createId: () => `plan-${++idCounter}`,
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fsPromises.rm(projectsDirPath, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('projectId validation', () => {
|
|
test('rejects traversal and empty ids', async () => {
|
|
await expect(runtime.readContext('../escape')).rejects.toThrow('unsupported characters');
|
|
await expect(runtime.readContext('a/b')).rejects.toThrow('unsupported characters');
|
|
await expect(runtime.readContext('')).rejects.toThrow('projectId is required');
|
|
});
|
|
});
|
|
|
|
describe('readContext', () => {
|
|
test('missing file is authoritative empty', async () => {
|
|
expect(await runtime.readContext(PROJECT_ID)).toEqual({
|
|
version: 2,
|
|
notes: [],
|
|
todos: [],
|
|
plans: [],
|
|
});
|
|
});
|
|
|
|
test('malformed stored context fails instead of reading as empty', async () => {
|
|
await fsPromises.mkdir(path.dirname(contextPath()), { recursive: true });
|
|
await fsPromises.writeFile(contextPath(), '{ not json', 'utf8');
|
|
await expect(runtime.readContext(PROJECT_ID)).rejects.toThrow('malformed');
|
|
});
|
|
|
|
test('drops malformed todo and plan entries without failing the read', async () => {
|
|
await writeJson(contextPath(), {
|
|
version: 2,
|
|
notes: [{ id: 'n1', body: 'kept', createdAt: 1, updatedAt: 1, source: 'manual' }],
|
|
todos: [{ id: 'a', text: 'ok', completed: false, createdAt: 1 }, { id: '', text: 'no id' }, { text: 'no id' }],
|
|
plans: [
|
|
{ id: 'p1', file: 'a.md', title: 'A', createdAt: 2 },
|
|
{ id: 'p2', file: '../escape.md', title: 'Bad', createdAt: 3 },
|
|
{ id: 'p3', file: 'no-extension', createdAt: 4 },
|
|
],
|
|
});
|
|
|
|
const context = await runtime.readContext(PROJECT_ID);
|
|
expect(context.notes.map((note) => note.body)).toEqual(['kept']);
|
|
expect(context.todos.map((todo) => todo.id)).toEqual(['a']);
|
|
expect(context.plans.map((plan) => plan.id)).toEqual(['p1']);
|
|
});
|
|
|
|
test('clamps a note body to the maximum length', async () => {
|
|
await writeJson(contextPath(), {
|
|
version: 2,
|
|
notes: [{ id: 'n1', body: 'x'.repeat(5000), createdAt: 1, updatedAt: 1 }],
|
|
todos: [],
|
|
plans: [],
|
|
});
|
|
expect((await runtime.readContext(PROJECT_ID)).notes[0].body).toHaveLength(3000);
|
|
});
|
|
|
|
test('converts a version 1 string note into a single entry', async () => {
|
|
await writeJson(contextPath(), { version: 1, notes: 'legacy blob', todos: [], plans: [] });
|
|
|
|
const notes = (await runtime.readContext(PROJECT_ID)).notes;
|
|
expect(notes).toHaveLength(1);
|
|
expect(notes[0].body).toBe('legacy blob');
|
|
expect(notes[0].source).toBe('manual');
|
|
expect(notes[0].pinned).toBe(false);
|
|
});
|
|
|
|
test('an empty version 1 string converts to no notes at all', async () => {
|
|
await writeJson(contextPath(), { version: 1, notes: ' ', todos: [], plans: [] });
|
|
expect((await runtime.readContext(PROJECT_ID)).notes).toEqual([]);
|
|
});
|
|
|
|
test('newest note is listed first', async () => {
|
|
await writeJson(contextPath(), {
|
|
version: 2,
|
|
notes: [
|
|
{ id: 'old', body: 'old', createdAt: 1, updatedAt: 1 },
|
|
{ id: 'new', body: 'new', createdAt: 9, updatedAt: 9 },
|
|
],
|
|
todos: [],
|
|
plans: [],
|
|
});
|
|
expect((await runtime.readContext(PROJECT_ID)).notes.map((note) => note.id)).toEqual(['new', 'old']);
|
|
});
|
|
});
|
|
|
|
describe('legacy migration', () => {
|
|
test('moves the three keys out of the client-owned config and preserves the rest', async () => {
|
|
await fsPromises.mkdir(plansDir(), { recursive: true });
|
|
await fsPromises.writeFile(path.join(plansDir(), '10-old.md'), '# Old plan\n\nbody here', 'utf8');
|
|
await writeJson(legacyConfigPath(), {
|
|
projectPath: '/tmp/test',
|
|
'setup-worktree': ['bun install'],
|
|
projectActions: [{ id: 'a', name: 'Dev', command: 'bun dev' }],
|
|
projectNotes: 'legacy notes',
|
|
projectTodos: [{ id: 't1', text: 'legacy todo', completed: true, createdAt: 5 }],
|
|
projectPlanFiles: [{ id: 'p1', path: path.join(plansDir(), '10-old.md'), createdAt: 10 }],
|
|
});
|
|
|
|
const context = await runtime.readContext(PROJECT_ID);
|
|
expect(context.notes.map((note) => note.body)).toEqual(['legacy notes']);
|
|
expect(context.todos).toEqual([{ id: 't1', text: 'legacy todo', completed: true, createdAt: 5 }]);
|
|
expect(context.plans).toEqual([{ id: 'p1', file: '10-old.md', title: 'Old plan', createdAt: 10, pinned: false }]);
|
|
|
|
const remaining = await readJson(legacyConfigPath());
|
|
expect(remaining).toEqual({
|
|
projectPath: '/tmp/test',
|
|
'setup-worktree': ['bun install'],
|
|
projectActions: [{ id: 'a', name: 'Dev', command: 'bun dev' }],
|
|
});
|
|
});
|
|
|
|
test('recovers a plan whose recorded path points outside the plans directory', async () => {
|
|
const strayPath = path.join(projectsDirPath, 'stray.md');
|
|
await fsPromises.writeFile(strayPath, '# Stray\n\nrecovered', 'utf8');
|
|
await writeJson(legacyConfigPath(), {
|
|
projectPlanFiles: [{ id: 'p1', path: strayPath, createdAt: 10 }],
|
|
});
|
|
|
|
const context = await runtime.readContext(PROJECT_ID);
|
|
expect(context.plans).toEqual([{ id: 'p1', file: 'stray.md', title: 'Stray', createdAt: 10, pinned: false }]);
|
|
expect(await fsPromises.readFile(path.join(plansDir(), 'stray.md'), 'utf8')).toContain('recovered');
|
|
});
|
|
|
|
test('drops a link whose markdown no longer exists anywhere', async () => {
|
|
await writeJson(legacyConfigPath(), {
|
|
projectNotes: 'kept',
|
|
projectPlanFiles: [{ id: 'gone', path: path.join(plansDir(), 'missing.md'), createdAt: 10 }],
|
|
});
|
|
|
|
const context = await runtime.readContext(PROJECT_ID);
|
|
expect(context.notes.map((note) => note.body)).toEqual(['kept']);
|
|
expect(context.plans).toEqual([]);
|
|
});
|
|
|
|
test('does not run when the legacy config holds no context keys', async () => {
|
|
await writeJson(legacyConfigPath(), { 'setup-worktree': ['bun install'] });
|
|
|
|
expect(await runtime.readContext(PROJECT_ID)).toEqual({ version: 2, notes: [], todos: [], plans: [] });
|
|
await expect(fsPromises.access(contextPath())).rejects.toThrow();
|
|
expect(await readJson(legacyConfigPath())).toEqual({ 'setup-worktree': ['bun install'] });
|
|
});
|
|
|
|
test('is idempotent across repeated reads', async () => {
|
|
await writeJson(legacyConfigPath(), { projectNotes: 'once', projectTodos: [] });
|
|
|
|
const first = await runtime.readContext(PROJECT_ID);
|
|
const second = await runtime.readContext(PROJECT_ID);
|
|
expect(second).toEqual(first);
|
|
expect(await readJson(legacyConfigPath())).toEqual({});
|
|
});
|
|
|
|
test('concurrent reads converge on the same migrated content', async () => {
|
|
await writeJson(legacyConfigPath(), { projectNotes: 'concurrent', projectTodos: [] });
|
|
|
|
const results = await Promise.all([
|
|
runtime.readContext(PROJECT_ID),
|
|
runtime.readContext(PROJECT_ID),
|
|
runtime.readContext(PROJECT_ID),
|
|
]);
|
|
for (const result of results) {
|
|
expect(result.notes.map((note) => note.body)).toEqual(['concurrent']);
|
|
}
|
|
expect((await readJson(contextPath())).notes[0].body).toBe('concurrent');
|
|
});
|
|
});
|
|
|
|
describe('todos', () => {
|
|
test('round-trips through disk', async () => {
|
|
await runtime.saveTodos(PROJECT_ID, [{ id: 't1', text: 'do it', completed: false, createdAt: 1 }]);
|
|
|
|
expect((await runtime.readContext(PROJECT_ID)).todos).toEqual([
|
|
{ id: 't1', text: 'do it', completed: false, createdAt: 1 },
|
|
]);
|
|
});
|
|
|
|
test('preserves notes and plans it does not write', async () => {
|
|
const { note } = await runtime.createNote(PROJECT_ID, { body: 'keep me' });
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Keep me', body: 'x' });
|
|
|
|
await runtime.saveTodos(PROJECT_ID, [{ id: 't1', text: 'todo', createdAt: 1 }]);
|
|
|
|
const context = await runtime.readContext(PROJECT_ID);
|
|
expect(context.notes.map((entry) => entry.id)).toEqual([note.id]);
|
|
expect(context.plans.map((entry) => entry.id)).toEqual([plan.id]);
|
|
});
|
|
|
|
test('serializes concurrent writes without losing one', async () => {
|
|
await Promise.all([
|
|
runtime.saveTodos(PROJECT_ID, [{ id: '1', text: 'one', createdAt: 1 }]),
|
|
runtime.saveTodos(PROJECT_ID, [{ id: '2', text: 'two', createdAt: 2 }]),
|
|
]);
|
|
|
|
expect((await runtime.readContext(PROJECT_ID)).todos).toHaveLength(1);
|
|
});
|
|
|
|
test('clamps oversized todo text', async () => {
|
|
await runtime.saveTodos(PROJECT_ID, [{ id: 't1', text: 'z'.repeat(300), createdAt: 1 }]);
|
|
expect((await runtime.readContext(PROJECT_ID)).todos[0].text).toHaveLength(120);
|
|
});
|
|
});
|
|
|
|
describe('notes', () => {
|
|
test('create returns the stored note and prepends it', async () => {
|
|
const first = await runtime.createNote(PROJECT_ID, { body: 'first' });
|
|
const second = await runtime.createNote(PROJECT_ID, { body: 'second' });
|
|
|
|
expect(first.note.source).toBe('manual');
|
|
expect(first.note.pinned).toBe(false);
|
|
expect(second.context.notes.map((note) => note.body)).toEqual(['second', 'first']);
|
|
});
|
|
|
|
test('create records provenance for a note distilled from a chat selection', async () => {
|
|
const { note } = await runtime.createNote(PROJECT_ID, {
|
|
body: 'insight',
|
|
source: 'selection',
|
|
origin: { sessionId: 'ses_1', messageId: 'msg_1' },
|
|
});
|
|
|
|
expect(note.source).toBe('selection');
|
|
expect(note.origin).toEqual({ sessionId: 'ses_1', messageId: 'msg_1' });
|
|
});
|
|
|
|
test('create drops an origin with no session', async () => {
|
|
const { note } = await runtime.createNote(PROJECT_ID, { body: 'x', origin: { messageId: 'msg_1' } });
|
|
expect(note.origin).toBeUndefined();
|
|
});
|
|
|
|
test('create rejects an empty body', async () => {
|
|
await expect(runtime.createNote(PROJECT_ID, { body: ' ' })).rejects.toThrow('body is required');
|
|
});
|
|
|
|
test('create clamps an oversized body', async () => {
|
|
const { note } = await runtime.createNote(PROJECT_ID, { body: 'y'.repeat(4000) });
|
|
expect(note.body).toHaveLength(3000);
|
|
});
|
|
|
|
test('update patches the body and bumps updatedAt without touching createdAt', async () => {
|
|
const { note } = await runtime.createNote(PROJECT_ID, { body: 'before' });
|
|
await new Promise((resolve) => setTimeout(resolve, 2));
|
|
|
|
const result = await runtime.updateNote(PROJECT_ID, note.id, { body: 'after' });
|
|
expect(result.note.body).toBe('after');
|
|
expect(result.note.createdAt).toBe(note.createdAt);
|
|
expect(result.note.updatedAt).toBeGreaterThan(note.updatedAt);
|
|
});
|
|
|
|
test('pinning alone leaves the body and updatedAt untouched', async () => {
|
|
const { note } = await runtime.createNote(PROJECT_ID, { body: 'body' });
|
|
|
|
const result = await runtime.updateNote(PROJECT_ID, note.id, { pinned: true });
|
|
expect(result.note.pinned).toBe(true);
|
|
expect(result.note.body).toBe('body');
|
|
expect(result.note.updatedAt).toBe(note.updatedAt);
|
|
});
|
|
|
|
test('update rejects an empty patch', async () => {
|
|
const { note } = await runtime.createNote(PROJECT_ID, { body: 'body' });
|
|
await expect(runtime.updateNote(PROJECT_ID, note.id, {})).rejects.toThrow('body or pinned is required');
|
|
});
|
|
|
|
test('update rejects blanking the body', async () => {
|
|
const { note } = await runtime.createNote(PROJECT_ID, { body: 'body' });
|
|
await expect(runtime.updateNote(PROJECT_ID, note.id, { body: ' ' })).rejects.toThrow('body is required');
|
|
});
|
|
|
|
test('update returns null for an unknown note', async () => {
|
|
expect(await runtime.updateNote(PROJECT_ID, 'missing', { body: 'x' })).toBeNull();
|
|
});
|
|
|
|
test('delete removes only the requested note', async () => {
|
|
const keep = await runtime.createNote(PROJECT_ID, { body: 'keep' });
|
|
const drop = await runtime.createNote(PROJECT_ID, { body: 'drop' });
|
|
|
|
const result = await runtime.deleteNote(PROJECT_ID, drop.note.id);
|
|
expect(result.deleted).toBe(true);
|
|
expect(result.context.notes.map((note) => note.id)).toEqual([keep.note.id]);
|
|
});
|
|
|
|
test('deleting an unknown note reports no deletion', async () => {
|
|
const result = await runtime.deleteNote(PROJECT_ID, 'missing');
|
|
expect(result.deleted).toBe(false);
|
|
});
|
|
|
|
test('refuses to grow past the note limit', async () => {
|
|
const notes = Array.from({ length: 200 }, (_unused, index) => ({
|
|
id: `n${index}`,
|
|
body: `note ${index}`,
|
|
createdAt: index,
|
|
updatedAt: index,
|
|
}));
|
|
await writeJson(contextPath(), { version: 2, notes, todos: [], plans: [] });
|
|
|
|
await expect(runtime.createNote(PROJECT_ID, { body: 'one too many' })).rejects.toThrow('at most 200 notes');
|
|
});
|
|
|
|
test('concurrent creates all survive', async () => {
|
|
await Promise.all([
|
|
runtime.createNote(PROJECT_ID, { body: 'a' }),
|
|
runtime.createNote(PROJECT_ID, { body: 'b' }),
|
|
runtime.createNote(PROJECT_ID, { body: 'c' }),
|
|
]);
|
|
|
|
const bodies = (await runtime.readContext(PROJECT_ID)).notes.map((note) => note.body);
|
|
expect(bodies.sort()).toEqual(['a', 'b', 'c']);
|
|
});
|
|
});
|
|
|
|
describe('plans', () => {
|
|
test('create writes markdown and returns a readable plan', async () => {
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'My Plan', body: 'step one' });
|
|
expect(plan.file).toMatch(/^\d+-my-plan\.md$/);
|
|
|
|
const read = await runtime.readPlan(PROJECT_ID, plan.id);
|
|
expect(read.title).toBe('My Plan');
|
|
expect(read.body).toBe('step one');
|
|
expect(read.raw).toBe('# My Plan\n\nstep one');
|
|
});
|
|
|
|
test('newest plan is listed first', async () => {
|
|
const first = await runtime.createPlan(PROJECT_ID, { title: 'First', body: 'a' });
|
|
await new Promise((resolve) => setTimeout(resolve, 2));
|
|
const second = await runtime.createPlan(PROJECT_ID, { title: 'Second', body: 'b' });
|
|
|
|
const context = await runtime.readContext(PROJECT_ID);
|
|
expect(context.plans.map((entry) => entry.id)).toEqual([second.plan.id, first.plan.id]);
|
|
});
|
|
|
|
test('reading an unknown plan returns null rather than throwing', async () => {
|
|
expect(await runtime.readPlan(PROJECT_ID, 'nope')).toBeNull();
|
|
});
|
|
|
|
test('reading a plan whose markdown was deleted returns null', async () => {
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Doomed', body: 'x' });
|
|
await fsPromises.rm(path.join(plansDir(), plan.file));
|
|
|
|
expect(await runtime.readPlan(PROJECT_ID, plan.id)).toBeNull();
|
|
});
|
|
|
|
test('delete removes both the entry and the markdown', async () => {
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Bye', body: 'x' });
|
|
|
|
const result = await runtime.deletePlan(PROJECT_ID, plan.id);
|
|
expect(result.deleted).toBe(true);
|
|
expect(result.context.plans).toEqual([]);
|
|
await expect(fsPromises.access(path.join(plansDir(), plan.file))).rejects.toThrow();
|
|
});
|
|
|
|
test('deleting an unknown plan reports no deletion and keeps state', async () => {
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Stay', body: 'x' });
|
|
|
|
const result = await runtime.deletePlan(PROJECT_ID, 'missing');
|
|
expect(result.deleted).toBe(false);
|
|
expect(result.context.plans.map((entry) => entry.id)).toEqual([plan.id]);
|
|
});
|
|
|
|
test('plans created in the same millisecond do not collide on a file name', async () => {
|
|
const created = await Promise.all([
|
|
runtime.createPlan(PROJECT_ID, { title: 'Same', body: 'a' }),
|
|
runtime.createPlan(PROJECT_ID, { title: 'Same', body: 'b' }),
|
|
]);
|
|
|
|
const files = new Set(created.map((entry) => entry.plan.file));
|
|
expect(files.size).toBe(2);
|
|
const context = await runtime.readContext(PROJECT_ID);
|
|
expect(context.plans).toHaveLength(2);
|
|
});
|
|
|
|
test('update rewrites the markdown verbatim and re-derives the manifest title', async () => {
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Old', body: 'first' });
|
|
|
|
const result = await runtime.updatePlan(PROJECT_ID, plan.id, { raw: '# New title\n\n- step\n- step two\n' });
|
|
expect(result.plan.title).toBe('New title');
|
|
expect(result.plan.file).toBe(plan.file);
|
|
|
|
expect(await fsPromises.readFile(path.join(plansDir(), plan.file), 'utf8')).toBe('# New title\n\n- step\n- step two\n');
|
|
expect((await runtime.readContext(PROJECT_ID)).plans[0].title).toBe('New title');
|
|
});
|
|
|
|
test('update keeps the file name when the title changes', async () => {
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Original', body: 'x' });
|
|
|
|
await runtime.updatePlan(PROJECT_ID, plan.id, { raw: '# Totally different\n\nx' });
|
|
|
|
const context = await runtime.readContext(PROJECT_ID);
|
|
expect(context.plans[0].file).toBe(plan.file);
|
|
expect(context.plans).toHaveLength(1);
|
|
});
|
|
|
|
test('update returns null for an unknown plan without writing anything', async () => {
|
|
expect(await runtime.updatePlan(PROJECT_ID, 'missing', { raw: '# X' })).toBeNull();
|
|
await expect(fsPromises.readdir(plansDir())).rejects.toThrow();
|
|
});
|
|
|
|
test('update refuses to recreate markdown deleted underneath it', async () => {
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Gone', body: 'x' });
|
|
await fsPromises.rm(path.join(plansDir(), plan.file));
|
|
|
|
expect(await runtime.updatePlan(PROJECT_ID, plan.id, { raw: '# Resurrected' })).toBeNull();
|
|
await expect(fsPromises.access(path.join(plansDir(), plan.file))).rejects.toThrow();
|
|
});
|
|
|
|
test('update rejects a non-string payload', async () => {
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'A', body: 'x' });
|
|
await expect(runtime.updatePlan(PROJECT_ID, plan.id, {})).rejects.toThrow('raw is required');
|
|
});
|
|
|
|
test('update does not disturb notes or todos', async () => {
|
|
await runtime.createNote(PROJECT_ID, { body: 'keep me' });
|
|
await runtime.saveTodos(PROJECT_ID, [{ id: 't1', text: 'keep', completed: false, createdAt: 1 }]);
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'A', body: 'x' });
|
|
|
|
await runtime.updatePlan(PROJECT_ID, plan.id, { raw: '# B\n\ny' });
|
|
|
|
const context = await runtime.readContext(PROJECT_ID);
|
|
expect(context.notes.map((note) => note.body)).toEqual(['keep me']);
|
|
expect(context.todos).toHaveLength(1);
|
|
});
|
|
|
|
test('pinning a plan leaves its title and file alone', async () => {
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'Pin me', body: 'x' });
|
|
|
|
const result = await runtime.setPlanPinned(PROJECT_ID, plan.id, true);
|
|
expect(result.plan).toEqual({ ...plan, pinned: true });
|
|
expect((await runtime.readContext(PROJECT_ID)).plans[0].pinned).toBe(true);
|
|
});
|
|
|
|
test('pinning an unknown plan returns null', async () => {
|
|
expect(await runtime.setPlanPinned(PROJECT_ID, 'missing', true)).toBeNull();
|
|
});
|
|
|
|
test('editing a plan preserves its pin state', async () => {
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: 'A', body: 'x' });
|
|
await runtime.setPlanPinned(PROJECT_ID, plan.id, true);
|
|
|
|
const result = await runtime.updatePlan(PROJECT_ID, plan.id, { raw: '# B\n\ny' });
|
|
expect(result.plan.pinned).toBe(true);
|
|
});
|
|
|
|
test('an untitled body still produces a titled markdown file', async () => {
|
|
const { plan } = await runtime.createPlan(PROJECT_ID, { title: '', body: '' });
|
|
const read = await runtime.readPlan(PROJECT_ID, plan.id);
|
|
expect(read.title).toBe('Plan');
|
|
expect(read.body).toBe('');
|
|
});
|
|
});
|
|
|
|
describe('parsePlanMarkdown', () => {
|
|
test('reads the leading heading as the title', () => {
|
|
expect(parsePlanMarkdown('# Title\n\nbody')).toEqual({ title: 'Title', body: 'body' });
|
|
});
|
|
|
|
test('falls back to the first non-empty line', () => {
|
|
expect(parsePlanMarkdown('\n\njust text\nmore')).toEqual({ title: 'just text', body: 'just text\nmore' });
|
|
});
|
|
|
|
test('normalizes CRLF input', () => {
|
|
expect(parsePlanMarkdown('# Title\r\n\r\nbody')).toEqual({ title: 'Title', body: 'body' });
|
|
});
|
|
|
|
test('empty input yields the default title', () => {
|
|
expect(parsePlanMarkdown('')).toEqual({ title: 'Plan', body: '' });
|
|
});
|
|
});
|