mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
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>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Read My Mind intent route tests (src/web/routes/readmymind-routes.ts)
|
||||
* @fileoverview Read My Mind route tests (src/web/routes/readmymind-routes.ts)
|
||||
* via app.inject(), no live port.
|
||||
*
|
||||
* The routes read the process-wide `intentStore` singleton, whose data file
|
||||
@@ -7,10 +7,14 @@
|
||||
* in-memory map lives for the whole file, so each test uses a distinct
|
||||
* session workingDir to stay isolated.
|
||||
*
|
||||
* Port: SessionPort.
|
||||
* The predictor singleton is stubbed (`vi.spyOn(readMyMindPredictor,
|
||||
* 'predict')`): nothing here ever spawns tmux or the claude CLI.
|
||||
*
|
||||
* Port: SessionPort & ConfigPort & InfraPort.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { registerReadMyMindRoutes } from '../../src/web/routes/readmymind-routes.js';
|
||||
import { readMyMindPredictor, type PredictionResult } from '../../src/readmymind-predictor.js';
|
||||
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
||||
|
||||
const SESSION_ID = 'test-session-1';
|
||||
@@ -104,6 +108,113 @@ describe('DELETE /api/sessions/:id/intent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/sessions/:id/readmymind', () => {
|
||||
const RESULT: PredictionResult = {
|
||||
suggestions: [{ prompt: 'run the tests', why: 'a fix just landed', kind: 'verify' }],
|
||||
durationMs: 1234,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns the stubbed suggestions and feeds user signals into the prompt', async () => {
|
||||
const predict = vi.spyOn(readMyMindPredictor, 'predict').mockResolvedValue(RESULT);
|
||||
await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: `/api/sessions/${SESSION_ID}/intent`,
|
||||
payload: { goals: 'GOALS_MARKER ship the release' },
|
||||
});
|
||||
|
||||
const res = await harness.app.inject({ method: 'POST', url: `/api/sessions/${SESSION_ID}/readmymind` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.suggestions).toEqual(RESULT.suggestions);
|
||||
expect(body.data.durationMs).toBe(1234);
|
||||
|
||||
expect(predict).toHaveBeenCalledTimes(1);
|
||||
const options = predict.mock.calls[0][0];
|
||||
expect(options.sessionId).toBe(SESSION_ID);
|
||||
expect(options.model).toBe('claude-opus-4-5-20251101');
|
||||
expect(options.prompt).toContain('TRUST TIERS');
|
||||
expect(options.prompt).toContain('GOALS_MARKER');
|
||||
});
|
||||
|
||||
it('threads steer and rejected suggestions into the rethink section', async () => {
|
||||
const predict = vi.spyOn(readMyMindPredictor, 'predict').mockResolvedValue(RESULT);
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${SESSION_ID}/readmymind`,
|
||||
payload: { steer: 'STEER_MARKER the mobile bug', rejected: ['REJECTED_MARKER run the tests'] },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const prompt = predict.mock.calls[0][0].prompt;
|
||||
expect(prompt).toContain('STEER_MARKER');
|
||||
expect(prompt).toContain('REJECTED_MARKER');
|
||||
});
|
||||
|
||||
it('409s while a prediction is already running for the session', async () => {
|
||||
let release: (value: PredictionResult) => void = () => {};
|
||||
// First call hangs until released; later calls resolve immediately.
|
||||
vi.spyOn(readMyMindPredictor, 'predict')
|
||||
.mockImplementationOnce(() => new Promise<PredictionResult>((resolve) => (release = resolve)))
|
||||
.mockResolvedValue(RESULT);
|
||||
|
||||
const first = harness.app.inject({ method: 'POST', url: `/api/sessions/${SESSION_ID}/readmymind` });
|
||||
// Let the first request reach the in-flight registration.
|
||||
await vi.waitFor(() => expect(readMyMindPredictor.predict).toHaveBeenCalled());
|
||||
|
||||
const second = await harness.app.inject({ method: 'POST', url: `/api/sessions/${SESSION_ID}/readmymind` });
|
||||
expect(second.statusCode).toBe(409);
|
||||
expect(second.json().errorCode).toBe('CONFLICT');
|
||||
|
||||
release(RESULT);
|
||||
expect((await first).statusCode).toBe(200);
|
||||
|
||||
// The slot frees once the prediction settles.
|
||||
const third = await harness.app.inject({ method: 'POST', url: `/api/sessions/${SESSION_ID}/readmymind` });
|
||||
expect(third.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('400s non-claude sessions', async () => {
|
||||
vi.spyOn(readMyMindPredictor, 'predict').mockResolvedValue(RESULT);
|
||||
(harness.ctx.sessions.get(SESSION_ID) as unknown as { mode: string }).mode = 'shell';
|
||||
const res = await harness.app.inject({ method: 'POST', url: `/api/sessions/${SESSION_ID}/readmymind` });
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(readMyMindPredictor.predict).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('502s a predictor failure with the clean error message', async () => {
|
||||
vi.spyOn(readMyMindPredictor, 'predict').mockRejectedValue(new Error('Predictor returned malformed JSON'));
|
||||
const res = await harness.app.inject({ method: 'POST', url: `/api/sessions/${SESSION_ID}/readmymind` });
|
||||
expect(res.statusCode).toBe(502);
|
||||
expect(res.json().error).toContain('malformed JSON');
|
||||
|
||||
// The in-flight slot is released after a failure.
|
||||
vi.spyOn(readMyMindPredictor, 'predict').mockResolvedValue(RESULT);
|
||||
const retry = await harness.app.inject({ method: 'POST', url: `/api/sessions/${SESSION_ID}/readmymind` });
|
||||
expect(retry.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('rejects unknown body keys (strict schema)', async () => {
|
||||
vi.spyOn(readMyMindPredictor, 'predict').mockResolvedValue(RESULT);
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${SESSION_ID}/readmymind`,
|
||||
payload: { autoSend: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(readMyMindPredictor.predict).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('404s an unknown session id', async () => {
|
||||
vi.spyOn(readMyMindPredictor, 'predict').mockResolvedValue(RESULT);
|
||||
const res = await harness.app.inject({ method: 'POST', url: '/api/sessions/nope/readmymind' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-user scoping', () => {
|
||||
let savedMultiuser: string | undefined;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user