diff --git a/examples/hook-execution.ts b/examples/hook-execution.ts new file mode 100644 index 0000000..894a2a8 --- /dev/null +++ b/examples/hook-execution.ts @@ -0,0 +1,62 @@ +/** + * Hook execution streaming example. + * + * Demonstrates how to handle hook execution messages in a session stream. + * + * Usage: + * npx tsx examples/hook-execution.ts + */ + +import { DroidMessageType, createSession } from '../src/index.js'; + +async function main(): Promise { + const prompt = 'Run a simple shell command using Execute tool.'; + + console.log(`Sending prompt: "${prompt}"\n`); + + // Note: To actually see hooks, you need to have hooks configured in your droid settings. + const session = await createSession({ cwd: process.cwd() }); + + try { + for await (const msg of session.stream(prompt)) { + switch (msg.type) { + case DroidMessageType.Assistant: + process.stdout.write(msg.text); + break; + + case DroidMessageType.ToolCall: + console.log(`\n[Tool Call] ${msg.toolUse.name} (${msg.toolUse.id})`); + break; + + case DroidMessageType.Hook: + if (msg.status === 'started') { + console.log( + ` [Hook Started] ID: ${msg.hookId}, Event: ${msg.eventName}, Command: ${msg.command}` + ); + } else { + console.log( + ` [Hook ${msg.status}] ID: ${msg.hookId}, Exit Code: ${msg.exitCode}` + ); + if (msg.stdout) console.log(` stdout: ${msg.stdout.trim()}`); + if (msg.stderr) console.log(` stderr: ${msg.stderr.trim()}`); + } + break; + + case DroidMessageType.ToolResult: + console.log(`[Tool Result] ${msg.isError ? 'Error' : 'OK'}`); + break; + + case DroidMessageType.Result: + console.log('\n\n--- Turn complete ---'); + break; + } + } + } finally { + await session.close(); + } +} + +main().catch((err: unknown) => { + console.error('Error:', err); + process.exit(1); +}); diff --git a/src/client.ts b/src/client.ts index 74ef0db..42fdf05 100644 --- a/src/client.ts +++ b/src/client.ts @@ -19,6 +19,8 @@ import type { CancelMcpAuthResult, ClearMcpAuthRequestParams, ClearMcpAuthResult, + CloseSessionRequestParams, + CloseSessionResult, CompactSessionRequestParams, CompactSessionResult, GetContextStatsResult, @@ -61,6 +63,7 @@ import { AuthenticateMcpServerResultSchema, CancelMcpAuthResultSchema, ClearMcpAuthResultSchema, + CloseSessionResultSchema, CompactSessionResultSchema, GetContextStatsResultSchema, ExecuteRewindResultSchema, @@ -241,6 +244,16 @@ export class DroidClient { ); } + async closeSession( + params: CloseSessionRequestParams = {} + ): Promise { + return this._sessionRpc( + DroidServerMethod.CLOSE_SESSION, + params, + CloseSessionResultSchema + ); + } + async killWorkerSession( params: KillWorkerSessionRequestParams ): Promise { diff --git a/src/hooks.ts b/src/hooks.ts new file mode 100644 index 0000000..7ff76c0 --- /dev/null +++ b/src/hooks.ts @@ -0,0 +1,91 @@ +import type { DroidHookEvent } from './schemas/hooks.js'; + +export type { DroidHookEvent, DroidHookOutput } from './schemas/hooks.js'; + +export type DroidPermissionMode = + | 'off' + | 'spec' + | 'auto-low' + | 'auto-medium' + | 'auto-high'; + +export interface BaseDroidHookInput { + session_id: string; + transcript_path: string; + cwd: string; + permission_mode: DroidPermissionMode; + hook_event_name: DroidHookEvent; + message_id?: string; + [key: string]: unknown; +} + +export interface ToolHookInput extends BaseDroidHookInput { + hook_event_name: 'PreToolUse' | 'PostToolUse'; + tool_name: string; + tool_input: Record; + tool_response?: unknown; +} + +export interface UserPromptSubmitHookInput extends BaseDroidHookInput { + hook_event_name: 'UserPromptSubmit'; + prompt: string; + has_images?: boolean; +} + +export interface NotificationHookInput extends BaseDroidHookInput { + hook_event_name: 'Notification'; + message: string; + notification_type: + | 'permission_prompt' + | 'idle_prompt' + | 'auth_success' + | 'elicitation_dialog'; +} + +export interface StopHookInput extends BaseDroidHookInput { + hook_event_name: 'Stop'; + stop_hook_active: boolean; + tool_execution_count?: number; + elapsed_time?: number; +} + +export interface SubagentStopHookInput extends BaseDroidHookInput { + hook_event_name: 'SubagentStop'; + task_name: string; + task_result?: string; + task_error?: string; + stop_hook_active: boolean; +} + +export interface PreCompactHookInput extends BaseDroidHookInput { + hook_event_name: 'PreCompact'; + trigger: 'manual' | 'auto'; + custom_instructions?: string; + message_count: number; + estimated_tokens: number; +} + +export interface SessionStartHookInput extends BaseDroidHookInput { + hook_event_name: 'SessionStart'; + source: 'startup' | 'resume' | 'clear' | 'compact'; + previous_session_id?: string; + calling_session_id?: string; +} + +export interface SessionEndHookInput extends BaseDroidHookInput { + hook_event_name: 'SessionEnd'; + reason: 'clear' | 'logout' | 'prompt_input_exit' | 'other'; + session_duration_ms: number; + message_count: number; +} + +export type DroidHookInput = + | ToolHookInput + | UserPromptSubmitHookInput + | NotificationHookInput + | StopHookInput + | SubagentStopHookInput + | PreCompactHookInput + | SessionStartHookInput + | SessionEndHookInput + | BaseDroidHookInput; diff --git a/src/index.ts b/src/index.ts index efe11ee..acf2ae4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,22 @@ export type { DroidClientOptions, } from './client.js'; +export type { + BaseDroidHookInput, + DroidHookEvent, + DroidHookInput, + DroidHookOutput, + DroidPermissionMode, + NotificationHookInput, + PreCompactHookInput, + SessionEndHookInput, + SessionStartHookInput, + StopHookInput, + SubagentStopHookInput, + ToolHookInput, + UserPromptSubmitHookInput, +} from './hooks.js'; + export { convertNotificationToStreamMessage, DroidMessageType, @@ -59,6 +75,7 @@ export type { MissionWorkerCompleted, McpAuthRequired, McpAuthCompleted, + HookExecution, StructuredOutput, StructuredOutputFields, ErrorEvent, diff --git a/src/schemas/client.ts b/src/schemas/client.ts index 5e8d3f2..00c58c6 100644 --- a/src/schemas/client.ts +++ b/src/schemas/client.ts @@ -8,8 +8,8 @@ import { DroidInteractionMode, DroidServerMethod, McpServerType, - MissionState, ModelProvider, + MissionState, ReasoningEffort, SettingsLevel, SkillLocation, @@ -311,6 +311,19 @@ export type InterruptSessionRequestParams = z.infer< typeof InterruptSessionRequestParamsSchema >; +/** Parameters for droid.close_session request. */ +export const CloseSessionRequestParamsSchema = z + .object({ + reason: z + .enum(['clear', 'logout', 'prompt_input_exit', 'other']) + .optional(), + }) + .strict(); + +export type CloseSessionRequestParams = z.infer< + typeof CloseSessionRequestParamsSchema +>; + /** Parameters for droid.kill_worker_session request. */ export const KillWorkerSessionRequestParamsSchema = z .object({ @@ -634,6 +647,13 @@ export type InterruptSessionRequest = z.infer< typeof InterruptSessionRequestSchema >; +export const CloseSessionRequestSchema = JsonRpcRequestSchema.extend({ + method: z.literal(DroidServerMethod.CLOSE_SESSION), + params: CloseSessionRequestParamsSchema, +}); + +export type CloseSessionRequest = z.infer; + export const KillWorkerSessionRequestSchema = JsonRpcRequestSchema.extend({ method: z.literal(DroidServerMethod.KILL_WORKER_SESSION), params: KillWorkerSessionRequestParamsSchema, @@ -811,6 +831,7 @@ export const ClientRequestSchema = z.discriminatedUnion('method', [ InitializeSessionRequestSchema, LoadSessionRequestSchema, AddUserMessageRequestSchema, + CloseSessionRequestSchema, InterruptSessionRequestSchema, KillWorkerSessionRequestSchema, UpdateSessionSettingsRequestSchema, @@ -904,6 +925,11 @@ export type InterruptSessionResult = z.infer< typeof InterruptSessionResultSchema >; +/** Result for droid.close_session response (empty). */ +export const CloseSessionResultSchema = EmptyResultSchema; + +export type CloseSessionResult = z.infer; + /** Result for droid.kill_worker_session response (empty). */ export const KillWorkerSessionResultSchema = EmptyResultSchema; @@ -1114,6 +1140,15 @@ export type InterruptSessionResponse = z.infer< typeof InterruptSessionResponseSchema >; +export const CloseSessionResponseSchema = z.union([ + JsonRpcResponseSuccessSchema.extend({ + result: CloseSessionResultSchema, + }), + JsonRpcResponseFailureSchema, +]); + +export type CloseSessionResponse = z.infer; + export const KillWorkerSessionResponseSchema = z.union([ JsonRpcResponseSuccessSchema.extend({ result: KillWorkerSessionResultSchema, diff --git a/src/schemas/enums.ts b/src/schemas/enums.ts index ca595a0..c2dd815 100644 --- a/src/schemas/enums.ts +++ b/src/schemas/enums.ts @@ -12,6 +12,7 @@ export enum DroidServerMethod { INITIALIZE_SESSION = 'droid.initialize_session', LOAD_SESSION = 'droid.load_session', ADD_USER_MESSAGE = 'droid.add_user_message', + CLOSE_SESSION = 'droid.close_session', INTERRUPT_SESSION = 'droid.interrupt_session', KILL_WORKER_SESSION = 'droid.kill_worker_session', UPDATE_SESSION_SETTINGS = 'droid.update_session_settings', @@ -69,6 +70,8 @@ export enum SessionNotificationType { MISSION_WORKER_COMPLETED = 'mission_worker_completed', MCP_AUTH_REQUIRED = 'mcp_auth_required', MCP_AUTH_COMPLETED = 'mcp_auth_completed', + HOOK_EXECUTION_STARTED = 'hook_execution_started', + HOOK_EXECUTION_COMPLETED = 'hook_execution_completed', STRUCTURED_OUTPUT = 'structured_output', } diff --git a/src/schemas/hooks.ts b/src/schemas/hooks.ts new file mode 100644 index 0000000..00c808b --- /dev/null +++ b/src/schemas/hooks.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; + +export const DroidHookEventSchema = z.enum([ + 'PreToolUse', + 'PostToolUse', + 'Notification', + 'UserPromptSubmit', + 'Stop', + 'SubagentStop', + 'PreCompact', + 'SessionStart', + 'SessionEnd', +]); + +export type DroidHookEvent = z.infer; + +export const DroidHookSpecificOutputSchema = z + .object({ + hookEventName: DroidHookEventSchema.optional(), + permissionDecision: z.enum(['allow', 'deny', 'ask']).optional(), + permissionDecisionReason: z.string().optional(), + additionalContext: z.string().optional(), + updatedInput: z.record(z.unknown()).optional(), + }) + .passthrough(); + +export const DroidHookOutputSchema = z + .object({ + continue: z.boolean().optional(), + stopReason: z.string().optional(), + suppressOutput: z.boolean().optional(), + systemMessage: z.string().optional(), + decision: z.enum(['block', 'approve']).optional(), + reason: z.string().optional(), + hookSpecificOutput: DroidHookSpecificOutputSchema.optional(), + }) + .passthrough(); + +export type DroidHookOutput = z.infer; + +export const DroidHookExecutionResultSchema = DroidHookOutputSchema.extend({ + exitCode: z.number(), + stdout: z.string(), + stderr: z.string(), +}); + +export type DroidHookExecutionResult = z.infer< + typeof DroidHookExecutionResultSchema +>; diff --git a/src/schemas/index.ts b/src/schemas/index.ts index e06cbdd..5282b99 100644 --- a/src/schemas/index.ts +++ b/src/schemas/index.ts @@ -99,6 +99,18 @@ export type { SessionStartEvent, } from './session-metadata.js'; +export { + DroidHookEventSchema, + DroidHookExecutionResultSchema, + DroidHookOutputSchema, + DroidHookSpecificOutputSchema, +} from './hooks.js'; +export type { + DroidHookEvent, + DroidHookExecutionResult, + DroidHookOutput, +} from './hooks.js'; + export { Base64ImageSourceSchema, ContentBlockSchema, @@ -230,6 +242,10 @@ export { ClearMcpAuthResponseSchema, ClearMcpAuthResultSchema, ClientRequestSchema, + CloseSessionRequestParamsSchema, + CloseSessionRequestSchema, + CloseSessionResponseSchema, + CloseSessionResultSchema, ContextStatsSchema, CompactSessionRequestParamsSchema, CompactSessionRequestSchema, @@ -360,6 +376,10 @@ export type { ClearMcpAuthResponse, ClearMcpAuthResult, ClientRequest, + CloseSessionRequest, + CloseSessionRequestParams, + CloseSessionResponse, + CloseSessionResult, ContextStats, CompactSessionRequest, CompactSessionRequestParams, @@ -485,6 +505,10 @@ export { ErrorNotificationSchema, ExecuteToolConfirmationDetailsSchema, ExitSpecModeConfirmationDetailsSchema, + HookCommandSchema, + HookExecutionCompletedNotificationSchema, + HookExecutionStartedNotificationSchema, + HookResultSchema, McpAuthCompletedNotificationSchema, McpAuthRequiredNotificationSchema, McpStatusChangedNotificationSchema, @@ -544,6 +568,10 @@ export type { ErrorNotification, ExecuteToolConfirmationDetails, ExitSpecModeConfirmationDetails, + HookCommand, + HookExecutionCompletedNotification, + HookExecutionStartedNotification, + HookResult, McpAuthCompletedNotification, McpAuthRequiredNotification, McpStatusChangedNotification, diff --git a/src/schemas/server.ts b/src/schemas/server.ts index 7cfeb3b..e4afabe 100644 --- a/src/schemas/server.ts +++ b/src/schemas/server.ts @@ -15,6 +15,7 @@ import { ToolConfirmationOutcome, ToolConfirmationType, } from './enums.js'; +import { DroidHookEventSchema } from './hooks.js'; import { McpServerStatusInfoSchema, McpStatusSummarySchema, @@ -398,6 +399,64 @@ export type McpAuthCompletedNotification = z.infer< typeof McpAuthCompletedNotificationSchema >; +/** Hook command metadata included in hook execution notifications. */ +export const HookCommandSchema = z + .object({ + command: z.string(), + timeout: z.number().optional(), + }) + .passthrough(); + +export type HookCommand = z.infer; + +/** Hook execution result included in hook completion notifications. */ +export const HookResultSchema = z + .object({ + exitCode: z.number(), + stdout: z.string(), + stderr: z.string(), + command: z.string().optional(), + timeout: z.number().optional(), + }) + .passthrough(); + +export type HookResult = z.infer; + +/** Hook execution started notification. */ +export const HookExecutionStartedNotificationSchema = z + .object({ + type: z.literal(SessionNotificationType.HOOK_EXECUTION_STARTED), + hookId: z.string(), + hookEventName: DroidHookEventSchema, + hookMatcher: z.string().optional(), + hookCommands: z.array(HookCommandSchema), + hookToolCallId: z.string().optional(), + isParallelExecution: z.boolean().optional(), + parallelGroupId: z.string().optional(), + }) + .passthrough(); + +export type HookExecutionStartedNotification = z.infer< + typeof HookExecutionStartedNotificationSchema +>; + +/** Hook execution completed notification. */ +export const HookExecutionCompletedNotificationSchema = z + .object({ + type: z.literal(SessionNotificationType.HOOK_EXECUTION_COMPLETED), + hookId: z.string(), + hookEventName: DroidHookEventSchema.optional(), + hookMatcher: z.string().optional(), + hookToolCallId: z.string().optional(), + hookStatus: z.enum(['completed', 'error']), + hookResults: z.array(HookResultSchema).optional(), + }) + .passthrough(); + +export type HookExecutionCompletedNotification = z.infer< + typeof HookExecutionCompletedNotificationSchema +>; + /** Structured output validation error emitted by Droid. */ export const StructuredOutputErrorSchema = z .object({ @@ -448,6 +507,8 @@ export const SessionNotificationSchemaList = [ MissionWorkerCompletedNotificationSchema, McpAuthRequiredNotificationSchema, McpAuthCompletedNotificationSchema, + HookExecutionStartedNotificationSchema, + HookExecutionCompletedNotificationSchema, StructuredOutputNotificationSchema, ] as const; @@ -780,7 +841,7 @@ export const AskUserResponseSchema = z.union([ export type AskUserResponse = z.infer; -/** Union over all 3 server → client methods. */ +/** Union over all server → client methods. */ const _CliRequestOrNotificationSchema = z.union([ SessionNotificationSchema, RequestPermissionRequestSchema, diff --git a/src/session.ts b/src/session.ts index 3c0f481..0614055 100644 --- a/src/session.ts +++ b/src/session.ts @@ -243,6 +243,7 @@ export class DroidSession { this._cleanupAbortSignal = null; try { + await this._client.closeSession({ reason: 'other' }).catch(() => {}); await this._client.close(); } finally { const cleanups = this._cleanupCallbacks.splice(0); diff --git a/src/stream.ts b/src/stream.ts index eecd3a4..d1af7d7 100644 --- a/src/stream.ts +++ b/src/stream.ts @@ -6,6 +6,7 @@ import { SessionNotificationType, ToolConfirmationOutcome, } from './schemas/enums.js'; +import type { DroidHookEvent } from './schemas/hooks.js'; import type { McpServerStatusInfo, McpStatusSummary } from './schemas/mcp.js'; import { FactoryDroidMessageRole, @@ -53,6 +54,7 @@ export const DroidMessageType = { MissionWorkerCompleted: 'mission_worker_completed', McpAuthRequired: 'mcp_auth_required', McpAuthCompleted: 'mcp_auth_completed', + Hook: 'hook', Error: 'error', Result: 'result', } as const; @@ -216,6 +218,20 @@ export interface McpAuthCompleted { readonly message: string; } +export interface HookExecution { + readonly type: 'hook'; + readonly hookId: string; + readonly eventName?: DroidHookEvent; + readonly matcher?: string; + readonly toolCallId?: string; + readonly command?: string; + readonly timeout?: number; + readonly status: 'started' | 'completed' | 'error'; + readonly exitCode?: number; + readonly stdout?: string; + readonly stderr?: string; +} + export interface StructuredOutputFields { readonly structuredOutput: JsonObject | null; readonly structuredOutputError: ServerStructuredOutputError | null; @@ -282,6 +298,7 @@ export type DroidStreamMessage = | DroidUserMessage | DroidToolCallMessage | ToolResult + | HookExecution | ErrorEvent | DroidResultMessage; @@ -306,7 +323,8 @@ export type DroidStreamEvent = | MissionWorkerStarted | MissionWorkerCompleted | McpAuthRequired - | McpAuthCompleted; + | McpAuthCompleted + | HookExecution; export type InternalDroidMessage = | DroidStreamEvent @@ -532,6 +550,33 @@ export function convertNotificationToStreamMessage( message: notification.message, }; + case SessionNotificationType.HOOK_EXECUTION_STARTED: + return notification.hookCommands.map((hookCommand) => ({ + type: DroidMessageType.Hook, + hookId: notification.hookId, + eventName: notification.hookEventName, + matcher: notification.hookMatcher, + toolCallId: notification.hookToolCallId, + command: hookCommand.command, + timeout: hookCommand.timeout, + status: 'started', + })); + + case SessionNotificationType.HOOK_EXECUTION_COMPLETED: + return (notification.hookResults ?? []).map((hookResult) => ({ + type: DroidMessageType.Hook, + hookId: notification.hookId, + eventName: notification.hookEventName, + matcher: notification.hookMatcher, + toolCallId: notification.hookToolCallId, + command: hookResult.command, + timeout: hookResult.timeout, + status: notification.hookStatus, + exitCode: hookResult.exitCode, + stdout: hookResult.stdout, + stderr: hookResult.stderr, + })); + case SessionNotificationType.STRUCTURED_OUTPUT: return { type: 'structured_output', @@ -755,6 +800,7 @@ export function isDefaultStreamMessage( message.type === DroidMessageType.User || message.type === DroidMessageType.ToolCall || message.type === DroidMessageType.ToolResult || + message.type === DroidMessageType.Hook || message.type === DroidMessageType.Error || message.type === DroidMessageType.Result ); diff --git a/tests/hooks.test.ts b/tests/hooks.test.ts new file mode 100644 index 0000000..d82fcae --- /dev/null +++ b/tests/hooks.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import type { ToolHookInput } from '../src/hooks.js'; +import { DroidHookOutputSchema } from '../src/schemas/hooks.js'; + +describe('hook types and schemas', () => { + it('accepts minimal hookSpecificOutput without hookEventName', () => { + const result = DroidHookOutputSchema.parse({ + hookSpecificOutput: { + permissionDecision: 'deny', + }, + }); + + expect(result.hookSpecificOutput).toEqual({ + permissionDecision: 'deny', + }); + }); + + it('preserves hook output fields used by file hooks', () => { + const result = DroidHookOutputSchema.parse({ + continue: false, + stopReason: 'blocked', + systemMessage: 'context', + decision: 'block', + reason: 'policy', + hookSpecificOutput: { + permissionDecision: 'deny', + permissionDecisionReason: 'unsafe', + additionalContext: 'extra', + updatedInput: { command: 'npm test' }, + }, + }); + + expect(result.hookSpecificOutput?.updatedInput).toEqual({ + command: 'npm test', + }); + }); + + it('exports hook input types for command hook authors', () => { + const input: ToolHookInput = { + hook_event_name: 'PreToolUse', + session_id: 's1', + transcript_path: '', + cwd: '.', + permission_mode: 'off', + tool_name: 'Execute', + tool_input: { command: 'npm test' }, + }; + + expect(input.tool_name).toBe('Execute'); + }); +}); diff --git a/tests/run.test.ts b/tests/run.test.ts index f2a236c..06e1198 100644 --- a/tests/run.test.ts +++ b/tests/run.test.ts @@ -45,6 +45,10 @@ function setupRunResponder( tokenUsageSessionId: sessionId, }); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); } @@ -136,6 +140,10 @@ describe('run()', () => { queueMicrotask(() => { transport.injectMessage(makeErrorResponse(id, -32603, 'send failed')); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); @@ -183,6 +191,10 @@ describe('run()', () => { ) ); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); @@ -246,6 +258,10 @@ describe('run()', () => { ) ); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); @@ -336,6 +352,10 @@ describe('run()', () => { ) ); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); @@ -400,6 +420,10 @@ describe('run()', () => { ) ); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); diff --git a/tests/schemas.test.ts b/tests/schemas.test.ts index dd4e3e7..dc71353 100644 --- a/tests/schemas.test.ts +++ b/tests/schemas.test.ts @@ -134,12 +134,13 @@ import { } from '../src/schemas/index.js'; describe('enums', () => { - it('DroidServerMethod has all 26 methods', () => { + it('DroidServerMethod has all 27 methods', () => { const values = Object.values(DroidServerMethod); - expect(values).toHaveLength(26); + expect(values).toHaveLength(27); expect(values).toContain('droid.initialize_session'); expect(values).toContain('droid.load_session'); expect(values).toContain('droid.add_user_message'); + expect(values).toContain('droid.close_session'); expect(values).toContain('droid.interrupt_session'); expect(values).toContain('droid.kill_worker_session'); expect(values).toContain('droid.update_session_settings'); @@ -175,7 +176,7 @@ describe('enums', () => { expect(DroidClientMethod.ASK_USER).toBe('droid.ask_user'); }); - it('SessionNotificationType has 24 types', () => { + it('SessionNotificationType has 26 types', () => { const values = Object.values(SessionNotificationType); const expectedValues = [ 'assistant_text_delta', @@ -201,6 +202,8 @@ describe('enums', () => { 'mission_worker_completed', 'mcp_auth_required', 'mcp_auth_completed', + 'hook_execution_started', + 'hook_execution_completed', 'structured_output', ]; expect(values).toHaveLength(expectedValues.length); diff --git a/tests/session.test.ts b/tests/session.test.ts index 9dcdbb4..64cbca4 100644 --- a/tests/session.test.ts +++ b/tests/session.test.ts @@ -54,6 +54,10 @@ function setupInitResponder( }) ); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); } @@ -86,6 +90,10 @@ function setupLoadResponder( }) ); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); } @@ -136,6 +144,10 @@ function setupFullResponder( queueMicrotask(() => { transport.injectMessage(makeSuccessResponse(id, {})); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } else if (method === DroidServerMethod.UPDATE_SESSION_SETTINGS) { queueMicrotask(() => { transport.injectMessage(makeSuccessResponse(id, {})); @@ -377,6 +389,10 @@ describe('DroidSession', () => { transport.injectMessage(makeSuccessResponse(id, {})); sendDefaultStreamSequence(transport); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); @@ -449,6 +465,10 @@ describe('DroidSession', () => { structuredOutput: { name: 'Ada' }, }); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); @@ -504,6 +524,10 @@ describe('DroidSession', () => { }, }); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }); @@ -592,6 +616,32 @@ describe('DroidSession', () => { await session.close(); await session.close(); }); + + it('requests graceful close so file hooks can receive SessionEnd', async () => { + const transport = new InMemoryTransport(); + await transport.connect(); + + setupFullResponder(transport, 'sess-close-session-end', { + [DroidServerMethod.CLOSE_SESSION]: (id) => { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); + }, + }); + + const session = await createSession({ + transport, + }); + + await session.close(); + + const closeMessage = transport.sentMessages.find( + (message) => message['method'] === DroidServerMethod.CLOSE_SESSION + ); + expect(closeMessage).toBeDefined(); + expect(closeMessage?.['params']).toEqual({ reason: 'other' }); + expect(transport.isConnected).toBe(false); + }); }); describe('MCP methods (VAL-API-011)', () => { @@ -1148,6 +1198,10 @@ describe('DroidSession', () => { ) ); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }; @@ -1316,6 +1370,10 @@ describe('DroidSession', () => { ) ); }); + } else if (method === DroidServerMethod.CLOSE_SESSION) { + queueMicrotask(() => { + transport.injectMessage(makeSuccessResponse(id, {})); + }); } }; diff --git a/tests/stream.test.ts b/tests/stream.test.ts index 3712adb..bd4c5ee 100644 --- a/tests/stream.test.ts +++ b/tests/stream.test.ts @@ -37,6 +37,7 @@ import type { MissionWorkerCompleted, McpAuthRequired, McpAuthCompleted, + HookExecution, ErrorEvent, DroidMessage, DroidResultMessage, @@ -72,6 +73,7 @@ const expectedDroidMessageTypes = [ 'mission_worker_completed', 'mcp_auth_required', 'mcp_auth_completed', + 'hook', 'error', 'result', ] as const satisfies readonly DroidMessage['type'][]; @@ -329,6 +331,20 @@ describe('DroidMessage types', () => { expect(msg.outcome).toBe(McpAuthOutcome.Success); }); + it('HookExecution has correct structure', () => { + const msg: HookExecution = { + type: 'hook', + hookId: 'hook-1', + eventName: 'PreToolUse', + matcher: 'Execute', + toolCallId: 'tool-1', + command: 'echo hook', + status: 'started', + }; + expect(msg.type).toBe('hook'); + expect(msg.command).toBe('echo hook'); + }); + it('ErrorEvent has correct structure', () => { const msg: ErrorEvent = { type: 'error', @@ -370,7 +386,7 @@ describe('DroidMessage types', () => { expect(msg.tokenUsage!.inputTokens).toBe(100); }); - it('DroidMessage union type allows all 23 types', () => { + it('DroidMessage union type allows all message types', () => { const messages: DroidMessage[] = [ { type: 'assistant', @@ -488,6 +504,19 @@ describe('DroidMessage types', () => { outcome: McpAuthOutcome.Success, message: 'm', }, + { + type: 'hook', + hookId: 'hook-1', + eventName: 'PreToolUse', + matcher: 'Execute', + toolCallId: 'tool-1', + command: 'echo hook', + timeout: 5, + status: 'completed', + exitCode: 0, + stdout: 'ok', + stderr: '', + }, { type: 'error', message: 'err', @@ -1110,6 +1139,91 @@ describe('convertNotificationToStreamMessage', () => { }); }); + describe('hook execution', () => { + it('converts each started hook command to a HookExecution message', () => { + const notification = makeNotification( + SessionNotificationType.HOOK_EXECUTION_STARTED, + { + hookId: 'hook-1', + hookEventName: 'PreToolUse', + hookMatcher: 'Execute', + hookToolCallId: 'tool-1', + hookCommands: [ + { command: 'echo one', timeout: 5 }, + { command: 'echo two' }, + ], + } + ); + const result = convertNotificationToStreamMessage( + notification + ) as HookExecution[]; + + expect(result).toEqual([ + { + type: 'hook', + hookId: 'hook-1', + eventName: 'PreToolUse', + matcher: 'Execute', + toolCallId: 'tool-1', + command: 'echo one', + timeout: 5, + status: 'started', + }, + { + type: 'hook', + hookId: 'hook-1', + eventName: 'PreToolUse', + matcher: 'Execute', + toolCallId: 'tool-1', + command: 'echo two', + timeout: undefined, + status: 'started', + }, + ]); + }); + + it('converts each completed hook result to a HookExecution message', () => { + const notification = makeNotification( + SessionNotificationType.HOOK_EXECUTION_COMPLETED, + { + hookId: 'hook-1', + hookEventName: 'PreToolUse', + hookMatcher: 'Execute', + hookToolCallId: 'tool-1', + hookStatus: 'completed', + hookResults: [ + { + command: 'echo one', + timeout: 5, + exitCode: 0, + stdout: 'one\n', + stderr: '', + }, + ], + } + ); + const result = convertNotificationToStreamMessage( + notification + ) as HookExecution[]; + + expect(result).toEqual([ + { + type: 'hook', + hookId: 'hook-1', + eventName: 'PreToolUse', + matcher: 'Execute', + toolCallId: 'tool-1', + command: 'echo one', + timeout: 5, + status: 'completed', + exitCode: 0, + stdout: 'one\n', + stderr: '', + }, + ]); + }); + }); + describe('structured_output', () => { it('converts successful structured output', () => { const notification = makeNotification( @@ -1289,6 +1403,19 @@ describe('convertNotificationToStreamMessage', () => { outcome: McpAuthOutcome.Success, message: 'm', }, + [SessionNotificationType.HOOK_EXECUTION_STARTED]: { + hookId: 'hook-1', + hookEventName: 'PreToolUse', + hookCommands: [{ command: 'echo hook' }], + }, + [SessionNotificationType.HOOK_EXECUTION_COMPLETED]: { + hookId: 'hook-1', + hookEventName: 'PreToolUse', + hookStatus: 'completed', + hookResults: [ + { command: 'echo hook', exitCode: 0, stdout: '', stderr: '' }, + ], + }, [SessionNotificationType.STRUCTURED_OUTPUT]: { messageId: 'm', structuredOutput: { name: 'Ada' },