// Answering Billion's questions (server/owner.js): choices, the Q numbers, // open → answered / dismissed, answers from the app and from Telegram (buttons // and replies), and waiting.json written before any of that. fetch is mocked: // nothing here talks to Telegram. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { writeFileSync, rmSync, readFileSync, mkdirSync } from 'fs'; import { join } from 'path'; import { notifyOwner, handleUpdate, waitingItems, waitingPayload, dismissWaiting, answerWaiting, checkChoices, pollOnce, NOTIFY_WINDOW_MS, resolveQuestion, reopenQuestion, UNDO_MS, chatMessages, questionProject, questionType, QUESTION_TYPES, } from '../server/owner.js'; import { handleMcpMessage, NOTIFY_OWNER_TOOL } from '../server/mcp.js'; import { dropMessages, takeMessages } from '../server/messages.js'; import { mayAnswerOwner } from '../server/ws.js'; import { USERS_PATH } from '../server/auth.js'; import { sessions, CONFIG_DIR, config } from '../server/state.js'; const TOKEN = '123456:SECRET-token'; const ENV = { TELEGRAM_BOT_TOKEN: TOKEN, TELEGRAM_CHAT_ID: '42' }; const reply = (result) => ({ ok: true, json: async () => ({ ok: true, result }) }); let fetchMock; let clock = 2e12; const now = () => (clock += 10 * NOTIFY_WINDOW_MS); const calls = () => fetchMock.mock.calls.map(([url, init]) => ({ method: url.split('/').pop(), body: JSON.parse(init.body) })); let b; const typed = () => b.pty.write.mock.calls.map(c => c[0]).join(''); beforeEach(() => { fetchMock = vi.fn(async () => reply({ message_id: 900 })); vi.stubGlobal('fetch', fetchMock); rmSync(join(CONFIG_DIR, 'waiting.json'), { force: true }); b = { id: 'answers-billion', name: 'Billion', isBillion: true, command: 'claude', state: 'WAITING', exited: false, ownerId: null, stateChangedAt: Date.now() - 5000, recentStrippedLines: [], isTUI: true, lastOutputAt: 0, pty: { write: vi.fn() }, }; sessions.set(b.id, b); }); afterEach(() => { vi.unstubAllGlobals(); sessions.delete(b.id); dropMessages(b.id); }); // Pushed to the phone unless a test says otherwise: most of these are about Telegram. const ask = (text, opts = {}) => notifyOwner(text, { env: ENV, now: now(), telegram: true, ...opts }); describe('choices and recommended', () => { it('accepts 2 to 5 short distinct choices and a recommended one of them', () => { expect(checkChoices(undefined, undefined)).toBeNull(); expect(checkChoices(['yes', 'no'], 'yes')).toBeNull(); expect(checkChoices(['a', 'b', 'c', 'd', 'e'])).toBeNull(); expect(checkChoices(['yes'])).toMatch(/2 to 5/); expect(checkChoices(['a', 'b', 'c', 'd', 'e', 'f'])).toMatch(/2 to 5/); expect(checkChoices('yes,no')).toMatch(/2 to 5/); expect(checkChoices(['yes', ' '])).toMatch(/1 to 40/); expect(checkChoices(['yes', 'x'.repeat(41)])).toMatch(/1 to 40/); expect(checkChoices(['yes', 'yes'])).toMatch(/differ/); expect(checkChoices(['yes', 'no'], 'maybe')).toMatch(/one of the choices/); expect(checkChoices(undefined, 'yes')).toMatch(/needs choices/); expect(checkChoices(['1', '2'], 1)).toMatch(/one of the choices/); }); it('refuses a bad pick before pinning or sending anything', async () => { expect((await ask('Buy?', { choices: ['yes', 'no'], recommended: 'maybe' })).error).toMatch(/one of the choices/); expect(waitingItems()).toEqual([]); expect(fetchMock).not.toHaveBeenCalled(); }); it('are in the MCP schema and reach notifyOwner', async () => { const { properties } = NOTIFY_OWNER_TOOL.inputSchema; expect(properties.choices).toMatchObject({ type: 'array', minItems: 2, maxItems: 5 }); expect(properties.recommended.type).toBe('string'); const notify = vi.fn(async () => ({ ok: true, n: 4 })); const res = await handleMcpMessage({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'notify_owner', arguments: { text: 'Buy?', choices: ['yes', 'no'], recommended: 'yes' } } }, { session: { isBillion: true }, notifyOwner: notify }); expect(notify).toHaveBeenCalledWith('Buy?', { choices: ['yes', 'no'], recommended: 'yes', urgency: undefined, project: undefined }); expect(res.result.content[0].text).toContain('[Owner via app] Q4:'); }); it('go to Telegram as buttons, the recommended one marked, callback_data under 64 bytes', async () => { expect(await ask('Buy the domain?', { choices: ['yes', 'no'], recommended: 'yes' })).toEqual({ ok: true, n: 1, telegram: true }); const [item] = waitingItems(); expect(item).toMatchObject({ n: 1, status: 'open', choices: ['yes', 'no'], recommended: 'yes', tgMessageId: 900 }); const { body } = calls()[0]; expect(body.text).toBe('Q1: Buy the domain?'); const buttons = body.reply_markup.inline_keyboard.flat(); expect(buttons.map(x => x.text)).toEqual(['yes (recommended)', 'no']); expect(buttons.map(x => x.callback_data)).toEqual([`${item.id}:0`, `${item.id}:1`]); for (const x of buttons) expect(Buffer.byteLength(x.callback_data)).toBeLessThanOrEqual(64); }); }); describe('answering in the app', () => { it('types "[Owner via app] Q: " with the question for context, and marks it answered', async () => { await ask('first'); await ask('Spend $20 on a domain for the launch page? I recommend yes, it is cheap.', { choices: ['yes', 'no'] }); const item = waitingItems()[1]; const broadcast = vi.fn(); fetchMock.mockClear(); expect(await answerWaiting(item.id, 'yes', 'app', { broadcast, env: ENV })).toMatchObject({ ok: true }); expect(typed()).toContain('[Owner via app] Q2: yes (re: "Spend $20 on a domain for the launch page? I recommend yes,…")'); expect(waitingItems()[1]).toMatchObject({ status: 'answered', answer: 'yes', answeredVia: 'app' }); expect(broadcast).toHaveBeenCalledWith(waitingPayload()); // The phone's copy says so and loses its buttons. expect(calls()).toEqual([{ method: 'editMessageText', body: { chat_id: '42', message_id: 900, text: `Q2: ${item.text}\n\nAnswered in app: yes` } }]); }); it('edits the phone\'s copy even when the app answered before the send came back', async () => { let finish; fetchMock.mockImplementationOnce(() => new Promise(r => { finish = () => r(reply({ message_id: 901 })); })); const asked = ask('Ship it?', { choices: ['yes', 'no'] }); await vi.waitFor(() => expect(finish).toBeDefined()); expect((await answerWaiting(waitingItems()[0].id, 'yes', 'app', { env: ENV })).ok).toBe(true); finish(); await asked; expect(calls().at(-1)).toEqual({ method: 'editMessageText', body: { chat_id: '42', message_id: 901, text: 'Q1: Ship it?\n\nAnswered in app: yes' } }); }); it('keeps the card open when Billion is not running, and refuses a second answer', async () => { await ask('Which name?', { env: {} }); const [item] = waitingItems(); sessions.delete(b.id); expect(await answerWaiting(item.id, 'Raven', 'app')).toEqual({ error: 'Billion is not running' }); expect(waitingItems()[0].status).toBe('open'); sessions.set(b.id, b); expect(await answerWaiting(item.id, ' ', 'app')).toEqual({ error: 'The answer is empty.' }); expect((await answerWaiting(item.id, 'Raven', 'app')).ok).toBe(true); expect((await answerWaiting(item.id, 'Crow', 'app')).error).toMatch(/Q1 was answered already: Raven/); expect(dismissWaiting(item.id)).toBe(true); expect(waitingItems()[0].status).toBe('dismissed'); expect((await answerWaiting(item.id, 'Crow', 'app')).error).toMatch(/gone/); expect(waitingPayload().items).toEqual([]); }); }); describe('answering on Telegram', () => { const tap = (chat, data, id = 'cq1') => ({ update_id: 1, callback_query: { id, data, message: { chat: { id: chat }, message_id: 900 } } }); it('a button tap answers the question, acknowledges the tap, and edits the message', async () => { await ask('Buy it?', { choices: ['yes', 'no'], recommended: 'yes' }); const [item] = waitingItems(); fetchMock.mockClear(); expect(await handleUpdate(tap(42, `${item.id}:1`), { env: ENV })).toBe('answered'); expect(typed()).toContain('[Owner via Telegram] Q1: no (re: "Buy it?")'); expect(waitingItems()[0]).toMatchObject({ status: 'answered', answer: 'no', answeredVia: 'telegram' }); const sent = calls(); expect(sent.find(c => c.method === 'editMessageText').body.text).toBe('Q1: Buy it?\n\nAnswered: no'); expect(sent.find(c => c.method === 'answerCallbackQuery').body).toEqual({ callback_query_id: 'cq1', text: 'Sent: no' }); // A second tap only hears that it was answered. expect(await handleUpdate(tap(42, `${item.id}:0`, 'cq2'), { env: ENV })).toBe('stale'); expect(calls().at(-1).body).toEqual({ callback_query_id: 'cq2', text: 'Already answered: no' }); }); it('ignores a tap from any other chat, without a word', async () => { await ask('Buy it?', { choices: ['yes', 'no'] }); const [item] = waitingItems(); fetchMock.mockClear(); expect(await handleUpdate(tap(43, `${item.id}:0`), { env: ENV })).toBe('ignored'); expect(await handleUpdate(tap(42, `${item.id}:0`), { env: { TELEGRAM_BOT_TOKEN: TOKEN } })).toBe('ignored'); expect(fetchMock).not.toHaveBeenCalled(); expect(b.pty.write).not.toHaveBeenCalled(); expect(waitingItems()[0].status).toBe('open'); }); it('a typed reply to a question answers it; a plain message goes through as before', async () => { await ask('Which name?'); const msg = (text, extra) => ({ update_id: 2, message: { chat: { id: 42 }, text, ...extra } }); expect(await handleUpdate(msg('Raven', { reply_to_message: { message_id: 900 } }), { env: ENV })).toBe('answered'); expect(typed()).toContain('[Owner via Telegram] Q1: Raven (re: "Which name?")'); expect(waitingItems()[0]).toMatchObject({ status: 'answered', answer: 'Raven' }); expect(await handleUpdate(msg('and ship it', { reply_to_message: { message_id: 555 } }), { env: ENV })).toBe('delivered'); expect(await handleUpdate(msg('hello'), { env: ENV })).toBe('delivered'); expect(waitingItems()).toHaveLength(1); }); it('asks Telegram for button taps as well as messages', async () => { fetchMock.mockResolvedValueOnce(reply([])); await pollOnce(0, { env: ENV }); expect(calls()[0].body.allowed_updates).toEqual(['message', 'callback_query']); }); }); describe('waiting.json from before answers', () => { it('reads old items as open, numbered in order, and carries on from there', async () => { writeFileSync(join(CONFIG_DIR, 'waiting.json'), JSON.stringify([ { id: 'old-1', text: 'Old one', at: '2026-01-01T00:00:00.000Z' }, { id: 'old-2', text: 'Old two', at: '2026-01-02T00:00:00.000Z' }, ])); expect(waitingItems().map(i => [i.id, i.n, i.status])).toEqual([['old-1', 1, 'open'], ['old-2', 2, 'open']]); expect((await answerWaiting('old-2', 'done', 'app')).ok).toBe(true); expect(typed()).toContain('[Owner via app] Q2: done'); expect((await ask('New', { env: {} })).n).toBe(3); expect(waitingItems().map(i => [i.n, i.status])).toEqual([[1, 'open'], [2, 'answered'], [3, 'open']]); }); }); describe('the list\'s size', () => { it('keeps the newest 50 open questions and the newest 30 closed ones', () => { const items = Array.from({ length: 100 }, (_, i) => ({ id: `x${i}`, n: i + 1, text: 't', at: '', status: i % 2 ? 'open' : 'answered' })); writeFileSync(join(CONFIG_DIR, 'waiting.json'), JSON.stringify(items)); dismissWaiting('x1'); // any save trims const kept = waitingItems(); expect(kept.filter(i => i.status === 'open')).toHaveLength(49); expect(kept.filter(i => i.status !== 'open')).toHaveLength(30); expect(kept.at(-1).id).toBe('x99'); }); }); describe('who may answer', () => { it('anyone on a single-user server; nobody once user accounts are on', () => { expect(mayAnswerOwner()).toBe(true); writeFileSync(USERS_PATH, JSON.stringify([{ id: 'u1', displayName: 'A', color: '#d4a847', tokenHash: 'x' }])); try { expect(mayAnswerOwner()).toBe(false); } finally { rmSync(USERS_PATH, { force: true }); } }); }); describe('resolve_question', () => { const openCount = () => waitingPayload().items.filter(i => i.status === 'open').length; it('closes an open question by number, drops the badge, edits the phone copy, types nothing', async () => { await ask('Ship it?', { choices: ['yes', 'no'] }); await ask('Option B?'); expect(openCount()).toBe(2); fetchMock.mockClear(); const broadcast = vi.fn(); const result = await resolveQuestion({ number: 1 }, ' yes, ship ', { broadcast, env: ENV }); expect(result.item).toMatchObject({ n: 1, status: 'answered', answer: 'yes, ship', answeredVia: 'terminal' }); expect(openCount()).toBe(1); expect(broadcast).toHaveBeenCalledWith(waitingPayload()); expect(calls()).toEqual([{ method: 'editMessageText', body: { chat_id: '42', message_id: 900, text: 'Q1: Ship it?\n\nAnswered in terminal: yes, ship' } }]); expect(typed()).toBe(''); }); it('closes by id too', async () => { const { id } = (await ask('Option B?')) && waitingItems()[0]; expect((await resolveQuestion({ id }, 'go', { env: ENV })).item.status).toBe('answered'); }); it('errors for an unknown, answered or dismissed question and changes nothing', async () => { await ask('One?'); await ask('Two?'); expect((await resolveQuestion({ number: 9 }, 'x', { env: ENV })).error).toMatch(/no Q9/); expect((await resolveQuestion({ id: 'nope' }, 'x', { env: ENV })).error).toMatch(/no question nope/); await resolveQuestion({ number: 1 }, 'yes', { env: ENV }); expect((await resolveQuestion({ number: 1 }, 'no', { env: ENV })).error).toMatch(/Q1 was answered already: yes/); dismissWaiting(waitingItems()[1].id); expect((await resolveQuestion({ number: 2 }, 'no', { env: ENV })).error).toMatch(/Q2 was dismissed/); expect((await resolveQuestion({ number: 1 }, ' ', { env: ENV })).error).toMatch(/empty/); expect(waitingItems().map(i => [i.status, i.answer])).toEqual([['answered', 'yes'], ['dismissed', undefined]]); }); it('is Billion\'s tool only, over MCP', async () => { const list = (session) => handleMcpMessage({ jsonrpc: '2.0', id: 1, method: 'tools/list' }, { session }).result.tools.map(t => t.name); expect(list({ isBillion: true })).toContain('resolve_question'); expect(list({})).not.toContain('resolve_question'); const call = (args, ctx) => handleMcpMessage({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'resolve_question', arguments: args } }, ctx); expect((await call({ number: 1, answer: 'yes' }, { session: {} })).error.message).toMatch(/Unknown tool/); expect((await call({ answer: 'yes' }, { session: { isBillion: true } })).result.content[0].text).toMatch(/number or id/); const resolve = vi.fn(async () => ({ ok: true, item: { n: 3, answer: 'yes' } })); const res = await call({ number: 3, answer: 'yes' }, { session: { isBillion: true }, resolveQuestion: resolve }); expect(resolve).toHaveBeenCalledWith({ number: 3, id: undefined }, 'yes'); expect(res.result.content[0].text).toBe('Q3 is marked answered: yes'); }); }); describe('reopen_question and Undo', () => { it('puts an answered question back to open by number, clears the answer, re-broadcasts, sends nothing', async () => { await ask('Ship it?', { choices: ['yes', 'no'] }); await answerWaiting(waitingItems()[0].id, 'yes', 'app', { env: ENV }); fetchMock.mockClear(); b.pty.write.mockClear(); const broadcast = vi.fn(); const result = await reopenQuestion({ number: 1 }, { broadcast }); expect(result.item).toMatchObject({ n: 1, status: 'open' }); expect(waitingItems()[0]).not.toHaveProperty('answer'); expect(broadcast).toHaveBeenCalledWith(waitingPayload()); const bubble = chatMessages().find(m => m.q?.id === result.item.id); expect(bubble.q).toMatchObject({ status: 'open', choices: ['yes', 'no'] }); expect(bubble.q).not.toHaveProperty('answer'); expect(broadcast).toHaveBeenCalledWith({ type: 'chat-message', message: bubble }); expect(fetchMock).not.toHaveBeenCalled(); expect(typed()).toBe(''); // And it can be answered again. expect((await answerWaiting(waitingItems()[0].id, 'no', 'app', { env: ENV })).ok).toBe(true); }); it('refuses an unknown, open or dismissed question', async () => { await ask('One?'); await ask('Two?'); expect((await reopenQuestion({ number: 9 })).error).toMatch(/no Q9/); expect((await reopenQuestion({ id: 'nope' })).error).toMatch(/no question nope/); expect((await reopenQuestion({ number: 1 })).error).toBe('Q1 is open already.'); dismissWaiting(waitingItems()[1].id); expect((await reopenQuestion({ number: 2 })).error).toMatch(/Q2 was dismissed/); }); it('from the owner: only within the minute, and Billion hears the answer is withdrawn', async () => { await ask('Ship it?'); const { id } = waitingItems()[0]; await answerWaiting(id, 'yes', 'app', { env: ENV }); const at = Date.parse(waitingItems()[0].answeredAt); expect((await reopenQuestion({ id }, { owner: true, now: at + UNDO_MS + 1 })).error).toMatch(/Too late to undo Q1/); expect(waitingItems()[0].status).toBe('answered'); expect((await reopenQuestion({ id }, { owner: true, now: at + 1000 })).ok).toBe(true); // Queued behind the answer itself. expect(JSON.stringify(takeMessages(b.id).queue)).toContain('[Owner via app] Q1: undo my answer \\"yes\\"; the question is open again.'); expect(waitingItems()[0].status).toBe('open'); }); it('is Billion\'s tool only, over MCP', async () => { const list = (session) => handleMcpMessage({ jsonrpc: '2.0', id: 1, method: 'tools/list' }, { session }).result.tools.map(t => t.name); expect(list({ isBillion: true })).toContain('reopen_question'); expect(list({})).not.toContain('reopen_question'); const call = (args, ctx) => handleMcpMessage({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'reopen_question', arguments: args } }, ctx); expect((await call({}, { session: { isBillion: true } })).result.content[0].text).toMatch(/number or id/); const reopen = vi.fn(async () => ({ ok: true, item: { n: 3 } })); const res = await call({ number: 3 }, { session: { isBillion: true }, reopenQuestion: reopen }); expect(reopen).toHaveBeenCalledWith({ number: 3, id: undefined }); expect(res.result.content[0].text).toBe('Q3 is open again.'); }); }); describe('urgency', () => { it('is in the MCP schema and reaches notifyOwner', async () => { expect(NOTIFY_OWNER_TOOL.inputSchema.properties.urgency.enum).toEqual(['blocking', 'normal', 'low']); const notify = vi.fn(async () => ({ ok: true, n: 1 })); await handleMcpMessage({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'notify_owner', arguments: { text: 'Merge?', urgency: 'blocking' } } }, { session: { isBillion: true }, notifyOwner: notify }); expect(notify).toHaveBeenCalledWith('Merge?', expect.objectContaining({ urgency: 'blocking' })); }); it('defaults to normal, is kept on the item, and refuses an unknown level before pinning or sending', async () => { await ask('Name?'); await ask('Merge #12?', { urgency: 'blocking' }); await ask('Rename later?', { urgency: 'low' }); expect(waitingItems().map(i => i.urgency)).toEqual(['normal', 'blocking', 'low']); expect(waitingPayload().items.map(i => i.urgency)).toEqual(['normal', 'blocking', 'low']); fetchMock.mockClear(); expect((await ask('Now?', { urgency: 'urgent' })).error).toBe('urgency must be "blocking", "normal" or "low", not "urgent".'); expect(waitingItems()).toHaveLength(3); expect(fetchMock).not.toHaveBeenCalled(); }); it('puts "! " before blocking questions on Telegram only', async () => { await ask('Merge #12?', { urgency: 'blocking' }); await ask('Name?', { urgency: 'normal' }); await ask('Rename later?', { urgency: 'low' }); expect(calls().map(c => c.body.text)).toEqual(['! Q1: Merge #12?', 'Q2: Name?', 'Q3: Rename later?']); }); it('reads as normal on items saved before it existed', () => { writeFileSync(join(CONFIG_DIR, 'waiting.json'), JSON.stringify([{ id: 'old', n: 1, text: 'Old?', at: '2026-01-01T00:00:00Z', status: 'open' }])); expect(waitingItems()[0].urgency).toBe('normal'); }); }); describe('project', () => { const REPOS = ['agent-007', 'finnamon', 'Mirage']; it('takes a board repo by name in any case, "general", or an unknown name lower-cased and capped', () => { expect(questionProject('finnamon', 'x', REPOS)).toBe('finnamon'); expect(questionProject(' MIRAGE ', 'x', REPOS)).toBe('Mirage'); expect(questionProject('General', 'about agent-007', REPOS)).toBe('general'); expect(questionProject('Side-Project', 'x', REPOS)).toBe('side-project'); expect(questionProject('X'.repeat(90), 'x', REPOS)).toBe('x'.repeat(40)); }); it('is read off a GitHub PR, issue or repo URL whose repo is on the board', () => { expect(questionProject(undefined, 'Merge https://github.com/bill10/finnamon/pull/12 ?', REPOS)).toBe('finnamon'); expect(questionProject('', 'See github.com/someone/Agent-007/issues/3', REPOS)).toBe('agent-007'); expect(questionProject(undefined, 'Clone https://github.com/x/mirage.git first?', REPOS)).toBe('Mirage'); // A repo not on the board: then a name in the text, else general. expect(questionProject(undefined, 'https://github.com/x/other/pull/1 blocks finnamon', REPOS)).toBe('finnamon'); }); it('is read off a board repo named in the text, as a word, else general', () => { expect(questionProject(undefined, 'Ship agent-007 tonight?', REPOS)).toBe('agent-007'); expect(questionProject(undefined, 'Rename finnamon-web?', REPOS)).toBe('general'); expect(questionProject(undefined, 'Buy a domain?', REPOS)).toBe('general'); expect(questionProject(undefined, 'Buy a domain?', [])).toBe('general'); }); it('is in the MCP schema, reaches notifyOwner, is kept on the item and its bubble, and old items read as general', async () => { expect(NOTIFY_OWNER_TOOL.inputSchema.properties.project.type).toBe('string'); expect(NOTIFY_OWNER_TOOL.description).toMatch(/Pass project/); const notify = vi.fn(async () => ({ ok: true, n: 1 })); await handleMcpMessage({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'notify_owner', arguments: { text: 'Merge?', project: 'finnamon' } } }, { session: { isBillion: true }, notifyOwner: notify }); expect(notify).toHaveBeenCalledWith('Merge?', expect.objectContaining({ project: 'finnamon' })); const saved = config.repos; config.repos = [{ path: '/code/finnamon' }]; try { await ask('Merge https://github.com/bill10/finnamon/pull/4?'); await ask('Name?', { project: 'Side' }); expect(waitingPayload().items.map(i => i.project)).toEqual(['finnamon', 'side']); expect(chatMessages().filter(m => m.q).slice(-2).map(m => m.q.project)).toEqual(['finnamon', 'side']); expect((await ask('Now?', { project: 7 })).error).toMatch(/project must be/); } finally { config.repos = saved; } writeFileSync(join(CONFIG_DIR, 'waiting.json'), JSON.stringify([{ id: 'old', n: 1, text: 'Old?', at: '2026-01-01T00:00:00Z', status: 'open' }])); expect(waitingItems()[0].project).toBe('general'); }); }); describe('type', () => { it('takes one from the list in any case; any other name is other', () => { for (const t of QUESTION_TYPES) expect(questionType(t, 'Merge the PR?')).toBe(t); expect(questionType(' Finance ', 'x')).toBe('finance'); expect(questionType('legal', 'Merge the PR?')).toBe('other'); expect(questionType(7, 'x')).toBe('other'); // Empty is none: read off the text. expect(questionType('', 'Merge the PR?')).toBe('engineering'); }); it('is read off the text when not given, the first rule that matches winning', () => { const cases = { finance: ['Raise the price to $12?', 'Renew the domain?', 'Which plan?', 'Cancel the subscription?', 'Pay the invoice?', 'Spend the money?', 'Is $5 ok?'], engineering: ['Merge PR #12?', 'CI is red, retry?', 'Deploy tonight?', 'Fix this bug first?', 'Skip the flaky test?', 'Cut a release?'], marketing: ['Post it on Reddit?', 'Submit to HN?', 'Send the newsletter?', 'Tweet the launch?', 'Share on X?', 'Update the Changelog page?'], outreach: ['Someone replied, answer them?', 'She responded to the pitch; follow up?', 'A LinkedIn message came in: take the call?', 'An email from Acme: accept?', 'Answer the DM?', 'More outreach this week?', 'An inbound lead: call?'], admin: ['Log in to Stripe for me?', 'The token expired; rotate it?', 'Make an account on Fly?', 'Grant access to the repo?', 'Where are the credentials?', 'Finish the setup?', 'Install Docker?'], product: ['Add the feature?', 'Which design?', 'Is the UX right?', 'Change the roadmap?', 'Which direction?'], other: ['What do you think?', 'Hello?', ''], }; for (const [t, texts] of Object.entries(cases)) for (const text of texts) expect([text, questionType(undefined, text)]).toEqual([text, t]); // Outreach beats marketing: a reply to a post. expect(questionType(undefined, 'Someone replied to our Reddit post: answer?')).toBe('outreach'); // First match wins: money before a PR. expect(questionType(null, 'Merge the PR that changes the price?')).toBe('finance'); // X the platform, not a lowercase x. for (const text of ['Option x or y?', 'Retry 5 x 2?']) expect([text, questionType(undefined, text)]).toEqual([text, 'other']); // Words, not parts of words. expect(questionType(undefined, 'Is the postgres move fine?')).toBe('other'); }); it('is in the MCP schema, reaches notifyOwner, is kept on the item and its bubble, and old items get one once, saved', async () => { expect(NOTIFY_OWNER_TOOL.inputSchema.properties.type.enum).toEqual(QUESTION_TYPES); expect(NOTIFY_OWNER_TOOL.description).toMatch(/Pass project .* and type/); const notify = vi.fn(async () => ({ ok: true, n: 1 })); await handleMcpMessage({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'notify_owner', arguments: { text: 'Merge?', type: 'engineering' } } }, { session: { isBillion: true }, notifyOwner: notify }); expect(notify).toHaveBeenCalledWith('Merge?', expect.objectContaining({ type: 'engineering' })); await ask('Merge the PR?'); await ask('Name?', { type: 'Product' }); await ask('Name?', { type: 'legal' }); expect(waitingPayload().items.map(i => i.type)).toEqual(['engineering', 'product', 'other']); expect(chatMessages().filter(m => m.q).slice(-3).map(m => m.q.type)).toEqual(['engineering', 'product', 'other']); const file = join(CONFIG_DIR, 'waiting.json'); writeFileSync(file, JSON.stringify([{ id: 'old', n: 1, text: 'Renew the domain?', at: '2026-01-01T00:00:00Z', status: 'open' }])); expect(waitingItems()[0].type).toBe('finance'); expect(JSON.parse(readFileSync(file, 'utf8'))[0].type).toBe('finance'); // A stored type stands; the file is rewritten only while one lacks it. writeFileSync(file, JSON.stringify([ { id: 'a', n: 1, text: 'Renew the domain?', at: '2026-01-01T00:00:00Z', status: 'open', type: 'admin' }, { id: 'b', n: 2, text: 'Merge the PR?', at: '2026-01-01T00:00:00Z', status: 'open' }, ])); expect(waitingItems().map(i => i.type)).toEqual(['admin', 'engineering']); expect(JSON.parse(readFileSync(file, 'utf8')).map(i => i.type)).toEqual(['admin', 'engineering']); const typed = JSON.stringify([{ id: 'c', text: 'Renew the domain?', type: 'admin' }]); writeFileSync(file, typed); expect(waitingItems()[0]).toMatchObject({ type: 'admin', n: 1, status: 'open' }); expect(readFileSync(file, 'utf8')).toBe(typed); // all typed: read, not rewritten // A stored type off the list is set right, and saved. writeFileSync(file, JSON.stringify([{ id: 'e', text: 'Merge the PR?', type: 'Finance' }, { id: 'f', text: 'Merge the PR?', type: 'eng' }])); expect(waitingItems().map(i => i.type)).toEqual(['finance', 'other']); expect(JSON.parse(readFileSync(file, 'utf8')).map(i => i.type)).toEqual(['finance', 'other']); // A save that fails (a directory where its temp file goes) still reads, typed. writeFileSync(file, JSON.stringify([{ id: 'd', text: 'Merge the PR?' }])); mkdirSync(`${file}.tmp`); try { expect(waitingItems()[0].type).toBe('engineering'); expect(JSON.parse(readFileSync(file, 'utf8'))[0].type).toBeUndefined(); } finally { rmSync(`${file}.tmp`, { recursive: true, force: true }); } }); });