Recently Written · git

ideamine

An idea inbox for Claude Code: /idea saves ideas at zero tokens; Claude triages them and routes each to the cheapest model that can build it.

git clone https://github.com/equwal/ideamine

Log | Files | Refs


tests/coop.test.js (9705 bytes)

1 import assert from 'node:assert/strict';
2 import { spawn } from 'node:child_process';
3 import fs from 'node:fs';
4 import http from 'node:http';
5 import os from 'node:os';
6 import path from 'node:path';
7 import { afterEach, beforeEach, test } from 'node:test';
8 import fc from 'fast-check';
9 import * as config from '../src/config.js';
10 import * as coop from '../src/coop.js';
11 import * as store from '../src/store.js';
12 
13 const TOKEN = 'test-operator-token';
14 let hub; // a fake coop hub: { url, sent, answers, messages, close }
15 let logs;
16 
17 /** A coop hub with the two operator routes that ideamine uses. `answers` are the replies to the next sends. */
18 function startHub() {
19   const h = { sent: [], answers: [], messages: [], auth: [] };
20   const server = http.createServer((req, res) => {
21     const chunks = [];
22     req.on('data', (c) => chunks.push(c));
23     req.on('end', () => {
24       h.auth.push(req.headers.authorization);
25       const reply = (status, json) => res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(json));
26       const u = new URL(req.url, 'http://hub');
27       if (req.headers.authorization !== `Bearer ${TOKEN}`) return reply(401, { error: 'unauthorized' });
28       if (req.method === 'POST' && u.pathname === '/v1/admin/sessions/workers/messages') {
29         h.sent.push(JSON.parse(Buffer.concat(chunks).toString()));
30         const [status, json] = h.answers.shift() || [404, { error: 'not_found', message: 'no peer in this session can take work now' }];
31         return reply(status, json);
32       }
33       if (req.method === 'GET' && u.pathname === '/v1/admin/sessions/workers/messages') {
34         const after = Number(u.searchParams.get('after'));
35         return reply(200, { messages: h.messages.filter((m) => Number(m.id) > after).slice(0, Number(u.searchParams.get('limit'))) });
36       }
37       reply(404, { error: 'not_found' });
38     });
39   });
40   return new Promise((resolve) =>
41     server.listen(0, '127.0.0.1', () => resolve({ ...h, h, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) })),
42   );
43 }
44 
45 beforeEach(async () => {
46   process.env.IDEAMINE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-coop-'));
47   hub = await startHub();
48   process.env.IDEAMINE_COOP_URL = hub.url;
49   process.env.IDEAMINE_COOP_TOKEN = TOKEN;
50   logs = [];
51 });
52 
53 afterEach(async () => {
54   await hub.close();
55   delete process.env.IDEAMINE_COOP_URL;
56   delete process.env.IDEAMINE_COOP_TOKEN;
57 });
58 
59 const round = () => coop.round(coop.settings(), { log: (line) => logs.push(line) });
60 const idea = (id) => store.findIdea(store.load(), id);
61 const lastNote = (id) => idea(id).notes.at(-1).text;
62 
63 /** An idea that the user dropped in Doing on a board with coop, through the page's own dropCommand. */
64 function dropInDoing(text = 'build the thing') {
65   const [{ idea: added }] = store.addIdeas(text);
66   store.applyTriage([{ id: added.id, verdict: 'do', brief: 'the plan', model: 'sonnet' }]);
67   const page = fs.readFileSync(new URL('../dashboard/index.html', import.meta.url), 'utf8');
68   const dropCommand = new Function('state', `${/function dropCommand[\s\S]*?\n}\n/.exec(page)[0]}; return dropCommand;`)({ data: { live: { coop: true } } });
69   const command = dropCommand({ ...added, lane: 'do' }, 'doing');
70   assert.deepEqual(command, { action: 'update', body: { id: added.id, status: 'doing', agent: 'any' } });
71   const { id, ...patch } = command.body;
72   store.updateIdea(id, patch);
73   return id;
74 }
75 
76 test('coop is off without a hub url or without a token, the token is never a setting, and only Doing waits for an agent', () => {
77   assert.ok(coop.settings());
78   assert.ok(!Object.keys(config.SETTINGS).some((key) => key.includes('token')));
79   delete process.env.IDEAMINE_COOP_TOKEN;
80   assert.equal(coop.settings(), null);
81   process.env.IDEAMINE_COOP_TOKEN = TOKEN;
82   delete process.env.IDEAMINE_COOP_URL;
83   assert.equal(coop.settings(), null);
84   const [{ idea: i }] = store.addIdeas('x');
85   assert.throws(() => store.updateIdea(i.id, { agent: 'any' }), /only an idea in Doing can wait for an agent/);
86   assert.throws(() => store.updateIdea(i.id, { status: 'doing', agent: 'claude@g' }), /agent must be "any"/);
87 });
88 
89 test('a card dropped in Doing goes to a free agent, shows its name, and DONE closes it', async () => {
90   const id = dropInDoing();
91   assert.equal(lastNote(id), 'waits for a free coop agent');
92   hub.h.answers.push([200, { id: '101', to: 'claude@gentoo', sent_at: 'now' }]);
93   await round();
94   assert.equal(hub.h.sent.length, 1);
95   assert.equal(hub.h.sent[0].to, 'any');
96   assert.match(hub.h.sent[0].text, /^Card IDEA-1 of jose's ideamine board: build the thing\n\nbuild the thing\n\nPlan from the triage: the plan\n/);
97   assert.deepEqual([idea(id).status, idea(id).agent.name, idea(id).agent.msg], ['doing', 'claude@gentoo', '101']);
98   assert.equal(lastNote(id), 'coop gave it to claude@gentoo');
99 
100   await round(); // no answer yet: nothing changes, and nothing else is sent
101   assert.equal(idea(id).agent.name, 'claude@gentoo');
102   assert.equal(hub.h.sent.length, 1);
103 
104   hub.h.messages.push({ id: '102', from: 'claude@gentoo', to: 'operator', reply_to: '101', text: 'DONE: pushed abc123 to main' });
105   await round();
106   assert.equal(store.lane(idea(id)), 'done');
107   assert.equal(idea(id).agent, undefined);
108   assert.equal(lastNote(id), 'claude@gentoo: DONE: pushed abc123 to main');
109   assert.ok(hub.h.auth.every((a) => a === `Bearer ${TOKEN}`));
110   assert.ok(logs.every((line) => !line.includes(TOKEN)), 'the token stays out of the log');
111 });
112 
113 test('an answer without DONE puts the card back in Do, with the answer as a note', async () => {
114   const id = dropInDoing();
115   hub.h.answers.push([200, { id: '7', to: 'codex@gentoo' }]);
116   await round();
117   hub.h.messages.push({ id: '8', from: 'codex@gentoo', reply_to: '7', text: 'FAILED: the project is not on this machine' });
118   await round();
119   assert.equal(store.lane(idea(id)), 'do');
120   assert.equal(lastNote(id), 'codex@gentoo: FAILED: the project is not on this machine');
121 });
122 
123 test('when no agent is free, the card waits and goes in a later round', async () => {
124   const id = dropInDoing();
125   await round(); // the hub answers 404
126   assert.equal(idea(id).agent.name, 'any');
127   assert.equal(logs.length, 0, 'a busy pool is normal, not an error');
128   hub.h.answers.push([200, { id: '5', to: 'claude@subread' }]);
129   await round();
130   assert.equal(idea(id).agent.name, 'claude@subread');
131 });
132 
133 test('a card that the user moves out of Doing is free of its agent, and a late answer changes nothing', async () => {
134   const id = dropInDoing();
135   hub.h.answers.push([200, { id: '3', to: 'claude@honjimaku' }]);
136   await round();
137   store.updateIdea(id, { status: 'reopen' });
138   assert.equal(idea(id).agent, undefined);
139   hub.h.messages.push({ id: '4', from: 'claude@honjimaku', reply_to: '3', text: 'DONE: late' });
140   await round();
141   assert.equal(store.lane(idea(id)), 'do');
142   assert.notEqual(lastNote(id), 'claude@honjimaku: DONE: late');
143 });
144 
145 test('a card without an answer for 3 hours goes back in Do', async () => {
146   const id = dropInDoing();
147   hub.h.answers.push([200, { id: '9', to: 'claude@gentoo' }]);
148   await round();
149   store.mutate((db) => (store.findIdea(db, id).agent.at = new Date(Date.now() - 4 * 3600000).toISOString()));
150   await round();
151   assert.equal(store.lane(idea(id)), 'do');
152   assert.equal(lastNote(id), 'no answer from claude@gentoo in 3 h');
153 });
154 
155 test('claim takes each waiting card once, oldest first, and never another card: a property', () => {
156   fc.assert(
157     fc.property(fc.array(fc.constantFrom('waits', 'doing', 'do', 'inbox', 'done'), { maxLength: 12 }), (kinds) => {
158       process.env.IDEAMINE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-claim-'));
159       const waits = [];
160       kinds.forEach((kind, n) => {
161         const [{ idea: i }] = store.addIdeas(`idea ${n}`);
162         if (kind === 'do') store.applyTriage([{ id: i.id, verdict: 'do' }]);
163         if (['waits', 'doing', 'done'].includes(kind)) store.updateIdea(i.id, { status: kind === 'done' ? 'done' : 'doing' });
164         if (kind === 'waits') {
165           store.updateIdea(i.id, { agent: 'any' });
166           store.mutate((db) => (store.findIdea(db, i.id).agent.at = new Date(Date.UTC(2026, 0, 1, 0, 0, 59 - n)).toISOString()));
167           waits.push(i.id);
168         }
169       });
170       const taken = [];
171       for (let c; (c = coop.claim()); ) taken.push(c.id);
172       assert.deepEqual(taken, waits.reverse()); // the later idea waits longer here
173       assert.ok(store.load().ideas.every((i) => (waits.includes(i.id) ? i.agent.name === 'coop' : !i.agent)));
174     }),
175     { numRuns: 60 },
176   );
177 });
178 
179 test('claims from many processes at once never give one card twice', async () => {
180   const cards = 24;
181   for (let n = 0; n < cards; n++) {
182     const [{ idea: i }] = store.addIdeas(`card ${n}`);
183     store.updateIdea(i.id, { status: 'doing', agent: 'any' });
184   }
185   const mod = new URL('../src/coop.js', import.meta.url).href;
186   const run = () =>
187     new Promise((resolve, reject) => {
188       const script = `const c = await import(${JSON.stringify(mod)}); const got = []; for (let x; (x = c.claim()); ) got.push(x.id); console.log(JSON.stringify(got));`;
189       const child = spawn(process.execPath, ['--input-type=module', '-e', script], { env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
190       let out = '';
191       let err = '';
192       child.stdout.on('data', (d) => (out += d));
193       child.stderr.on('data', (d) => (err += d));
194       child.on('close', (code) => (code === 0 ? resolve(JSON.parse(out)) : reject(new Error(err))));
195     });
196   const all = (await Promise.all(Array.from({ length: 6 }, run))).flat();
197   assert.equal(all.length, cards);
198   assert.equal(new Set(all).size, cards);
199 });