Files
proffesor-for-testing__agen…/tests/unit/cli/commands/pre-task-bridge-empty-patterns.test.ts
Dragan Spiridonov 6655314120 fix(hooks): wire post-edit dream trigger (#480) + drop pre-task bridge pattern-count gate (#487)
post-edit was initializing `dreamTriggered = false` and only calling
`incrementDreamExperience` — `checkAndTriggerDream` was never invoked.
Since post-edit fires far more often than post-task during Claude Code
sessions, the hook-driven dream loop was starved: `experienceCount`
grew monotonically but `lastDreamTime` stayed null and
`dream_insights.applied` stayed at 0.

Mirrors task-hooks.ts: call `checkAndTriggerDream` after bumping the
counter and surface the full {triggered, reason, insightsGenerated,
insightsApplied} shape in JSON output so operators can distinguish
"not triggered because of state" from "not triggered because we
never tried."

The pre-task bridge was gated on `selectedPatternIds.length > 0`,
which silently dropped the `task-bridge:*` kv write for low-confidence
prompts. post-task's `if (outcome.bridge)` then skipped
`updateHookRouterQValue`, so `rl_q_values` never populated. The
Bellman update's state_key uses (taskType|priority|domain|
complexityBucket) and action_key uses `routing.recommendedAgent` —
neither requires non-empty patterns. The downstream per-pattern
fan-out at hooks-dream-learning.ts:428 already guards
`bridge.selectedPatternIds.length > 0` before iterating, so writing
the bridge with `[]` is structurally safe.

Tests:
- post-edit-dream-trigger.test.ts: 3 cases covering invocation,
  full JSON shape on trigger, and dreamReason surfaced when
  conditions are not met
- pre-task-bridge-empty-patterns.test.ts: parametrized over
  patterns=[] and patterns=[p1,p2], asserts bridge row exists in
  both; separate no-op test for missing --description

125/125 hook-handler unit tests pass; build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 11:20:49 +00:00

200 lines
6.0 KiB
TypeScript

