Files
Codeman/test/transcript-watcher.test.ts
Codeman maintainer 161f1da2eb feat: Read My Mind phase 1, per-case intent profiles (capture + API + skill)
Per-case profiles of user intent (docs/readmymind-plan.md): user-stated goals
plus the user's recently submitted prompts, captured from the Claude session
transcript behind the new synced readMyMindEnabled setting (default OFF).

- intent-store.ts: keyed by owner + realpath(workingDir), FIFO/size caps,
  consecutive-dupe collapse, atomic 0600 writes to ~/.codeman/intents.json
- transcript-watcher.ts: new transcript:user_prompt event for typed user turns
  (tool_result-only entries stay silent); capture wiring in server.ts is
  claude-only and gated on the setting per event
- readmymind-routes.ts: GET/PUT/DELETE /api/sessions/:id/intent, ownership
  via findSessionOrFail, strict Zod schema
- agent skill: SKILL.md recipe + endpoints.md rows so agents can read and
  record intent (PUT replaces: read + merge; never delete unprompted)
- groundwork for the phase-2 predictor button; nothing is ever auto-sent

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 18:03:13 +02:00

334 lines
10 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { TranscriptWatcher, TranscriptState } from '../src/transcript-watcher.js';
import { writeFileSync, unlinkSync, mkdirSync, existsSync, appendFileSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
describe('TranscriptWatcher', () => {
let watcher: TranscriptWatcher;
let testDir: string;
let testFile: string;
beforeEach(() => {
watcher = new TranscriptWatcher();
testDir = join(tmpdir(), `transcript-test-${Date.now()}`);
if (!existsSync(testDir)) {
mkdirSync(testDir, { recursive: true });
}
testFile = join(testDir, 'test-transcript.jsonl');
});
afterEach(() => {
watcher.stop();
// Clean up test file
try {
if (existsSync(testFile)) {
unlinkSync(testFile);
}
} catch {
// Ignore cleanup errors
}
});
describe('Initialization', () => {
it('should start in stopped state', () => {
expect(watcher.isRunning()).toBe(false);
});
it('should have initial state with defaults', () => {
const state = watcher.getState();
expect(state.isComplete).toBe(false);
expect(state.toolExecuting).toBe(false);
expect(state.currentTool).toBeNull();
expect(state.hasError).toBe(false);
expect(state.planModeDetected).toBe(false);
expect(state.entryCount).toBe(0);
});
});
describe('File Watching', () => {
it('should start watching an existing file', () => {
writeFileSync(testFile, '');
watcher.start(testFile);
expect(watcher.isRunning()).toBe(true);
});
it('should handle non-existent file by polling', () => {
const nonExistent = join(testDir, 'nonexistent.jsonl');
watcher.start(nonExistent);
expect(watcher.isRunning()).toBe(true);
});
it('should stop watching on stop()', () => {
writeFileSync(testFile, '');
watcher.start(testFile);
expect(watcher.isRunning()).toBe(true);
watcher.stop();
expect(watcher.isRunning()).toBe(false);
});
it('should update path with updatePath()', () => {
const file1 = join(testDir, 'file1.jsonl');
const file2 = join(testDir, 'file2.jsonl');
writeFileSync(file1, '');
writeFileSync(file2, '');
watcher.start(file1);
expect(watcher.isRunning()).toBe(true);
watcher.updatePath(file2);
expect(watcher.isRunning()).toBe(true);
});
});
describe('Entry Processing', () => {
it('should process user entry and reset state', async () => {
// Start with some state
writeFileSync(testFile, '');
watcher.start(testFile);
// Add user entry
const userEntry = {
type: 'user',
timestamp: new Date().toISOString(),
message: { role: 'user', content: 'test' },
};
appendFileSync(testFile, JSON.stringify(userEntry) + '\n');
await vi.waitFor(() => {
expect(watcher.getState().entryCount).toBeGreaterThanOrEqual(1);
});
});
it('should emit transcript:complete on result entry', async () => {
writeFileSync(testFile, '');
watcher.start(testFile);
const completeHandler = vi.fn();
watcher.on('transcript:complete', completeHandler);
// Add result entry
const resultEntry = { type: 'result', timestamp: new Date().toISOString() };
appendFileSync(testFile, JSON.stringify(resultEntry) + '\n');
await vi.waitFor(() => {
expect(completeHandler).toHaveBeenCalled();
});
const state = watcher.getState();
expect(state.isComplete).toBe(true);
});
it('should track tool execution', async () => {
writeFileSync(testFile, '');
watcher.start(testFile);
const toolStartHandler = vi.fn();
watcher.on('transcript:tool_start', toolStartHandler);
// Add assistant entry with tool_use
const assistantEntry = {
type: 'assistant',
timestamp: new Date().toISOString(),
message: {
role: 'assistant',
content: [{ type: 'tool_use', name: 'Read', input: { file_path: '/test.txt' } }],
},
};
appendFileSync(testFile, JSON.stringify(assistantEntry) + '\n');
await vi.waitFor(() => {
expect(toolStartHandler).toHaveBeenCalledWith('Read');
});
const state = watcher.getState();
expect(state.toolExecuting).toBe(true);
expect(state.currentTool).toBe('Read');
});
it('should complete a tool when Claude writes tool_result in a user entry', async () => {
writeFileSync(testFile, '');
watcher.start(testFile);
const toolEndHandler = vi.fn();
watcher.on('transcript:tool_end', toolEndHandler);
appendFileSync(
testFile,
JSON.stringify({
type: 'assistant',
timestamp: new Date().toISOString(),
message: {
role: 'assistant',
content: [{ type: 'tool_use', name: 'Bash', input: { command: 'printf done' } }],
},
}) + '\n'
);
await vi.waitFor(() => {
expect(watcher.getState().toolExecuting).toBe(true);
});
appendFileSync(
testFile,
JSON.stringify({
type: 'user',
timestamp: new Date().toISOString(),
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'done', is_error: false }],
},
}) + '\n'
);
await vi.waitFor(() => {
expect(toolEndHandler).toHaveBeenCalledWith('Bash', false);
});
expect(watcher.getState()).toMatchObject({
toolExecuting: false,
currentTool: null,
});
});
it('should detect plan mode from AskUserQuestion tool', async () => {
writeFileSync(testFile, '');
watcher.start(testFile);
const planModeHandler = vi.fn();
watcher.on('transcript:plan_mode', planModeHandler);
// Add assistant entry with AskUserQuestion
const assistantEntry = {
type: 'assistant',
timestamp: new Date().toISOString(),
message: {
role: 'assistant',
content: [{ type: 'tool_use', name: 'AskUserQuestion', input: { question: 'test?' } }],
},
};
appendFileSync(testFile, JSON.stringify(assistantEntry) + '\n');
await vi.waitFor(() => {
expect(planModeHandler).toHaveBeenCalled();
});
const state = watcher.getState();
expect(state.planModeDetected).toBe(true);
});
it('should detect errors in result entry', async () => {
writeFileSync(testFile, '');
watcher.start(testFile);
// Add result entry with error
const resultEntry = {
type: 'result',
timestamp: new Date().toISOString(),
error: { type: 'api_error', message: 'Rate limited' },
};
appendFileSync(testFile, JSON.stringify(resultEntry) + '\n');
await vi.waitFor(() => {
expect(watcher.getState().hasError).toBe(true);
});
const state = watcher.getState();
expect(state.errorMessage).toContain('Rate limited');
});
});
describe('User prompt capture (Read My Mind)', () => {
it('emits transcript:user_prompt with the raw text for string content', async () => {
writeFileSync(testFile, '');
watcher.start(testFile);
const promptHandler = vi.fn();
watcher.on('transcript:user_prompt', promptHandler);
const ts = new Date().toISOString();
appendFileSync(
testFile,
JSON.stringify({ type: 'user', timestamp: ts, message: { role: 'user', content: 'fix the login bug' } }) + '\n'
);
await vi.waitFor(() => {
expect(promptHandler).toHaveBeenCalledWith('fix the login bug', ts);
});
});
it('emits joined text blocks but stays silent for tool_result-only entries', async () => {
writeFileSync(testFile, '');
watcher.start(testFile);
const promptHandler = vi.fn();
watcher.on('transcript:user_prompt', promptHandler);
appendFileSync(
testFile,
JSON.stringify({
type: 'user',
timestamp: new Date().toISOString(),
message: {
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'ok', is_error: false }],
},
}) + '\n'
);
appendFileSync(
testFile,
JSON.stringify({
type: 'user',
timestamp: new Date().toISOString(),
message: {
role: 'user',
content: [
{ type: 'text', text: 'run the tests' },
{ type: 'text', text: 'then push' },
],
},
}) + '\n'
);
await vi.waitFor(() => {
expect(promptHandler).toHaveBeenCalledTimes(1);
});
expect(promptHandler).toHaveBeenCalledWith('run the tests then push', expect.any(String));
});
it('does not emit for whitespace-only string content', async () => {
writeFileSync(testFile, '');
watcher.start(testFile);
const promptHandler = vi.fn();
watcher.on('transcript:user_prompt', promptHandler);
appendFileSync(
testFile,
JSON.stringify({
type: 'user',
timestamp: new Date().toISOString(),
message: { role: 'user', content: ' ' },
}) + '\n'
);
// Wait for the entry to be processed, then assert no emission happened.
await vi.waitFor(() => {
expect(watcher.getState().entryCount).toBeGreaterThanOrEqual(1);
});
expect(promptHandler).not.toHaveBeenCalled();
});
});
describe('State Management', () => {
it('should return a copy of state', () => {
const state1 = watcher.getState();
const state2 = watcher.getState();
expect(state1).not.toBe(state2); // Different objects
expect(state1).toEqual(state2); // Same content
});
it('should reset state on stop()', () => {
writeFileSync(testFile, '');
watcher.start(testFile);
watcher.stop();
const state = watcher.getState();
expect(state.entryCount).toBe(0);
});
});
});