feat: disable subagent summaries, label subagent observations (#2073)

* 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>
This commit is contained in:
Alex Newman
2026-04-19 14:58:01 -07:00
committed by GitHub
parent 306a0b1de9
commit 789efe4234
27 changed files with 1381 additions and 361 deletions
@@ -0,0 +1,315 @@
# Plan: Disable Summaries for Subagents + Label Subagent Observations
## Goal
1. **Disable summaries for subagents** — prevent any summary generation path (hook → worker → SDK agent) from firing for events originating in a Claude Code subagent.
2. **Label observations from subagents** — tag every observation with the subagent identity (agent_id + agent_type) so downstream queries can distinguish main-session work from subagent work.
## Phase 0 — Documentation Discovery (COMPLETE)
### Claude Code hook payload fields (source: https://code.claude.com/docs/en/hooks.md)
- `agent_id` — present **only** when the hook fires inside a subagent invocation (e.g., `"agent-def456"`). Absent in the main session.
- `agent_type` — the subagent identifier (built-in like `"Bash"`, `"Explore"`, `"Plan"`, or a custom agent name). Present in subagents **and** when `--agent` flag is used.
- `session_id` — shared across main and subagents in the same session. Cannot distinguish contexts on its own.
- `transcript_path` — shared session transcript. Not a reliable discriminator.
- `SubagentStop` — dedicated event that fires when a subagent finishes. Currently **NOT registered** in `plugin/hooks/hooks.json`.
- `Stop` — fires for the main Claude agent (not subagents). Currently registered → wired to `summarize` handler.
**Discriminator for subagent context**: presence of `agent_id` OR `agent_type` in the hook stdin JSON.
### Current claude-mem architecture (grepped + read)
- `src/cli/types.ts:1-15``NormalizedHookInput` lacks `agentId` / `agentType`.
- `src/cli/adapters/claude-code.ts:5-17` — Claude Code adapter does NOT extract `agent_id` / `agent_type`.
- `src/cli/handlers/summarize.ts:27-143` — Stop-hook handler posts to `/api/sessions/summarize` without guarding on subagent context.
- `src/cli/handlers/observation.ts:51-62` — PostToolUse handler POSTs observation body without subagent fields.
- `src/services/worker/http/routes/SessionRoutes.ts:555-646``handleObservationsByClaudeId` destructures only `{ contentSessionId, tool_name, tool_input, tool_response, cwd }`; `queueObservation` call at line 620 has no subagent field.
- `src/services/sqlite/observations/store.ts:75-80``INSERT INTO observations` column list has no `agent_type` / `agent_id`.
- `src/services/sqlite/migrations.ts:578-588` — migrations array ends with `migration009` (version 26). Next migration slot is `migration010` (version 27).
- `src/utils/logger.ts:195-203` — already reads `input.subagent_type` for formatting Task tool invocations (reference pattern, no downstream storage).
### Allowed APIs / patterns to copy
- **Adapter metadata extension pattern**: `src/cli/adapters/gemini-cli.ts:77-96` already collects platform-specific metadata into `metadata` and returns it on `NormalizedHookInput`. Copy this pattern.
- **Migration pattern**: `src/services/sqlite/migrations.ts:556-573` (migration009) is a copy-ready template for conditional `ALTER TABLE ADD COLUMN` additions.
- **Observation INSERT column extension pattern**: `src/services/sqlite/observations/store.ts:75-98` — add `agent_type`, `agent_id` to the column list and to `stmt.run(...)` bindings.
### Anti-patterns to avoid
- Do NOT assume `agent_id` is present on the main session — it is undefined there. Treat presence as the discriminator.
- Do NOT register SubagentStop as a new hook in `hooks.json` just to "disable" summaries — defensively short-circuiting in the handler is simpler and covers both current and future Claude Code versions where Stop might fire in subagent contexts.
- Do NOT rely on `session_id` to distinguish — it is shared.
- Do NOT invent a `parent_tool_use_id` field in hook input. The Claude Code docs do not expose parent tool use ID on hook payloads. Only use `agent_id` + `agent_type`.
- Do NOT break the existing observation hash-dedup logic in `store.ts:19-28` — leave the hash inputs as-is.
---
## Phase 1 — Extend hook input surface to carry subagent fields
**What to implement** (COPY pattern from gemini-cli adapter metadata handling):
1. Edit `src/cli/types.ts:1-15` — add two optional fields to `NormalizedHookInput`:
```ts
agentId?: string; // Claude Code subagent agent_id (undefined in main session)
agentType?: string; // Claude Code subagent agent_type (undefined in main session)
```
2. Edit `src/cli/adapters/claude-code.ts:5-17` — in `normalizeInput`, extract `r.agent_id` and `r.agent_type`:
```ts
return {
sessionId: r.session_id ?? r.id ?? r.sessionId,
cwd: r.cwd ?? process.cwd(),
prompt: r.prompt,
toolName: r.tool_name,
toolInput: r.tool_input,
toolResponse: r.tool_response,
transcriptPath: r.transcript_path,
agentId: typeof r.agent_id === 'string' ? r.agent_id : undefined,
agentType: typeof r.agent_type === 'string' ? r.agent_type : undefined,
};
```
3. Edit `src/cli/adapters/gemini-cli.ts:88-97` — return matching `undefined` defaults so the interface contract is consistent across adapters. (No behavior change; just explicit `agentId: undefined, agentType: undefined` on the return object, or rely on the optional-field default by leaving it out. Leave it out — TypeScript optional is fine.)
**Documentation references**: Claude Code hooks docs section "Subagent Identification Fields"; gemini-cli adapter metadata pattern at `src/cli/adapters/gemini-cli.ts:77-96`.
**Verification checklist**:
- `grep -n "agentId" src/cli/types.ts` → finds the new field.
- `grep -n "agent_id" src/cli/adapters/claude-code.ts` → finds the extraction.
- `npm run build` succeeds.
**Anti-pattern guards**:
- Do NOT rename `agent_id` / `agent_type` snake_case raw fields. Camel-case only in `NormalizedHookInput`.
- Do NOT default to a sentinel string like `"main"`; leave undefined when absent.
---
## Phase 2 — Short-circuit summary generation in subagent context
**What to implement**:
1. Edit `src/cli/handlers/summarize.ts:27-36`, immediately after the worker-ready check (line 34) and before any processing:
```ts
// Skip summaries in subagent context — subagents do not own the session summary.
// Main Stop hook owns it; SubagentStop (if ever registered) must no-op.
if (input.agentId || input.agentType) {
logger.debug('HOOK', 'Skipping summary: subagent context detected', {
sessionId: input.sessionId,
agentId: input.agentId,
agentType: input.agentType
});
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
```
2. (Safety) Edit `src/services/worker/http/routes/SessionRoutes.ts` in `handleSummarizeByClaudeId` (around line 655-692): add a defensive guard that rejects the summarize request if the body includes `agentId` or `agentType`. Return `{ status: 'skipped', reason: 'subagent_context' }`. This is belt-and-suspenders in case any caller bypasses the hook layer.
3. Extend the `/api/sessions/summarize` body in `src/cli/handlers/summarize.ts:73-82` to include `agentId` and `agentType` (passthrough) so the worker can make the same decision independently. Only pass fields when defined:
```ts
body: JSON.stringify({
contentSessionId: sessionId,
last_assistant_message: lastAssistantMessage,
platformSource,
...(input.agentId ? { agentId: input.agentId } : {}),
...(input.agentType ? { agentType: input.agentType } : {}),
}),
```
**Documentation references**: summarize.ts handler flow at `src/cli/handlers/summarize.ts:27-143`; summarize route at `src/services/worker/http/routes/SessionRoutes.ts:655-692`.
**Verification checklist**:
- Unit test or manual dispatch with a payload containing `agent_id: "agent-abc"` → summarize handler returns before calling `/api/sessions/summarize`.
- `grep -n "subagent" src/cli/handlers/summarize.ts` → finds the new guard.
- `grep -n "subagent_context\|agentId" src/services/worker/http/routes/SessionRoutes.ts` → finds the server-side guard.
**Anti-pattern guards**:
- Do NOT also short-circuit in `session-complete` or `context` handlers — the session's main Stop still cleans up.
- Do NOT log at info level (spammy); `logger.debug` only.
---
## Phase 3 — Database schema migration for subagent labels on observations
**What to implement** (COPY migration009 pattern from `src/services/sqlite/migrations.ts:556-573`):
1. Append a new migration to `src/services/sqlite/migrations.ts` right after `migration009` (before the `migrations` array at line 578):
```ts
export const migration010: Migration = {
version: 27,
up: (db: Database) => {
const columns = db.prepare('PRAGMA table_info(observations)').all() as any[];
const hasAgentType = columns.some((c: any) => c.name === 'agent_type');
const hasAgentId = columns.some((c: any) => c.name === 'agent_id');
if (!hasAgentType) {
db.run('ALTER TABLE observations ADD COLUMN agent_type TEXT');
}
if (!hasAgentId) {
db.run('ALTER TABLE observations ADD COLUMN agent_id TEXT');
}
db.run('CREATE INDEX IF NOT EXISTS idx_observations_agent_type ON observations(agent_type)');
console.log('[migration010] Added agent_type, agent_id columns to observations');
},
down: (_db: Database) => {
// SQLite DROP COLUMN not fully supported; no-op
}
};
```
2. Add `migration010` to the `migrations` array at `src/services/sqlite/migrations.ts:578-588`.
3. Check `src/services/sqlite/migrations/runner.ts` to see if there's a parallel registration site; if so, mirror the addition there. (Investigation step — if `runner.ts` replicates migration definitions, extend it the same way. Otherwise, importing `migrations` from `migrations.ts` is sufficient.)
**Documentation references**: migration007 and migration009 at `src/services/sqlite/migrations.ts:491-509` and `556-573` as copy-ready templates.
**Verification checklist**:
- Run worker; check logs for `[migration010]`.
- `sqlite3 ~/.claude-mem/claude-mem.db "PRAGMA table_info(observations);"` → shows `agent_type` and `agent_id` columns.
- `sqlite3 ~/.claude-mem/claude-mem.db ".indexes observations"` → shows `idx_observations_agent_type`.
**Anti-pattern guards**:
- Do NOT drop or rename existing columns.
- Do NOT set NOT NULL constraints — main-session rows have NULL for these.
- Do NOT pick a version number that's already used (26 is migration009; use 27).
---
## Phase 4 — Thread subagent fields through hook → worker → SDK → DB
**What to implement**:
### 4a — Hook PostToolUse handler sends fields
Edit `src/cli/handlers/observation.ts:51-62`:
```ts
const response = await workerHttpRequest('/api/sessions/observations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contentSessionId: sessionId,
platformSource,
tool_name: toolName,
tool_input: toolInput,
tool_response: toolResponse,
cwd,
...(input.agentId ? { agentId: input.agentId } : {}),
...(input.agentType ? { agentType: input.agentType } : {}),
})
});
```
### 4b — Worker observations route receives and forwards
Edit `src/services/worker/http/routes/SessionRoutes.ts:555-646`:
- Destructure: `const { contentSessionId, tool_name, tool_input, tool_response, cwd, agentId, agentType } = req.body;`
- Pass to `queueObservation` at line 620:
```ts
this.sessionManager.queueObservation(sessionDbId, {
tool_name,
tool_input: cleanedToolInput,
tool_response: cleanedToolResponse,
prompt_number: promptNumber,
cwd: cwd || ...,
agentId: typeof agentId === 'string' ? agentId : undefined,
agentType: typeof agentType === 'string' ? agentType : undefined,
});
```
### 4c — queueObservation type extension
Investigation: find the `queueObservation` signature in the session manager (likely `src/services/session/` or similar). Add optional `agentId?: string; agentType?: string;` to the payload type. These must ride through to the SDK agent's observation context so they land in `storeObservation()`.
### 4d — Observation input type + store.ts extension
- Edit `src/services/sqlite/observations/types.ts:10-19` — add:
```ts
agent_type?: string | null;
agent_id?: string | null;
```
- Edit `src/services/sqlite/observations/store.ts:75-98`:
- Column list: add `, agent_type, agent_id` before `content_hash`.
- Placeholders: add `, ?, ?`.
- Bindings: add `observation.agent_type ?? null, observation.agent_id ?? null`.
- Verify there are no other `INSERT INTO observations` sites that need updating. Sites already located (to re-check):
- `src/services/sqlite/SessionStore.ts:1755` / `1890` / `2022` / `2623` — each needs the same two columns added. If these are separate insertion paths, extend all of them; pass `null` for fields not available in that path.
### 4e — SDK agent observation parser forwards fields
The SDK agent parses `<observation>` XML into an `ObservationInput` and calls `storeObservation`. The tool_input passed in must carry `agentId`/`agentType` through to here so the row gets labeled. Investigation step: find where `storeObservation()` is called with an `ObservationInput` built from the queued observation, and inject `agent_type`/`agent_id` from the queue item's subagent fields onto the `ObservationInput`. Location likely in `src/services/sdk/` or adjacent.
**Documentation references**:
- observation handler at `src/cli/handlers/observation.ts:51-62`
- SessionRoutes observations endpoint at `src/services/worker/http/routes/SessionRoutes.ts:555-646`
- storeObservation at `src/services/sqlite/observations/store.ts:75-98`
- Existing observation INSERT sites at `src/services/sqlite/SessionStore.ts:1755, 1890, 2022, 2623` (audit required)
**Verification checklist**:
- `grep -rn "agent_type\|agentType" src/` → shows fields threaded through every layer.
- Simulate a Task subagent PostToolUse payload → observation row has non-null `agent_type`.
- Main-session PostToolUse → observation row has NULL `agent_type` (existing behavior preserved).
- No existing test suite breaks: `npm test` passes.
**Anti-pattern guards**:
- Do NOT include `agent_type` / `agent_id` in the content-hash computation (`src/services/sqlite/observations/store.ts:19-28`). The hash identity must remain stable for dedup.
- Do NOT add fields to the FTS5 `observations_fts` virtual table — not searchable text.
- Do NOT backfill — leave existing rows NULL.
---
## Phase 5 — Tests and verification
**What to implement**:
1. Add a unit test at `tests/cli/handlers/summarize-subagent-skip.test.ts` verifying:
- When `input.agentId` is set, handler returns early with `exitCode: SUCCESS` and does NOT call `workerHttpRequest`.
- When `input.agentType` is set, same behavior.
- When both are undefined, handler proceeds (mock worker response).
2. Add a unit test at `tests/cli/adapters/claude-code-subagent.test.ts` verifying:
- `normalizeInput({ agent_id: "agent-abc", agent_type: "Explore" })` returns `{ agentId: "agent-abc", agentType: "Explore" }`.
- `normalizeInput({})` returns `agentId: undefined, agentType: undefined`.
3. Add a unit test at `tests/services/sqlite/observations/store-subagent-label.test.ts` verifying:
- `storeObservation` with `agent_type: "Explore"` inserts row with `agent_type = "Explore"`.
- Omitted `agent_type` → NULL in DB.
- Content-hash dedup still works (two observations with same title/narrative but different `agent_type` should still collide on dedup — verify expected behavior; update test if product intent differs).
4. Manual integration check: start worker, simulate a hook payload with `agent_id`/`agent_type`, observe observation row in DB.
**Verification checklist**:
- `npm test` passes.
- `npm run build` succeeds.
- Database inspection shows expected rows.
**Anti-pattern guards**:
- Do NOT mock the entire storeObservation — use a real in-memory Bun SQLite DB if existing tests do.
- Do NOT add integration tests that require a running worker unless the suite already does.
---
## Phase 6 — Build + autonomous execution pipeline
After Phases 1-5 land and pass verification:
1. **Build**: `npm run build-and-sync`.
2. **Commit**: a single commit titled `feat: disable subagent summaries and label subagent observations` with co-author footer.
3. **Push branch**: push current worktree branch `trail-guarantee` (or a new feature branch — confirm with `git status`). Create PR via `gh pr create` with summary of both features.
4. **Run `/loop 5m`** to continuously re-check PR review comments: as each CodeRabbit/Greptile/human comment arrives, address it in a new commit, push, and re-check. Exit loop only when all actionable review comments are resolved and status checks pass.
5. **Merge to main** via `gh pr merge --squash --auto` (or `--merge` per repo convention — inspect `.github/` first).
6. **Version bump**: `cd ~/Scripts/claude-mem/` and run `/version-bump`.
**Anti-pattern guards for this phase**:
- Do NOT force-push to main.
- Do NOT skip hooks (`--no-verify`).
- Do NOT squash-merge if the repo uses rebase-merge; check `.github/` for branch-protection hints.
- Do NOT resolve a review comment without actually addressing it — every resolved thread must have a corresponding commit or a reply explaining why no change is needed.
---
## Final Verification (end of Phase 5, before Phase 6)
- `grep -rn "agent_id\|agentId" src/` → fields present in: `types.ts`, `claude-code.ts`, `summarize.ts`, `observation.ts`, `SessionRoutes.ts`, observation types, store, migration010.
- `grep -rn "subagent_context" src/services/worker/` → worker-side guard present.
- `sqlite3 ~/.claude-mem/claude-mem.db "PRAGMA table_info(observations);"` → includes `agent_type`, `agent_id`.
- `npm test && npm run build` → both green.
- Smoke test: simulate a subagent hook payload end-to-end → observation labeled, no summary fired.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+9
View File
@@ -2,6 +2,13 @@ import type { PlatformAdapter, NormalizedHookInput, HookResult } from '../types.
// Maps Claude Code stdin format (session_id, cwd, tool_name, etc.)
// SessionStart hooks receive no stdin, so we must handle undefined input gracefully
// Defensive cap: Claude Code's agent identifiers are short (e.g., "agent-abc123", "Explore").
// Ignore anything longer than 128 chars so a malformed payload cannot balloon DB rows.
const MAX_AGENT_FIELD_LEN = 128;
const pickAgentField = (v: unknown): string | undefined =>
typeof v === 'string' && v.length > 0 && v.length <= MAX_AGENT_FIELD_LEN ? v : undefined;
export const claudeCodeAdapter: PlatformAdapter = {
normalizeInput(raw) {
const r = (raw ?? {}) as any;
@@ -13,6 +20,8 @@ export const claudeCodeAdapter: PlatformAdapter = {
toolInput: r.tool_input,
toolResponse: r.tool_response,
transcriptPath: r.transcript_path,
agentId: pickAgentField(r.agent_id),
agentType: pickAgentField(r.agent_type),
};
},
formatOutput(result) {
+3 -1
View File
@@ -57,7 +57,9 @@ export const observationHandler: EventHandler = {
tool_name: toolName,
tool_input: toolInput,
tool_response: toolResponse,
cwd
cwd,
agentId: input.agentId,
agentType: input.agentType
})
});
+14
View File
@@ -26,6 +26,20 @@ const MAX_WAIT_FOR_SUMMARY_MS = 110_000; // 110s — fits within Stop hook's 120
export const summarizeHandler: EventHandler = {
async execute(input: NormalizedHookInput): Promise<HookResult> {
// Skip summaries in subagent context — subagents do not own the session summary.
// Gate on agentId only: that field is present exclusively for Task-spawned subagents.
// agentType alone (no agentId) indicates `--agent`-started main sessions, which still
// own their summary. Do this BEFORE ensureWorkerRunning() so a subagent Stop hook
// does not bootstrap the worker.
if (input.agentId) {
logger.debug('HOOK', 'Skipping summary: subagent context detected', {
sessionId: input.sessionId,
agentId: input.agentId,
agentType: input.agentType
});
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
}
// Ensure worker is running before any other logic
const workerReady = await ensureWorkerRunning();
if (!workerReady) {
+4
View File
@@ -12,6 +12,10 @@ export interface NormalizedHookInput {
edits?: unknown[]; // afterFileEdit
// Platform-specific metadata (source, reason, trigger, mcp_context, etc.)
metadata?: Record<string, unknown>;
// Claude Code subagent identity — present only when hook fires inside a subagent.
// Main session has both undefined. Discriminator for subagent context.
agentId?: string; // Claude Code subagent agent_id (undefined in main session)
agentType?: string; // Claude Code subagent agent_type (undefined in main session)
}
export interface HookResult {
+12 -4
View File
@@ -24,6 +24,9 @@ export interface PersistentPendingMessage {
created_at_epoch: number;
started_processing_at_epoch: number | null;
completed_at_epoch: number | null;
// Claude Code subagent identity — NULL for main-session messages.
agent_type: string | null;
agent_id: string | null;
}
/**
@@ -64,8 +67,9 @@ export class PendingMessageStore {
session_db_id, content_session_id, message_type,
tool_name, tool_input, tool_response, cwd,
last_assistant_message,
prompt_number, status, retry_count, created_at_epoch
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?)
prompt_number, status, retry_count, created_at_epoch,
agent_type, agent_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', 0, ?, ?, ?)
`);
const result = stmt.run(
@@ -78,7 +82,9 @@ export class PendingMessageStore {
message.cwd || null,
message.last_assistant_message || null,
message.prompt_number || null,
now
now,
message.agentType ?? null,
message.agentId ?? null
);
return result.lastInsertRowid as number;
@@ -496,7 +502,9 @@ export class PendingMessageStore {
tool_response: persistent.tool_response ? JSON.parse(persistent.tool_response) : undefined,
prompt_number: persistent.prompt_number || undefined,
cwd: persistent.cwd || undefined,
last_assistant_message: persistent.last_assistant_message || undefined
last_assistant_message: persistent.last_assistant_message || undefined,
agentId: persistent.agent_id ?? undefined,
agentType: persistent.agent_type ?? undefined
};
}
}
+64 -8
View File
@@ -66,6 +66,7 @@ export class SessionStore {
this.addSessionPlatformSourceColumn();
this.addObservationModelColumns();
this.ensureMergedIntoProjectColumns();
this.addObservationSubagentColumns();
}
/**
@@ -975,6 +976,44 @@ export class SessionStore {
);
}
/**
* Add agent_type and agent_id columns to observations and pending_messages (migration 27).
* Mirrors MigrationRunner.addObservationSubagentColumns so bundled artifacts that embed
* SessionStore (e.g. context-generator.cjs) stay schema-consistent.
*/
private addObservationSubagentColumns(): void {
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(27) as SchemaVersion | undefined;
const obsCols = this.db.query('PRAGMA table_info(observations)').all() as TableColumnInfo[];
const obsHasAgentType = obsCols.some(col => col.name === 'agent_type');
const obsHasAgentId = obsCols.some(col => col.name === 'agent_id');
if (!obsHasAgentType) {
this.db.run('ALTER TABLE observations ADD COLUMN agent_type TEXT');
}
if (!obsHasAgentId) {
this.db.run('ALTER TABLE observations ADD COLUMN agent_id TEXT');
}
this.db.run('CREATE INDEX IF NOT EXISTS idx_observations_agent_type ON observations(agent_type)');
this.db.run('CREATE INDEX IF NOT EXISTS idx_observations_agent_id ON observations(agent_id)');
const pendingCols = this.db.query('PRAGMA table_info(pending_messages)').all() as TableColumnInfo[];
if (pendingCols.length > 0) {
const pendingHasAgentType = pendingCols.some(col => col.name === 'agent_type');
const pendingHasAgentId = pendingCols.some(col => col.name === 'agent_id');
if (!pendingHasAgentType) {
this.db.run('ALTER TABLE pending_messages ADD COLUMN agent_type TEXT');
}
if (!pendingHasAgentId) {
this.db.run('ALTER TABLE pending_messages ADD COLUMN agent_id TEXT');
}
}
if (!applied) {
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(27, new Date().toISOString());
}
}
/**
* Update the memory session ID for a session
* Called by SDKAgent when it captures the session ID from the first SDK message
@@ -1734,6 +1773,8 @@ export class SessionStore {
concepts: string[];
files_read: string[];
files_modified: string[];
agent_type?: string | null;
agent_id?: string | null;
},
promptNumber?: number,
discoveryTokens: number = 0,
@@ -1754,9 +1795,9 @@ export class SessionStore {
const stmt = this.db.prepare(`
INSERT INTO observations
(memory_session_id, project, type, title, subtitle, facts, narrative, concepts,
files_read, files_modified, prompt_number, discovery_tokens, content_hash, created_at, created_at_epoch,
files_read, files_modified, prompt_number, discovery_tokens, agent_type, agent_id, content_hash, created_at, created_at_epoch,
generated_by_model)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
@@ -1772,6 +1813,8 @@ export class SessionStore {
JSON.stringify(observation.files_modified),
promptNumber || null,
discoveryTokens,
observation.agent_type ?? null,
observation.agent_id ?? null,
contentHash,
timestampIso,
timestampEpoch,
@@ -1863,6 +1906,8 @@ export class SessionStore {
concepts: string[];
files_read: string[];
files_modified: string[];
agent_type?: string | null;
agent_id?: string | null;
}>,
summary: {
request: string;
@@ -1889,9 +1934,9 @@ export class SessionStore {
const obsStmt = this.db.prepare(`
INSERT INTO observations
(memory_session_id, project, type, title, subtitle, facts, narrative, concepts,
files_read, files_modified, prompt_number, discovery_tokens, content_hash, created_at, created_at_epoch,
files_read, files_modified, prompt_number, discovery_tokens, agent_type, agent_id, content_hash, created_at, created_at_epoch,
generated_by_model)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const observation of observations) {
@@ -1916,6 +1961,8 @@ export class SessionStore {
JSON.stringify(observation.files_modified),
promptNumber || null,
discoveryTokens,
observation.agent_type ?? null,
observation.agent_id ?? null,
contentHash,
timestampIso,
timestampEpoch,
@@ -1993,6 +2040,8 @@ export class SessionStore {
concepts: string[];
files_read: string[];
files_modified: string[];
agent_type?: string | null;
agent_id?: string | null;
}>,
summary: {
request: string;
@@ -2021,9 +2070,9 @@ export class SessionStore {
const obsStmt = this.db.prepare(`
INSERT INTO observations
(memory_session_id, project, type, title, subtitle, facts, narrative, concepts,
files_read, files_modified, prompt_number, discovery_tokens, content_hash, created_at, created_at_epoch,
files_read, files_modified, prompt_number, discovery_tokens, agent_type, agent_id, content_hash, created_at, created_at_epoch,
generated_by_model)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const observation of observations) {
@@ -2048,6 +2097,8 @@ export class SessionStore {
JSON.stringify(observation.files_modified),
promptNumber || null,
discoveryTokens,
observation.agent_type ?? null,
observation.agent_id ?? null,
contentHash,
timestampIso,
timestampEpoch,
@@ -2608,6 +2659,8 @@ export class SessionStore {
discovery_tokens: number;
created_at: string;
created_at_epoch: number;
agent_type?: string | null;
agent_id?: string | null;
}): { imported: boolean; id: number } {
// Check if observation already exists
const existing = this.db.prepare(`
@@ -2623,8 +2676,9 @@ export class SessionStore {
INSERT INTO observations (
memory_session_id, project, text, type, title, subtitle,
facts, narrative, concepts, files_read, files_modified,
prompt_number, discovery_tokens, created_at, created_at_epoch
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
prompt_number, discovery_tokens, agent_type, agent_id,
created_at, created_at_epoch
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
@@ -2641,6 +2695,8 @@ export class SessionStore {
obs.files_modified,
obs.prompt_number,
obs.discovery_tokens || 0,
obs.agent_type ?? null,
obs.agent_id ?? null,
obs.created_at,
obs.created_at_epoch
);
+7 -2
View File
@@ -141,6 +141,8 @@ export function importObservation(
discovery_tokens: number;
created_at: string;
created_at_epoch: number;
agent_type?: string | null;
agent_id?: string | null;
}
): ImportResult {
// Check if observation already exists
@@ -163,8 +165,9 @@ export function importObservation(
INSERT INTO observations (
memory_session_id, project, text, type, title, subtitle,
facts, narrative, concepts, files_read, files_modified,
prompt_number, discovery_tokens, created_at, created_at_epoch
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
prompt_number, discovery_tokens, agent_type, agent_id,
created_at, created_at_epoch
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
@@ -181,6 +184,8 @@ export function importObservation(
obs.files_modified,
obs.prompt_number,
obs.discovery_tokens || 0,
obs.agent_type ?? null,
obs.agent_id ?? null,
obs.created_at,
obs.created_at_epoch
);
+59 -2
View File
@@ -1,5 +1,6 @@
import { Database } from 'bun:sqlite';
import { Migration } from './Database.js';
import { logger } from '../../utils/logger.js';
// Re-export MigrationRunner for SessionStore migration extraction
export { MigrationRunner } from './migrations/runner.js';
@@ -572,6 +573,61 @@ export const migration009: Migration = {
}
};
/**
* Migration 010: Label observations (and their queue rows) with the subagent identity.
*
* Claude Code hooks that fire inside a subagent carry agent_id and agent_type on the
* stdin payload. These flow hook worker pending_messages SDK storage so that
* observation rows can be attributed to the originating subagent. Main-session rows
* keep NULL for both columns.
*/
export const migration010: Migration = {
version: 27,
up: (db: Database) => {
const added: string[] = [];
const obsColumns = db.prepare('PRAGMA table_info(observations)').all() as Array<{ name: string }>;
const obsHasAgentType = obsColumns.some(c => c.name === 'agent_type');
const obsHasAgentId = obsColumns.some(c => c.name === 'agent_id');
if (!obsHasAgentType) {
db.run('ALTER TABLE observations ADD COLUMN agent_type TEXT');
added.push('observations.agent_type');
}
if (!obsHasAgentId) {
db.run('ALTER TABLE observations ADD COLUMN agent_id TEXT');
added.push('observations.agent_id');
}
db.run('CREATE INDEX IF NOT EXISTS idx_observations_agent_type ON observations(agent_type)');
db.run('CREATE INDEX IF NOT EXISTS idx_observations_agent_id ON observations(agent_id)');
// Also thread the same fields through the pending_messages queue so the label
// survives worker restarts between enqueue and SDK-agent processing.
const pendingColumns = db.prepare('PRAGMA table_info(pending_messages)').all() as Array<{ name: string }>;
if (pendingColumns.length > 0) {
const pendingHasAgentType = pendingColumns.some(c => c.name === 'agent_type');
const pendingHasAgentId = pendingColumns.some(c => c.name === 'agent_id');
if (!pendingHasAgentType) {
db.run('ALTER TABLE pending_messages ADD COLUMN agent_type TEXT');
added.push('pending_messages.agent_type');
}
if (!pendingHasAgentId) {
db.run('ALTER TABLE pending_messages ADD COLUMN agent_id TEXT');
added.push('pending_messages.agent_id');
}
}
logger.debug(
'DB',
added.length > 0
? `[migration010] Added columns: ${added.join(', ')}`
: '[migration010] Subagent identity columns already present; ensured indexes'
);
},
down: (_db: Database) => {
// SQLite DROP COLUMN not fully supported; no-op
}
};
/**
* All migrations in order
*/
@@ -584,5 +640,6 @@ export const migrations: Migration[] = [
migration006,
migration007,
migration008,
migration009
];
migration009,
migration010
];
+48
View File
@@ -38,6 +38,7 @@ export class MigrationRunner {
this.createObservationFeedbackTable();
this.addSessionPlatformSourceColumn();
this.ensureMergedIntoProjectColumns();
this.addObservationSubagentColumns();
}
/**
@@ -952,4 +953,51 @@ export class MigrationRunner {
'CREATE INDEX IF NOT EXISTS idx_summaries_merged_into ON session_summaries(merged_into_project)'
);
}
/**
* Add agent_type and agent_id columns to observations and pending_messages (migration 27).
*
* Labels observation rows with the originating Claude Code subagent identity so
* downstream queries can distinguish main-session work from subagent work.
* Main-session rows keep NULL for both columns.
*
* Also threads the same columns through pending_messages so the label survives
* between enqueue (hook) and SDK-agent processing (which re-inserts into observations).
*/
private addObservationSubagentColumns(): void {
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(27) as SchemaVersion | undefined;
const obsCols = this.db.query('PRAGMA table_info(observations)').all() as TableColumnInfo[];
const obsHasAgentType = obsCols.some(c => c.name === 'agent_type');
const obsHasAgentId = obsCols.some(c => c.name === 'agent_id');
if (!obsHasAgentType) {
this.db.run('ALTER TABLE observations ADD COLUMN agent_type TEXT');
logger.debug('DB', 'Added agent_type column to observations table');
}
if (!obsHasAgentId) {
this.db.run('ALTER TABLE observations ADD COLUMN agent_id TEXT');
logger.debug('DB', 'Added agent_id column to observations table');
}
this.db.run('CREATE INDEX IF NOT EXISTS idx_observations_agent_type ON observations(agent_type)');
this.db.run('CREATE INDEX IF NOT EXISTS idx_observations_agent_id ON observations(agent_id)');
const pendingCols = this.db.query('PRAGMA table_info(pending_messages)').all() as TableColumnInfo[];
if (pendingCols.length > 0) {
const pendingHasAgentType = pendingCols.some(c => c.name === 'agent_type');
const pendingHasAgentId = pendingCols.some(c => c.name === 'agent_id');
if (!pendingHasAgentType) {
this.db.run('ALTER TABLE pending_messages ADD COLUMN agent_type TEXT');
logger.debug('DB', 'Added agent_type column to pending_messages table');
}
if (!pendingHasAgentId) {
this.db.run('ALTER TABLE pending_messages ADD COLUMN agent_id TEXT');
logger.debug('DB', 'Added agent_id column to pending_messages table');
}
}
if (!applied) {
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(27, new Date().toISOString());
}
}
}
+6 -2
View File
@@ -15,6 +15,8 @@ const DEDUP_WINDOW_MS = 30_000;
/**
* Compute a short content hash for deduplication.
* Uses (memory_session_id, title, narrative) as the semantic identity of an observation.
* Subagent fields (agent_type, agent_id) are intentionally excluded so the same work
* described once by a subagent and once by its parent deduplicates across contexts.
*/
export function computeObservationContentHash(
memorySessionId: string,
@@ -75,8 +77,8 @@ export function storeObservation(
const stmt = db.prepare(`
INSERT INTO observations
(memory_session_id, project, type, title, subtitle, facts, narrative, concepts,
files_read, files_modified, prompt_number, discovery_tokens, content_hash, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
files_read, files_modified, prompt_number, discovery_tokens, agent_type, agent_id, content_hash, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
const result = stmt.run(
@@ -92,6 +94,8 @@ export function storeObservation(
JSON.stringify(observation.files_modified),
promptNumber || null,
discoveryTokens,
observation.agent_type ?? null,
observation.agent_id ?? null,
contentHash,
timestampIso,
timestampEpoch
@@ -16,6 +16,9 @@ export interface ObservationInput {
concepts: string[];
files_read: string[];
files_modified: string[];
// Claude Code subagent identity — NULL for main-session rows.
agent_type?: string | null;
agent_id?: string | null;
}
/**
+8 -4
View File
@@ -68,8 +68,8 @@ export function storeObservationsAndMarkComplete(
const obsStmt = db.prepare(`
INSERT INTO observations
(memory_session_id, project, type, title, subtitle, facts, narrative, concepts,
files_read, files_modified, prompt_number, discovery_tokens, content_hash, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
files_read, files_modified, prompt_number, discovery_tokens, agent_type, agent_id, content_hash, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const observation of observations) {
@@ -93,6 +93,8 @@ export function storeObservationsAndMarkComplete(
JSON.stringify(observation.files_modified),
promptNumber || null,
discoveryTokens,
observation.agent_type ?? null,
observation.agent_id ?? null,
contentHash,
timestampIso,
timestampEpoch
@@ -187,8 +189,8 @@ export function storeObservations(
const obsStmt = db.prepare(`
INSERT INTO observations
(memory_session_id, project, type, title, subtitle, facts, narrative, concepts,
files_read, files_modified, prompt_number, discovery_tokens, content_hash, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
files_read, files_modified, prompt_number, discovery_tokens, agent_type, agent_id, content_hash, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const observation of observations) {
@@ -212,6 +214,8 @@ export function storeObservations(
JSON.stringify(observation.files_modified),
promptNumber || null,
discoveryTokens,
observation.agent_type ?? null,
observation.agent_id ?? null,
contentHash,
timestampIso,
timestampEpoch
+11
View File
@@ -49,6 +49,11 @@ export interface ActiveSession {
// Circuit breaker: track consecutive summary failures to prevent infinite retry loops (#1633).
// When this reaches MAX_CONSECUTIVE_SUMMARY_FAILURES, further summarize requests are skipped.
consecutiveSummaryFailures: number;
// Subagent identity carried forward from the most recent claimed pending message.
// When observations are parsed and stored, these fields label the resulting rows
// so subagent work is attributable. NULL / undefined means the batch came from the main session.
pendingAgentId?: string | null;
pendingAgentType?: string | null;
}
export interface PendingMessage {
@@ -59,6 +64,9 @@ export interface PendingMessage {
prompt_number?: number;
cwd?: string;
last_assistant_message?: string;
// Claude Code subagent identity — present only when the hook fired inside a subagent.
agentId?: string;
agentType?: string;
}
/**
@@ -77,6 +85,9 @@ export interface ObservationData {
tool_response: any;
prompt_number: number;
cwd?: string;
// Claude Code subagent identity — present only when the hook fired inside a subagent.
agentId?: string;
agentType?: string;
}
// ============================================================================
+7
View File
@@ -201,6 +201,13 @@ export class GeminiAgent {
// The message is now in 'processing' status in DB until ResponseProcessor calls confirmProcessed()
session.processingMessageIds.push(message._persistentId);
// Capture subagent identity from the claimed message so ResponseProcessor
// can label observation rows with the originating Claude Code subagent.
// Always overwrite (even with null) so a main-session message after a subagent
// message clears the stale identity; otherwise mixed batches could mislabel.
session.pendingAgentId = message.agentId ?? null;
session.pendingAgentType = message.agentType ?? null;
// Capture cwd from each message for worktree support
if (message.cwd) {
lastCwd = message.cwd;
+7
View File
@@ -150,6 +150,13 @@ export class OpenRouterAgent {
// The message is now in 'processing' status in DB until ResponseProcessor calls confirmProcessed()
session.processingMessageIds.push(message._persistentId);
// Capture subagent identity from the claimed message so ResponseProcessor
// can label observation rows with the originating Claude Code subagent.
// Always overwrite (even with null) so a main-session message after a subagent
// message clears the stale identity; otherwise mixed batches could mislabel.
session.pendingAgentId = message.agentId ?? null;
session.pendingAgentType = message.agentType ?? null;
// Capture cwd from messages for proper worktree support
if (message.cwd) {
lastCwd = message.cwd;
+7
View File
@@ -374,6 +374,13 @@ export class SDKAgent {
// The message is now in 'processing' status in DB until ResponseProcessor calls confirmProcessed()
session.processingMessageIds.push(message._persistentId);
// Capture subagent identity from the claimed message so ResponseProcessor
// can label observation rows with the originating Claude Code subagent.
// Always overwrite (even with null) so a main-session message after a subagent
// message clears the stale identity; otherwise mixed batches could mislabel.
session.pendingAgentId = message.agentId ?? null;
session.pendingAgentType = message.agentType ?? null;
// Capture cwd from each message for worktree support
if (message.cwd) {
cwdTracker.lastCwd = message.cwd;
+6 -2
View File
@@ -221,7 +221,9 @@ export class SessionManager {
consecutiveRestarts: 0, // Track consecutive restart attempts to prevent infinite loops
processingMessageIds: [], // CLAIM-CONFIRM: Track message IDs for confirmProcessed()
lastGeneratorActivity: Date.now(), // Initialize for stale detection (Issue #1099)
consecutiveSummaryFailures: 0 // Circuit breaker for summary retry loop (#1633)
consecutiveSummaryFailures: 0, // Circuit breaker for summary retry loop (#1633)
pendingAgentId: null, // Subagent identity carried from the most recent claimed message
pendingAgentType: null // (null for main-session messages)
};
logger.debug('SESSION', 'Creating new session object (memorySessionId cleared to prevent stale resume)', {
@@ -277,7 +279,9 @@ export class SessionManager {
tool_input: data.tool_input,
tool_response: data.tool_response,
prompt_number: data.prompt_number,
cwd: data.cwd
cwd: data.cwd,
agentId: data.agentId,
agentType: data.agentType
};
try {
+29 -11
View File
@@ -118,18 +118,36 @@ export async function processAgentResponse(
memorySessionId: session.memorySessionId
});
// Label observations with the subagent identity captured from the claimed messages.
// Main-session messages leave these null, so main-session rows stay NULL in the DB.
const labeledObservations = observations.map(obs => ({
...obs,
agent_type: session.pendingAgentType ?? null,
agent_id: session.pendingAgentId ?? null
}));
// ATOMIC TRANSACTION: Store observations + summary ONCE
// Messages are already deleted from queue on claim, so no completion tracking needed
const result = sessionStore.storeObservations(
session.memorySessionId,
session.project,
observations,
summaryForStore,
session.lastPromptNumber,
discoveryTokens,
originalTimestamp ?? undefined,
modelId
);
// Messages are already deleted from queue on claim, so no completion tracking needed.
// Wrap in try/finally so the subagent tracker clears even if storage throws —
// otherwise stale identity could leak into the next batch and mislabel rows.
// Expected invariant: all observations in a batch share the same agent context,
// because ResponseProcessor runs after a single agent-response cycle.
let result: ReturnType<typeof sessionStore.storeObservations>;
try {
result = sessionStore.storeObservations(
session.memorySessionId,
session.project,
labeledObservations,
summaryForStore,
session.lastPromptNumber,
discoveryTokens,
originalTimestamp ?? undefined,
modelId
);
} finally {
session.pendingAgentId = null;
session.pendingAgentType = null;
}
// Log storage result with IDs for end-to-end traceability
logger.info('DB', `STORED | sessionDbId=${session.sessionDbId} | memorySessionId=${session.memorySessionId} | obsCount=${result.observationIds.length} | obsIds=[${result.observationIds.join(',')}] | summaryId=${result.summaryId || 'none'}`, {
@@ -553,7 +553,7 @@ export class SessionRoutes extends BaseRouteHandler {
* Body: { contentSessionId, tool_name, tool_input, tool_response, cwd }
*/
private handleObservationsByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
const { contentSessionId, tool_name, tool_input, tool_response, cwd } = req.body;
const { contentSessionId, tool_name, tool_input, tool_response, cwd, agentId, agentType } = req.body;
const platformSource = normalizePlatformSource(req.body.platformSource);
const project = typeof cwd === 'string' && cwd.trim() ? getProjectContext(cwd).primary : '';
@@ -628,7 +628,9 @@ export class SessionRoutes extends BaseRouteHandler {
tool_name
});
return '';
})()
})(),
agentId: typeof agentId === 'string' ? agentId : undefined,
agentType: typeof agentType === 'string' ? agentType : undefined,
});
// Ensure SDK agent is running
@@ -653,13 +655,21 @@ export class SessionRoutes extends BaseRouteHandler {
* Checks privacy, queues summarize request for SDK agent
*/
private handleSummarizeByClaudeId = this.wrapHandler((req: Request, res: Response): void => {
const { contentSessionId, last_assistant_message } = req.body;
const { contentSessionId, last_assistant_message, agentId } = req.body;
const platformSource = normalizePlatformSource(req.body.platformSource);
if (!contentSessionId) {
return this.badRequest(res, 'Missing contentSessionId');
}
// Belt-and-suspenders: reject summarize requests from subagent context.
// Gate on agentId only — agentType alone indicates a main session started with
// --agent, which still owns its summary. Mirrors the hook-side guard in summarize.ts.
if (agentId) {
res.json({ status: 'skipped', reason: 'subagent_context' });
return;
}
const store = this.dbManager.getSessionStore();
// Get or create session
@@ -0,0 +1,120 @@
/**
* Tests for Claude Code adapter subagent field extraction.
*
* Validates that normalizeInput picks up the `agent_id` / `agent_type`
* fields from Claude Code hook stdin and that the type guard rejects
* non-string values. These fields are the discriminator for subagent
* context; they are undefined in main-session payloads.
*
* Sources:
* - Adapter: src/cli/adapters/claude-code.ts
* - Types: src/cli/types.ts
*/
import { describe, it, expect } from 'bun:test';
import { claudeCodeAdapter } from '../../../src/cli/adapters/claude-code.js';
describe('claudeCodeAdapter.normalizeInput — subagent fields', () => {
it('extracts agentId and agentType when both are present', () => {
const normalized = claudeCodeAdapter.normalizeInput({
session_id: 's1',
cwd: '/tmp',
agent_id: 'agent-abc',
agent_type: 'Explore',
});
expect(normalized.sessionId).toBe('s1');
expect(normalized.cwd).toBe('/tmp');
expect(normalized.agentId).toBe('agent-abc');
expect(normalized.agentType).toBe('Explore');
});
it('leaves agentId and agentType undefined when fields are absent (main-session payload)', () => {
const normalized = claudeCodeAdapter.normalizeInput({
session_id: 's1',
cwd: '/tmp',
});
expect(normalized.sessionId).toBe('s1');
expect(normalized.agentId).toBeUndefined();
expect(normalized.agentType).toBeUndefined();
});
it('rejects non-string agent_id via type guard (returns undefined)', () => {
const normalized = claudeCodeAdapter.normalizeInput({
session_id: 's1',
cwd: '/tmp',
agent_id: 42,
});
expect(normalized.agentId).toBeUndefined();
});
it('rejects non-string agent_type via type guard (returns undefined)', () => {
const normalized = claudeCodeAdapter.normalizeInput({
session_id: 's1',
cwd: '/tmp',
agent_type: { kind: 'Explore' },
});
expect(normalized.agentType).toBeUndefined();
});
it('extracts agentId alone even when agent_type is missing', () => {
const normalized = claudeCodeAdapter.normalizeInput({
session_id: 's1',
cwd: '/tmp',
agent_id: 'agent-only',
});
expect(normalized.agentId).toBe('agent-only');
expect(normalized.agentType).toBeUndefined();
});
it('handles null/undefined raw input gracefully (SessionStart hook)', () => {
const normalizedNull = claudeCodeAdapter.normalizeInput(null);
const normalizedUndef = claudeCodeAdapter.normalizeInput(undefined);
expect(normalizedNull.agentId).toBeUndefined();
expect(normalizedNull.agentType).toBeUndefined();
expect(normalizedUndef.agentId).toBeUndefined();
expect(normalizedUndef.agentType).toBeUndefined();
});
it('drops agent fields that exceed the 128-char safety cap', () => {
const oversized = 'a'.repeat(129);
const normalized = claudeCodeAdapter.normalizeInput({
session_id: 's1',
cwd: '/tmp',
agent_id: oversized,
agent_type: oversized,
});
expect(normalized.agentId).toBeUndefined();
expect(normalized.agentType).toBeUndefined();
});
it('keeps agent fields exactly at the 128-char boundary', () => {
const atLimit = 'a'.repeat(128);
const normalized = claudeCodeAdapter.normalizeInput({
session_id: 's1',
cwd: '/tmp',
agent_id: atLimit,
agent_type: atLimit,
});
expect(normalized.agentId).toBe(atLimit);
expect(normalized.agentType).toBe(atLimit);
});
it('drops empty-string agent fields (treat as absent)', () => {
const normalized = claudeCodeAdapter.normalizeInput({
session_id: 's1',
cwd: '/tmp',
agent_id: '',
agent_type: '',
});
expect(normalized.agentId).toBeUndefined();
expect(normalized.agentType).toBeUndefined();
});
});
@@ -0,0 +1,143 @@
/**
* Tests for subagent-context short-circuit in summarizeHandler.
*
* Validates that when the Stop hook fires inside a Claude Code subagent
* (identified by `agentId` or `agentType` on NormalizedHookInput), the
* summarize handler exits before calling the worker subagents must not
* own the session summary.
*
* Sources:
* - Handler: src/cli/handlers/summarize.ts
* - Mock pattern: tests/hooks/context-reinjection-guard.test.ts
*/
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from 'bun:test';
import { homedir } from 'os';
import { join } from 'path';
// Mock modules that touch the filesystem / network at import time.
// MUST be declared before the handler is imported.
mock.module('../../../src/shared/SettingsDefaultsManager.js', () => ({
SettingsDefaultsManager: {
get: (key: string) => {
if (key === 'CLAUDE_MEM_DATA_DIR') return join(homedir(), '.claude-mem');
return '';
},
getInt: () => 0,
loadFromFile: () => ({ CLAUDE_MEM_EXCLUDED_PROJECTS: [] }),
},
}));
// workerHttpRequest is the only worker entry point we must NOT call in
// subagent context. It throws so we can assert "never called" by proving
// the handler returns success anyway.
const workerCallLog: Array<{ path: string; options: any }> = [];
mock.module('../../../src/shared/worker-utils.js', () => ({
ensureWorkerRunning: () => Promise.resolve(true),
getWorkerPort: () => 37777,
workerHttpRequest: (apiPath: string, options?: any) => {
workerCallLog.push({ path: apiPath, options });
throw new Error(
`workerHttpRequest MUST NOT be called in subagent context (called with ${apiPath})`
);
},
}));
// Suppress logger during tests
import { logger } from '../../../src/utils/logger.js';
let loggerSpies: ReturnType<typeof spyOn>[] = [];
beforeEach(() => {
workerCallLog.length = 0;
loggerSpies = [
spyOn(logger, 'info').mockImplementation(() => {}),
spyOn(logger, 'debug').mockImplementation(() => {}),
spyOn(logger, 'warn').mockImplementation(() => {}),
spyOn(logger, 'error').mockImplementation(() => {}),
spyOn(logger, 'failure').mockImplementation(() => {}),
spyOn(logger, 'dataIn').mockImplementation(() => {}),
];
});
afterEach(() => {
loggerSpies.forEach(spy => spy.mockRestore());
});
describe('summarizeHandler — subagent short-circuit', () => {
it('skips summary and returns SUCCESS when agentId is set', async () => {
const { summarizeHandler } = await import('../../../src/cli/handlers/summarize.js');
const result = await summarizeHandler.execute({
sessionId: 'session-abc',
cwd: '/tmp',
platform: 'claude-code',
transcriptPath: '/tmp/does-not-matter.jsonl',
agentId: 'agent-abc',
});
expect(result.continue).toBe(true);
expect(result.suppressOutput).toBe(true);
expect(result.exitCode).toBe(0);
// Guard fires BEFORE any worker HTTP request. If workerHttpRequest were
// called, our mock would have thrown — reaching this expect proves it.
expect(workerCallLog.length).toBe(0);
});
it('does NOT skip when only agentType is set (--agent main session still owns its summary)', async () => {
// agent_type without agent_id is how Claude Code signals a main session started
// with --agent. These are main sessions, not Task-spawned subagents, so the
// summary path must proceed. Here the transcript path is missing so the handler
// falls through to the existing no-transcriptPath return — the key assertion is
// that the subagent guard did NOT short-circuit (handler reached the normal path).
const { summarizeHandler } = await import('../../../src/cli/handlers/summarize.js');
const result = await summarizeHandler.execute({
sessionId: 'session-def',
cwd: '/tmp',
platform: 'claude-code',
agentType: 'Explore',
// transcriptPath intentionally omitted
});
expect(result.continue).toBe(true);
expect(result.exitCode).toBe(0);
expect(workerCallLog.length).toBe(0);
});
it('skips summary when both agentId and agentType are set', async () => {
const { summarizeHandler } = await import('../../../src/cli/handlers/summarize.js');
const result = await summarizeHandler.execute({
sessionId: 'session-both',
cwd: '/tmp',
platform: 'claude-code',
transcriptPath: '/tmp/does-not-matter.jsonl',
agentId: 'agent-xyz',
agentType: 'Plan',
});
expect(result.continue).toBe(true);
expect(result.suppressOutput).toBe(true);
expect(result.exitCode).toBe(0);
expect(workerCallLog.length).toBe(0);
});
it('falls through to existing no-transcriptPath guard in main-session context', async () => {
// Neither agentId nor agentType → NOT a subagent. Handler should
// proceed past the subagent guard and hit the existing
// "no transcriptPath" early return. Worker must still not be called.
const { summarizeHandler } = await import('../../../src/cli/handlers/summarize.js');
const result = await summarizeHandler.execute({
sessionId: 'session-main',
cwd: '/tmp',
platform: 'claude-code',
// transcriptPath intentionally omitted
});
expect(result.continue).toBe(true);
expect(result.suppressOutput).toBe(true);
expect(result.exitCode).toBe(0);
expect(workerCallLog.length).toBe(0);
});
});
@@ -0,0 +1,161 @@
/**
* 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');
});
});