/**
* Regression: issue #487
*
* The pre-task handler used to gate the `task-bridge:*` kv write on
* `selectedPatternIds.length > 0`. When routing returned no patterns
* (the typical case for low-confidence prompts), no bridge row was
* written, post-task's `if (outcome.bridge)` then short-circuited,
* and `updateHookRouterQValue` never ran — so `rl_q_values` stayed
* empty even though routing IS happening.
*
* The fix: the bridge must write whenever the task has a description,
* regardless of pattern list length. The Bellman update at the
* post-task site uses (taskType|priority|domain|complexityBucket) as
* its state_key and `routing.recommendedAgent` as its action_key —
* neither requires non-empty `selectedPatternIds`.
*
* This test parametrizes over patterns=[] and patterns=[p1,p2] and
* asserts the bridge row exists in both cases.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { Command } from 'commander';
import Database from 'better-sqlite3';
const { umMock, routeTaskMock } = vi.hoisted(() => ({
umMock: {
isInitialized: vi.fn().mockReturnValue(true),
initialize: vi.fn().mockResolvedValue(undefined),
getDatabase: vi.fn(),
},
routeTaskMock: vi.fn(),
}));
vi.mock('../../../../src/kernel/unified-memory.js', () => ({
findProjectRoot: vi.fn(() => '/tmp/pre-task-bridge-test'),
getUnifiedMemory: vi.fn(() => umMock),
}));
vi.mock('../../../../src/cli/commands/hooks-handlers/hooks-shared.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../../../src/cli/commands/hooks-handlers/hooks-shared.js')>();
return {
...actual,
getHooksSystem: vi.fn().mockResolvedValue({
hookRegistry: { emit: vi.fn().mockResolvedValue([]) },
reasoningBank: {
routeTask: routeTaskMock,
},
}),
createHybridBackendWithTimeout: vi.fn().mockResolvedValue({
store: vi.fn(),
retrieve: vi.fn().mockResolvedValue(null),
delete: vi.fn(),
close: vi.fn(),
}),
incrementDreamExperience: vi.fn().mockResolvedValue(0),
applyHookBusyTimeout: vi.fn(),
};
});
import { registerTaskHooks } from '../../../../src/cli/commands/hooks-handlers/task-hooks.js';
function seedSchema(db: Database.Database) {
db.exec(`
CREATE TABLE kv_store (
key TEXT NOT NULL,
namespace TEXT NOT NULL,
value TEXT NOT NULL,
expires_at INTEGER,
created_at INTEGER,
PRIMARY KEY (key, namespace)
);
CREATE TABLE routing_outcomes (
id TEXT PRIMARY KEY,
task_json TEXT NOT NULL,
decision_json TEXT NOT NULL,
used_agent TEXT NOT NULL,
followed_recommendation INTEGER NOT NULL,
success INTEGER NOT NULL,
quality_score REAL NOT NULL,
duration_ms REAL NOT NULL,
error TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE qe_patterns (
id TEXT PRIMARY KEY,
average_token_savings INTEGER DEFAULT 0
);
`);
}
describe.each([
{
label: 'empty patterns (low-confidence prompt)',
patterns: [] as Array<{ id: string }>,
},
{
label: 'two matched patterns',
patterns: [{ id: 'p1' }, { id: 'p2' }],
},
])('pre-task bridge writes with $label (#487)', ({ patterns }) => {
let db: Database.Database;
let errorSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
db = new Database(':memory:');
seedSchema(db);
umMock.getDatabase.mockReturnValue(db);
routeTaskMock.mockReset();
routeTaskMock.mockResolvedValue({
success: true,
value: {
recommendedAgent: 'qe-test-architect',
confidence: patterns.length > 0 ? 0.4 : 0.05,
patterns,
domains: ['test-generation'],
alternatives: [],
guidance: [],
},
});
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Pre-task uses console.log for non-JSON branch; we run with --json,
// but suppress accidental log noise too.
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
errorSpy.mockRestore();
vi.restoreAllMocks();
db.close();
});
it('writes a task-bridge row whenever --description is present', async () => {
const hooks = new Command('hooks');
registerTaskHooks(hooks);
await hooks.parseAsync(
[
'pre-task',
'--task-id',
'hook-test',
'--description',
'trivial probe task for the universal-write path',
'--json',
],
{ from: 'user' },
);
const row = db
.prepare(`SELECT key, namespace, value FROM kv_store WHERE namespace = 'task-bridge'`)
.get() as { key: string; namespace: string; value: string } | undefined;
expect(row).toBeDefined();
expect(row!.key).toMatch(/^task:[0-9a-f]+$/);
const payload = JSON.parse(row!.value);
expect(payload.selectedPatternIds).toEqual(patterns.map((p) => p.id));
expect(payload.agent).toBe('qe-test-architect');
expect(payload.taskType).toBeTypeOf('string');
expect(payload.domain).toBe('test-generation');
expect(typeof payload.complexityBucket).toBe('number');
});
});
describe('pre-task bridge does not write when description is absent', () => {
let db: Database.Database;
beforeEach(() => {
db = new Database(':memory:');
seedSchema(db);
umMock.getDatabase.mockReturnValue(db);
routeTaskMock.mockReset();
routeTaskMock.mockResolvedValue({ success: false });
vi.spyOn(console, 'error').mockImplementation(() => {});
vi.spyOn(console, 'log').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
db.close();
});
it('skips the bridge write when --description is missing', async () => {
const hooks = new Command('hooks');
registerTaskHooks(hooks);
await hooks.parseAsync(['pre-task', '--task-id', 'no-desc', '--json'], { from: 'user' });
const count = db
.prepare(`SELECT COUNT(*) AS n FROM kv_store WHERE namespace = 'task-bridge'`)
.get() as { n: number };
expect(count.n).toBe(0);
});
});