Files
Codeman/test/readmymind-collectors.test.ts
T
Codeman maintainer 94abcf29dc feat: Read My Mind phase 2, the predictor and the brain button
The feature as pitched in docs/readmymind-plan.md: pressing the header
brain button predicts the prompt you were about to type, from the case's
intent profile plus everything the session already knows.

Backend:
- readmymind-context.ts: pure budgeted context assembler (9 ranked
  sources: pending approval dialog, user goals, last assistant turn tail,
  recent prompts, tool activity, git workspace signals, away context,
  sibling sessions, rethink state; 30 KB budget, whole-section drop from
  the bottom of the ranking, trust tiers stated in the prompt)
- readmymind-collectors.ts: transcript tail reader (the live watcher
  keeps only a 500-char snippet) and git signal collection (execFile,
  2s timeout, skipped for remote-SSH cases)
- readmymind-predictor.ts: one-shot claude -p in a throwaway tmux
  session, opus by default (readMyMindModel setting), strict JSON
  contract with 1-3 suggestions (continue / verify / redirect), newline
  stripping, 90s timeout; mutable singleton so route tests can stub it
- POST /api/sessions/:id/readmymind: claude-mode only (400), one
  prediction in flight per session (409 CONFLICT), rethink body
  { steer, rejected }; ownership via findSessionOrFail

Frontend:
- readmymind-ui.js (loadorder 11.3): header brain button, marker-hidden
  until readMyMindEnabled is ON, desktop only (phone key is phase 3);
  modal with editable suggestion + rationale and Send / Insert /
  Rethink / Dismiss; suggestion text rendered via value/textContent only
  and nothing ever auto-sends
- App Settings -> Panels checkbox for readMyMindEnabled; en + zh-CN
  strings

Verified end to end against a live isolated instance: transcript
capture, a real opus prediction grounded in the stated goals, rethink
steering, the 409, and the browser modal incl. Insert leaving the text
unsubmitted on the composer. 41 new unit/route tests; full test:ci
sweep green (4680 tests).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 23:05:11 +02:00

114 lines
4.6 KiB
TypeScript

/**
* @fileoverview Read My Mind collectors tests (src/readmymind-collectors.ts).
*
* `parseTranscriptSignals` runs on JSONL fixtures; `readTranscriptSignals`
* and `collectWorkspaceSignals` run against real temp files/repos under this
* test file's temp HOME (no tmux, no network).
*/
import { describe, it, expect } from 'vitest';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
parseTranscriptSignals,
readTranscriptSignals,
collectWorkspaceSignals,
} from '../src/readmymind-collectors.js';
function assistantLine(blocks: unknown[]): string {
return JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: blocks } });
}
function userToolResultLine(toolUseId: string, isError: boolean): string {
return JSON.stringify({
type: 'user',
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: toolUseId, is_error: isError }] },
});
}
describe('parseTranscriptSignals', () => {
it('keeps the FULL last assistant text, not a snippet', () => {
const long = 'x'.repeat(4000) + ' THE_END';
const lines = [
assistantLine([{ type: 'text', text: 'earlier reply' }]),
assistantLine([{ type: 'text', text: long }]),
];
const signals = parseTranscriptSignals(lines);
expect(signals.lastAssistantText).toContain('THE_END');
expect(signals.lastAssistantText!.length).toBeGreaterThan(3000);
});
it('extracts recent tool calls with argument summaries and failure marks', () => {
const lines = [
assistantLine([{ type: 'tool_use', id: 't1', name: 'Edit', input: { file_path: 'src/foo.ts' } }]),
assistantLine([{ type: 'tool_use', id: 't2', name: 'Bash', input: { command: 'npm test' } }]),
userToolResultLine('t2', true),
];
const signals = parseTranscriptSignals(lines);
expect(signals.recentTools).toEqual([
{ name: 'Edit', detail: 'src/foo.ts', failed: undefined },
{ name: 'Bash', detail: 'npm test', failed: true },
]);
});
it('caps retained tools to the most recent N', () => {
const lines = Array.from({ length: 15 }, (_, i) =>
assistantLine([{ type: 'tool_use', id: `t${i}`, name: 'Read', input: { file_path: `f${i}` } }])
);
const signals = parseTranscriptSignals(lines);
expect(signals.recentTools).toHaveLength(10);
expect(signals.recentTools[0].detail).toBe('f5');
expect(signals.recentTools[9].detail).toBe('f14');
});
it('skips malformed lines and tool_result-only user entries without text', () => {
const lines = ['{"type": "assistant", TRUNCATED', '', userToolResultLine('nope', false)];
const signals = parseTranscriptSignals(lines);
expect(signals.lastAssistantText).toBeNull();
expect(signals.recentTools).toEqual([]);
});
});
describe('readTranscriptSignals', () => {
it('reads a real transcript file and returns null for a missing one', async () => {
const dir = mkdtempSync(join(tmpdir(), 'rmm-transcript-'));
const file = join(dir, 'session.jsonl');
writeFileSync(file, [assistantLine([{ type: 'text', text: 'tail reply' }]), ''].join('\n'));
const signals = await readTranscriptSignals(file);
expect(signals?.lastAssistantText).toBe('tail reply');
expect(await readTranscriptSignals(join(dir, 'missing.jsonl'))).toBeNull();
});
});
describe('collectWorkspaceSignals', () => {
it('returns null for a non-git directory', async () => {
const dir = mkdtempSync(join(tmpdir(), 'rmm-nogit-'));
expect(await collectWorkspaceSignals(dir)).toBeNull();
expect(await collectWorkspaceSignals(join(dir, 'does-not-exist'))).toBeNull();
});
it('collects branch, status, commits, and changeset presence from a real repo', async () => {
const dir = mkdtempSync(join(tmpdir(), 'rmm-git-'));
const git = (...args: string[]) => execFileSync('git', args, { cwd: dir });
git('init', '-b', 'main');
git('config', 'user.email', 'test@example.com');
git('config', 'user.name', 'Test');
writeFileSync(join(dir, 'a.txt'), 'hello');
git('add', 'a.txt');
git('commit', '-m', 'first commit');
writeFileSync(join(dir, 'b.txt'), 'dirty');
mkdirSync(join(dir, '.changeset'));
writeFileSync(join(dir, '.changeset', 'README.md'), 'not a changeset');
writeFileSync(join(dir, '.changeset', 'blue-cats-run.md'), '---\n"pkg": patch\n---\n');
const signals = await collectWorkspaceSignals(dir);
expect(signals?.branch).toBe('main');
expect(signals?.statusShort).toContain('b.txt');
expect(signals?.recentCommits).toContain('first commit');
expect(signals?.hasChangesets).toBe(true);
});
});