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:
@@ -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
+215
-213
File diff suppressed because one or more lines are too long
+11
-11
File diff suppressed because one or more lines are too long
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
];
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user