789efe4234
* feat: disable subagent summaries and label subagent observations Detect Claude Code subagent hook context via `agent_id`/`agent_type` on stdin, short-circuit the Stop-hook summary path when present, and thread the subagent identity end-to-end onto observation rows (new `agent_type` and `agent_id` columns, migration 010 at version 27). Main-session rows remain NULL; content-hash dedup is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address PR #2073 review feedback - Narrow summarize subagent guard to agentId only so --agent-started main sessions still own their summary (agentType alone is main-session). - Remove now-dead agentId/agentType spreads from the summarize POST body. - Always overwrite pendingAgentId/pendingAgentType in SDK/Gemini/OpenRouter agents (clears stale subagent identity on main-session messages after a subagent message in the same batch). - Add idx_observations_agent_id index in migration 010 + the mirror migration in SessionStore + the runner. - Replace console.log in migration010 with logger.debug. - Update summarize test: agentType alone no longer short-circuits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address CodeRabbit + claude-review iteration 4 feedback - SessionRoutes.handleSummarizeByClaudeId: narrow worker-side guard to agentId only (matches hook-side). agentType alone = --agent main session, which still owns its summary. - ResponseProcessor: wrap storeObservations in try/finally so pendingAgentId/Type clear even if storage throws. Prevents stale subagent identity from leaking into the next batch on error. - SessionStore.importObservation + bulk.importObservation: persist agent_type/agent_id so backup/import round-trips preserve subagent attribution. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * polish: claude-review iteration 5 cleanup - Use ?? not || for nullable subagent fields in PendingMessageStore (prevents treating empty string as null). - Simplify observation.ts body spread — include fields unconditionally; JSON.stringify drops undefined anyway. - Narrow any[] to Array<{ name: string }> in migration010 column checks. - Add trailing newline to migrations.ts. - Document in observations/store.ts why the dedup hash intentionally excludes agent fields. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * polish: claude-review iteration 7 feedback - claude-code adapter: add 128-char safety cap on agent_id/agent_type so a malformed Claude Code payload cannot balloon DB rows. Empty strings now also treated as absent. - migration010: state-aware debug log lists only columns actually added; idempotent re-runs log "already present; ensured indexes". - Add 3 adapter tests covering the length cap boundary and empty-string rejection. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * perf: skip subagent summary before worker bootstrap Move the agentId short-circuit above ensureWorkerRunning() so a Stop hook fired inside a subagent does not trigger worker startup just to return early. Addresses CodeRabbit nit on summarize.ts:36-47. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
162 lines
5.7 KiB
TypeScript
162 lines
5.7 KiB
TypeScript
/**
|
|
* Tests for storeObservation subagent labeling (agent_type, agent_id).
|
|
*
|
|
* Validates:
|
|
* 1. Rows carry agent_type / agent_id when set on ObservationInput.
|
|
* 2. Omitted subagent fields store as NULL (main-session rows).
|
|
* 3. Dedup is intentionally UNAFFECTED by agent_type — the content hash
|
|
* covers (memory_session_id, title, narrative) only, so two observations
|
|
* with the same semantic identity but different originating subagents
|
|
* dedup to the same row. This preserves stable observation identity
|
|
* across main-session and subagent contexts and is the documented
|
|
* intended behavior per Phase 4 anti-pattern guard in the plan.
|
|
*
|
|
* Sources:
|
|
* - Store: src/services/sqlite/observations/store.ts
|
|
* - Types: src/services/sqlite/observations/types.ts
|
|
* - Test pattern: tests/sqlite/observations.test.ts
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
import { ClaudeMemDatabase } from '../../../../src/services/sqlite/Database.js';
|
|
import { storeObservation } from '../../../../src/services/sqlite/Observations.js';
|
|
import {
|
|
createSDKSession,
|
|
updateMemorySessionId,
|
|
} from '../../../../src/services/sqlite/Sessions.js';
|
|
import type { ObservationInput } from '../../../../src/services/sqlite/observations/types.js';
|
|
import type { Database } from 'bun:sqlite';
|
|
|
|
describe('storeObservation — subagent labeling', () => {
|
|
let db: Database;
|
|
|
|
beforeEach(() => {
|
|
db = new ClaudeMemDatabase(':memory:').db;
|
|
});
|
|
|
|
afterEach(() => {
|
|
db.close();
|
|
});
|
|
|
|
function createObservationInput(overrides: Partial<ObservationInput> = {}): ObservationInput {
|
|
return {
|
|
type: 'discovery',
|
|
title: 'Test Observation',
|
|
subtitle: 'Subtitle',
|
|
facts: ['fact1'],
|
|
narrative: 'Narrative body',
|
|
concepts: ['concept1'],
|
|
files_read: ['/path/to/file1.ts'],
|
|
files_modified: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function createSessionWithMemoryId(
|
|
contentSessionId: string,
|
|
memorySessionId: string,
|
|
project = 'test-project'
|
|
): string {
|
|
const sessionId = createSDKSession(db, contentSessionId, project, 'initial prompt');
|
|
updateMemorySessionId(db, sessionId, memorySessionId);
|
|
return memorySessionId;
|
|
}
|
|
|
|
it('stores agent_type and agent_id when provided', () => {
|
|
const memorySessionId = createSessionWithMemoryId('content-sub-1', 'mem-sub-1');
|
|
const input = createObservationInput({
|
|
agent_type: 'Explore',
|
|
agent_id: 'agent-abc',
|
|
});
|
|
|
|
const result = storeObservation(db, memorySessionId, 'test-project', input);
|
|
|
|
const row = db
|
|
.prepare('SELECT agent_type, agent_id FROM observations WHERE id = ?')
|
|
.get(result.id) as { agent_type: string | null; agent_id: string | null };
|
|
|
|
expect(row).not.toBeNull();
|
|
expect(row.agent_type).toBe('Explore');
|
|
expect(row.agent_id).toBe('agent-abc');
|
|
});
|
|
|
|
it('stores NULL for agent_type and agent_id when fields are omitted (main-session row)', () => {
|
|
const memorySessionId = createSessionWithMemoryId('content-main-1', 'mem-main-1');
|
|
const input = createObservationInput();
|
|
// input has no agent_type / agent_id
|
|
|
|
const result = storeObservation(db, memorySessionId, 'test-project', input);
|
|
|
|
const row = db
|
|
.prepare('SELECT agent_type, agent_id FROM observations WHERE id = ?')
|
|
.get(result.id) as { agent_type: string | null; agent_id: string | null };
|
|
|
|
expect(row).not.toBeNull();
|
|
expect(row.agent_type).toBeNull();
|
|
expect(row.agent_id).toBeNull();
|
|
});
|
|
|
|
it('stores agent_type only when agent_id is absent', () => {
|
|
const memorySessionId = createSessionWithMemoryId('content-partial-1', 'mem-partial-1');
|
|
const input = createObservationInput({
|
|
agent_type: 'Plan',
|
|
// agent_id intentionally omitted
|
|
});
|
|
|
|
const result = storeObservation(db, memorySessionId, 'test-project', input);
|
|
|
|
const row = db
|
|
.prepare('SELECT agent_type, agent_id FROM observations WHERE id = ?')
|
|
.get(result.id) as { agent_type: string | null; agent_id: string | null };
|
|
|
|
expect(row.agent_type).toBe('Plan');
|
|
expect(row.agent_id).toBeNull();
|
|
});
|
|
|
|
it('dedup is NOT affected by agent fields — second insert with different agent_type returns existing id', () => {
|
|
// INTENDED BEHAVIOR (per plan Phase 4 anti-pattern guard):
|
|
// The content hash covers (memory_session_id, title, narrative) only.
|
|
// Two observations with identical title + narrative but different
|
|
// agent_type must dedup to the same row so observation identity is
|
|
// stable across main-session and subagent contexts.
|
|
const memorySessionId = createSessionWithMemoryId('content-dedup-1', 'mem-dedup-1');
|
|
|
|
const first = storeObservation(
|
|
db,
|
|
memorySessionId,
|
|
'test-project',
|
|
createObservationInput({
|
|
title: 'Identical Title',
|
|
narrative: 'Identical narrative body.',
|
|
agent_type: 'Explore',
|
|
agent_id: 'agent-first',
|
|
})
|
|
);
|
|
|
|
const second = storeObservation(
|
|
db,
|
|
memorySessionId,
|
|
'test-project',
|
|
createObservationInput({
|
|
title: 'Identical Title',
|
|
narrative: 'Identical narrative body.',
|
|
agent_type: 'Plan',
|
|
agent_id: 'agent-second',
|
|
})
|
|
);
|
|
|
|
// Second insert is deduped → same id, no new row, original agent fields preserved.
|
|
expect(second.id).toBe(first.id);
|
|
|
|
const rowCount = db
|
|
.prepare('SELECT COUNT(*) as n FROM observations WHERE memory_session_id = ?')
|
|
.get(memorySessionId) as { n: number };
|
|
expect(rowCount.n).toBe(1);
|
|
|
|
const row = db
|
|
.prepare('SELECT agent_type, agent_id FROM observations WHERE id = ?')
|
|
.get(first.id) as { agent_type: string | null; agent_id: string | null };
|
|
expect(row.agent_type).toBe('Explore');
|
|
expect(row.agent_id).toBe('agent-first');
|
|
});
|
|
});
|