diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 277ecf6..3119b2b 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -247,6 +247,18 @@ export async function runHeadless(opts: HeadlessOpts): Promise { process.on('SIGTERM', sigintHandler); // ─── run ──────────────────────────────────────────────────────────── + // SessionStart hook (headless is a one-shot session). Agent-loop hooks + // (UserPromptSubmit/Stop/…) fire from runAgent; SessionEnd fires in finally. + try { + await hooks.dispatch({ + event: 'SessionStart', + cwd, + triggeredAt: new Date().toISOString(), + payload: { sessionId: session.id, source: 'headless' }, + }); + } catch { + /* ignore */ + } let exitCode = 0; try { const result = await runAgent({ @@ -326,6 +338,16 @@ export async function runHeadless(opts: HeadlessOpts): Promise { } exitCode = 3; } finally { + try { + await hooks.dispatch({ + event: 'SessionEnd', + cwd, + triggeredAt: new Date().toISOString(), + payload: { sessionId: session.id, exitCode }, + }); + } catch { + /* ignore */ + } process.off('SIGINT', sigintHandler); process.off('SIGTERM', sigintHandler); if (mcpServers.length > 0) await closeAllMcpServers(mcpServers); diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index 98ac033..d7712a3 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -336,6 +336,24 @@ export async function startRepl(opts: ReplOpts): Promise { }, 2000); }); + // Session-lifecycle hooks (the agent loop fires the per-turn ones). + const fireLifecycle = async ( + event: 'SessionStart' | 'SessionEnd' | 'Notification', + payload: Record = {}, + ): Promise => { + try { + await hooks.dispatch({ + event, + cwd: ctx.cwd, + triggeredAt: new Date().toISOString(), + payload, + }); + } catch { + /* hook failure must not break the REPL */ + } + }; + await fireLifecycle('SessionStart', { sessionId: session.id, source: 'cli' }); + while (true) { let userInput: string; try { @@ -478,8 +496,14 @@ export async function startRepl(opts: ReplOpts): Promise { if (result.stopReason === 'error') { output.write(' ✕ Error during agent loop. Try again or /status to inspect.\n\n'); } + // Notification hook — the turn finished and control returns to the user. + await fireLifecycle('Notification', { + message: 'DeepCode finished responding — awaiting your input.', + stopReason: result.stopReason, + }); } + await fireLifecycle('SessionEnd', { sessionId: session.id }); rl.close(); // Clean up MCP server connections if (mcpServers.length > 0) { diff --git a/docs/BEHAVIOR_PARITY.md b/docs/BEHAVIOR_PARITY.md index c31b0d7..ced3431 100644 --- a/docs/BEHAVIOR_PARITY.md +++ b/docs/BEHAVIOR_PARITY.md @@ -75,13 +75,13 @@ Specific deviations: | PreToolUse | ✓ | ✓ | ✅ | | PostToolUse | ✓ | ✓ | ✅ | | Stop | ✓ | ✓ | ✅ — fires when agent loop ends (any reason) | -| SubagentStop | ✓ | 🔄 | M4+ wiring | +| SubagentStop | ✓ | ✓ | ✅ — fires when a Task sub-agent finishes | | PreCompact | ✓ | ✓ | ✅ — fires through compaction event bus | | PostCompact | ✓ | ✓ | ✅ | | SessionStart | ✓ | ✓ | ✅ | | SessionEnd | ✓ | ✓ | ✅ | | UserPromptSubmit | ✓ | ✓ | ✅ | -| Notification | ✓ | 🔄 | M8 | +| Notification | ✓ | ✓ | ✅ — REPL fires on turn-end (awaiting input) | ## Hook handler types diff --git a/packages/core/src/agent.test.ts b/packages/core/src/agent.test.ts index 068f78f..bca01a4 100644 --- a/packages/core/src/agent.test.ts +++ b/packages/core/src/agent.test.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'node:fs'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { runAgent } from './agent.js'; import { HookDispatcher } from './hooks/index.js'; import { SessionManager } from './sessions/index.js'; @@ -601,4 +601,115 @@ describe('runAgent', () => { }); expect(result.stopReason).toBe('end_turn'); }); + + it('fires UserPromptSubmit + Stop lifecycle hooks on a normal run', async () => { + const hooks = new HookDispatcher({ hooks: {} }); + const spy = vi.spyOn(hooks, 'dispatch'); + await runAgent({ + provider: new MockProvider([endTurn('done')]), + tools: new ToolRegistry(), + systemPrompt: '', + userMessage: 'hi', + model: 'deepseek-chat', + cwd, + hooks, + }); + const events = spy.mock.calls.map((c) => (c[0] as { event: string }).event); + expect(events).toContain('UserPromptSubmit'); + expect(events).toContain('Stop'); + }); + + it('UserPromptSubmit hook injects additionalContext into the prompt', async () => { + const hooks = new HookDispatcher({ + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'prompt', prompt: 'REMEMBER: be terse.' }] }], + }, + }); + const provider = new MockProvider([endTurn('ok')]); + await runAgent({ + provider, + tools: new ToolRegistry(), + systemPrompt: '', + userMessage: 'do the thing', + model: 'deepseek-chat', + cwd, + hooks, + systemReminders: false, + }); + const firstUser = provider.received[0]!.messages[0] as StoredMessage; + const text = firstUser.content[0]; + expect(text?.type).toBe('text'); + if (text?.type === 'text') { + expect(text.text).toContain('do the thing'); + expect(text.text).toContain('REMEMBER: be terse.'); + } + }); + + it('fires SubagentStop when a Task sub-agent finishes', async () => { + const hooks = new HookDispatcher({ hooks: {} }); + const spy = vi.spyOn(hooks, 'dispatch'); + await runAgent({ + provider: new MockProvider([ + toolUse('delegating', { + type: 'tool_use', + id: 't1', + name: 'Task', + input: { prompt: 'explore' }, + }), + endTurn('sub done'), // sub-agent run + endTurn('top done'), // back at top level + ]), + tools: new ToolRegistry(), + systemPrompt: '', + userMessage: 'delegate', + model: 'deepseek-chat', + cwd, + hooks, + }); + const events = spy.mock.calls.map((c) => (c[0] as { event: string }).event); + expect(events).toContain('SubagentStop'); + }); + + it('fires PreCompact + PostCompact around auto-compaction', async () => { + await fs.writeFile(join(cwd, 'x.txt'), 'data'); + const hooks = new HookDispatcher({ hooks: {} }); + const spy = vi.spyOn(hooks, 'dispatch'); + const scripted: ProviderResult[] = [ + { + content: withToolCall('working', { + type: 'tool_use', + id: 'big', + name: 'Read', + input: { file_path: 'x.txt' }, + }), + stopReason: 'tool_use', + usage: { inputTokens: 90, outputTokens: 0, reasoningTokens: 0, cacheReadTokens: 0 }, + }, + endTurn('done'), + ]; + const provider: Provider = { + name: 'counting', + async runTurn(o: ProviderRunOpts): Promise { + if (o.systemPrompt.startsWith('You compress long agent conversations')) { + return endTurn('summary'); + } + const next = scripted.shift(); + if (!next) throw new Error('no scripted response'); + return next; + }, + }; + await runAgent({ + provider, + tools: new ToolRegistry(), + systemPrompt: 'agent', + userMessage: 'go', + model: 'deepseek-chat', + cwd, + hooks, + autoCompact: { contextWindow: 100, threshold: 0.8, keepFirstPairs: 0, keepLastMessages: 1 }, + }); + const events = spy.mock.calls.map((c) => (c[0] as { event: string }).event); + expect(events).toContain('PreCompact'); + expect(events).toContain('PostCompact'); + }); }); diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 7e3f504..766af67 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -163,6 +163,23 @@ export async function runAgent(opts: RunAgentOptions): Promise { /* reminder failures must not abort the agent */ } } + // UserPromptSubmit hook — fires before the prompt is processed; any JSON + // `additionalContext` it returns is appended to the prompt. Top-level only + // (a sub-agent's prompt isn't a user prompt). + if (opts.hooks && (opts.subAgentDepth ?? 0) === 0) { + try { + const r = await opts.hooks.dispatch({ + event: 'UserPromptSubmit', + cwd: opts.cwd, + triggeredAt: new Date().toISOString(), + payload: { prompt: opts.userMessage }, + }); + const extra = r.json?.additionalContext; + if (typeof extra === 'string' && extra.trim()) userText = `${userText}\n\n${extra}`; + } catch { + /* hook failure must not abort the prompt */ + } + } const userMsg: StoredMessage = { role: 'user', content: [{ type: 'text', text: userText }], @@ -264,6 +281,19 @@ export async function runAgent(opts: RunAgentOptions): Promise { .map((b) => b.text) .join('\n') .trim(); + // SubagentStop hook — fires when a sub-agent finishes. + if (opts.hooks) { + try { + await opts.hooks.dispatch({ + event: 'SubagentStop', + cwd: opts.cwd, + triggeredAt: new Date().toISOString(), + payload: { agentType: agentType ?? 'general', turnsUsed: sub.turnsUsed }, + }); + } catch { + /* hook failure must not break the sub-agent result */ + } + } return { text, turnsUsed: sub.turnsUsed, agentType: agentType ?? 'general' }; }; } @@ -323,6 +353,22 @@ export async function runAgent(opts: RunAgentOptions): Promise { const totalUsage = { inputTokens: 0, outputTokens: 0, reasoningTokens: 0 }; let turnsUsed = 0; + // Stop hook — fires when the TOP-LEVEL agent finishes a run (a sub-agent's + // completion is signalled by SubagentStop instead). Observation only. + const fireStop = async (reason: string): Promise => { + if (!opts.hooks || depth !== 0) return; + try { + await opts.hooks.dispatch({ + event: 'Stop', + cwd: opts.cwd, + triggeredAt: new Date().toISOString(), + payload: { stopReason: reason, turnsUsed }, + }); + } catch { + /* hook failure must not affect the result */ + } + }; + for (let turn = 0; turn < maxTurns; turn++) { if (opts.signal?.aborted) { return { @@ -392,6 +438,7 @@ export async function runAgent(opts: RunAgentOptions): Promise { // If no tool calls, we're done if (result.stopReason !== 'tool_use') { + await fireStop('end_turn'); return { history, turnsUsed, usage: totalUsage, stopReason: 'end_turn', modeSignal }; } @@ -580,6 +627,14 @@ export async function runAgent(opts: RunAgentOptions): Promise { }) ) { try { + if (opts.hooks) { + await opts.hooks.dispatch({ + event: 'PreCompact', + cwd: opts.cwd, + triggeredAt: new Date().toISOString(), + payload: { messages: history.length, trigger: 'auto' }, + }); + } const compactResult = await compact(history, { provider: opts.provider, summarizerModel: opts.autoCompact.summarizerModel, @@ -595,12 +650,21 @@ export async function runAgent(opts: RunAgentOptions): Promise { outputTokens: compactResult.usage.outputTokens, reasoningTokens: 0, }); + if (opts.hooks) { + await opts.hooks.dispatch({ + event: 'PostCompact', + cwd: opts.cwd, + triggeredAt: new Date().toISOString(), + payload: { messages: history.length, trigger: 'auto' }, + }); + } } catch { // compaction failure is non-fatal — continue with full history } } } + await fireStop('max_turns'); return { history, turnsUsed, usage: totalUsage, stopReason: 'max_turns', modeSignal }; }