fix(hooks-claude): build subagent payloads from base(), run SubagentStop in the child cwd, drop agentType
Address the D agentType removal + two #124 review findings on the CC bridge's subagent points: - **Payloads from base()**: `subagentStart/StopPayload` bypassed `base()`, so the SubagentStart/SubagentStop stdin payloads omitted the CC-promised `session_id` and `cwd`. Replaced both with a single `subagentPayload()` built from `base(child)` (the child's session_id/cwd when the child is available) + `agent_id` + `agent_type` (+ `stop_hook_active` on Stop). - **SubagentStop runs in the child cwd**: the listener called `runPoint(..., {})` with no agent, so the hook ran in the executor/server cwd. It now looks the child up via `ctx.get('agents').get(info.id)` — still recoverable because `subagent/end` fires from the service's detached `.then` BEFORE the tool caller disposes the child — and passes `{ agent: child }`, matching SubagentStart. New regression: server cwd ≠ child cwd, a `pwd` SubagentStop hook proves it ran in the CHILD workspace (proven red by neutering the lookup). - **agent_type is a constant**: `info.agentType` no longer exists (removed on the subagent branch); both points now report the `SUBAGENT_TYPE = "general-purpose"` constant (Claude Code's Task-tool default), so a hooks.json default/`*`/empty `agent_type` matcher fires. Updated the README matcher-subject note and the bridge/coverage tests (dropped their agentType emits). - **e2e comment**: hooks.e2e.ts said `./hooks.json` loads from the session cwd; corrected to process-level (server launch cwd), with the hook itself running in the session cwd.
This commit is contained in:
@@ -289,8 +289,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () =>
|
||||
// Drive the observe-only lifecycle events directly (no real child needed — the
|
||||
// bridge just listens). The agents registry is absent here, so SubagentStart's
|
||||
// child lookup yields undefined and it simply runs the hook.
|
||||
ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1'), agentType: 'researcher' })
|
||||
ctx.emit('subagent/end', { provider: 'inproc', id: AgentId('child-1'), agentType: 'researcher', stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] })
|
||||
ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') })
|
||||
ctx.emit('subagent/end', { provider: 'inproc', id: AgentId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] })
|
||||
|
||||
// Both hooks run async (detached .then); poll for their marker files rather
|
||||
// than a fixed sleep that flakes under load.
|
||||
|
||||
@@ -185,7 +185,7 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch',
|
||||
const injected: string[] = []
|
||||
const child = { id: AgentId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { header: { id: 'child-x' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
|
||||
ctx.agents.register(child)
|
||||
ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-x'), agentType: 'r' })
|
||||
ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-x') })
|
||||
await waitFor(() => injected.includes('child guidance'))
|
||||
expect(injected).toContain('child guidance')
|
||||
})
|
||||
@@ -236,17 +236,16 @@ describe('hooks-claude coverage — default reasons + sparse payloads', () => {
|
||||
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true)
|
||||
})
|
||||
|
||||
it('SubagentStop with no agentType + a rejecting hook run is contained', async () => {
|
||||
it('SubagentStop with no registered child runs the hook cleanly (fire-and-forget)', async () => {
|
||||
const d = dir()
|
||||
// Make the SubagentStop runPoint reject by registering a session whose append
|
||||
// throws — simplest: a hook that emits invalid output is fine; force the
|
||||
// .catch by making the session's append throw via a poisoned agent is hard,
|
||||
// so instead assert the no-agentType payload path runs cleanly (no crash).
|
||||
// The agents registry has no entry for the id, so the child lookup yields
|
||||
// undefined and the payload falls back to base(undefined) — assert the
|
||||
// observe-only SubagentStop run still executes the hook without crashing.
|
||||
const marker = join(d, 'stopran')
|
||||
const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
|
||||
const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const ctx = await harness(path, new MockAdapter([]))
|
||||
ctx.emit('subagent/end', { provider: 'p', id: AgentId('child-z'), stopReason: 'completed' }) // no agentType
|
||||
ctx.emit('subagent/end', { provider: 'p', id: AgentId('child-z'), stopReason: 'completed' })
|
||||
await waitFor(() => existsSync(marker))
|
||||
expect(existsSync(marker)).toBe(true)
|
||||
})
|
||||
@@ -492,6 +491,44 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server
|
||||
expect(where.endsWith(sessionDir.split('/').pop()!)).toBe(true)
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('runs a SubagentStop hook in the CHILD session workspace, not the server cwd', async () => {
|
||||
// The bug: SubagentStop ran runPoint(..., {}) with no agent, so the hook fell
|
||||
// back to the executor default (server cwd). SubagentStop must look the child
|
||||
// up (still recoverable at subagent/end) and run in the CHILD's session cwd.
|
||||
// Here the executor default and the child session cwd are DIFFERENT dirs; a
|
||||
// SubagentStop hook writes `pwd` to a marker and we assert it ran in the CHILD
|
||||
// dir. (Proven to regress: neuter the child lookup and the marker lands in the
|
||||
// server dir instead.)
|
||||
const serverDir = dir()
|
||||
const childDir = dir()
|
||||
const marker = join(childDir, 'stopwhere')
|
||||
hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'pwd > stopwhere' }] }] })
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
// Executor default cwd = serverDir (deliberately NOT the child session cwd).
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
|
||||
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||
|
||||
// Register a live child on its own session cwd; emit subagent/end with its id.
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const childHandle = ctx.agents.create({ agentId: AgentId('child-stop'), sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } })
|
||||
ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' })
|
||||
|
||||
await waitFor(() => existsSync(marker))
|
||||
expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir
|
||||
const { readFileSync } = await import('node:fs')
|
||||
const where = readFileSync(marker, 'utf8').trim()
|
||||
// `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames.
|
||||
expect(where.endsWith(childDir.split('/').pop()!)).toBe(true)
|
||||
await childHandle.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude coverage — systemMessage is warned, not surfaced', () => {
|
||||
|
||||
Reference in New Issue
Block a user