mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-28 03:30:40 +00:00
feat: add stopFailure and postCompact (#2825)
This commit is contained in:
parent
732cee2604
commit
dddb56d885
14 changed files with 1484 additions and 9 deletions
|
|
@ -57,9 +57,11 @@ The following table lists all available hook events in Qwen Code:
|
|||
| `UserPromptSubmit` | Fired when user submits a prompt | Input processing, validation, context injection |
|
||||
| `SessionStart` | Fired when a new session starts | Initialization, context setup |
|
||||
| `Stop` | Fired before Qwen concludes its response | Finalization, cleanup |
|
||||
| `StopFailure` | Fired when turn ends due to API error | Error logging, alerting, rate limit handling |
|
||||
| `SubagentStart` | Fired when a subagent starts | Subagent initialization |
|
||||
| `SubagentStop` | Fired when a subagent stops | Subagent finalization |
|
||||
| `PreCompact` | Fired before conversation compaction | Pre-compaction processing |
|
||||
| `PostCompact` | Fired after conversation compaction | Summary archiving, usage statistics |
|
||||
| `SessionEnd` | Fired when a session ends | Cleanup, reporting |
|
||||
| `PermissionRequest` | Fired when permission dialogs are displayed | Permission automation, policy enforcement |
|
||||
|
||||
|
|
@ -299,6 +301,60 @@ Event-specific fields are added based on the hook type. Below are the event-spec
|
|||
}
|
||||
```
|
||||
|
||||
#### StopFailure
|
||||
|
||||
**Purpose**: Executed when the turn ends due to an API error (instead of Stop). This is a **fire-and-forget** event - hook output and exit codes are ignored.
|
||||
|
||||
**Event-specific fields**:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "rate_limit | authentication_failed | billing_error | invalid_request | server_error | max_output_tokens | unknown",
|
||||
"error_details": "detailed error message (optional)",
|
||||
"last_assistant_message": "the last message from the assistant before the error (optional)"
|
||||
}
|
||||
```
|
||||
|
||||
**Matcher**: Matches against the `error` field. For example, `"matcher": "rate_limit"` will only trigger for rate limit errors.
|
||||
|
||||
**Output Options**:
|
||||
|
||||
- **None** - StopFailure is fire-and-forget. All hook output and exit codes are ignored.
|
||||
|
||||
**Exit Code Handling**:
|
||||
|
||||
| Exit Code | Behavior |
|
||||
| --------- | ------------------------- |
|
||||
| Any | Ignored (fire-and-forget) |
|
||||
|
||||
**Example Configuration**:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"StopFailure": [
|
||||
{
|
||||
"matcher": "rate_limit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/path/to/rate-limit-alert.sh",
|
||||
"name": "rate-limit-alerter"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Use Cases**:
|
||||
|
||||
- Rate limit monitoring and alerting
|
||||
- Authentication failure logging
|
||||
- Billing error notifications
|
||||
- Error statistics collection
|
||||
|
||||
#### SubagentStart
|
||||
|
||||
**Purpose**: Executed when a subagent (like the Task tool) is started to set up context or permissions.
|
||||
|
|
@ -387,6 +443,63 @@ Event-specific fields are added based on the hook type. Below are the event-spec
|
|||
}
|
||||
```
|
||||
|
||||
#### PostCompact
|
||||
|
||||
**Purpose**: Executed after conversation compaction completes to archive summaries or track usage.
|
||||
|
||||
**Event-specific fields**:
|
||||
|
||||
```json
|
||||
{
|
||||
"trigger": "manual | auto",
|
||||
"compact_summary": "the summary generated by the compaction process"
|
||||
}
|
||||
```
|
||||
|
||||
**Matcher**: Matches against the `trigger` field. For example, `"matcher": "manual"` will only trigger for manual compaction via `/compact` command.
|
||||
|
||||
**Output Options**:
|
||||
|
||||
- `hookSpecificOutput.additionalContext`: additional context (for logging only)
|
||||
- Standard hook output fields (for logging only)
|
||||
|
||||
**Note**: PostCompact is **not** in the official decision mode supported events list. The `decision` field and other control fields do not produce any control effects - they are only used for logging purposes.
|
||||
|
||||
**Exit Code Handling**:
|
||||
|
||||
| Exit Code | Behavior |
|
||||
| --------- | --------------------------------------------------------- |
|
||||
| 0 | Success - stdout shown to user in verbose mode |
|
||||
| Other | Non-blocking error - stderr shown to user in verbose mode |
|
||||
|
||||
**Example Configuration**:
|
||||
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"PostCompact": [
|
||||
{
|
||||
"matcher": "manual",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/path/to/save-compact-summary.sh",
|
||||
"name": "save-summary"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Use Cases**:
|
||||
|
||||
- Summary archiving to files or databases
|
||||
- Usage statistics tracking
|
||||
- Context change monitoring
|
||||
- Audit logging for compaction operations
|
||||
|
||||
#### Notification
|
||||
|
||||
**Purpose**: Executed when notifications are sent to customize or intercept them.
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ describe('hooks constants', () => {
|
|||
describe('DISPLAY_HOOK_EVENTS', () => {
|
||||
it('should contain all expected hook events', () => {
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.Stop);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.StopFailure);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PreToolUse);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PostToolUse);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PostToolUseFailure);
|
||||
|
|
@ -173,11 +174,12 @@ describe('hooks constants', () => {
|
|||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.SubagentStart);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.SubagentStop);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PreCompact);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PostCompact);
|
||||
expect(DISPLAY_HOOK_EVENTS).toContain(HookEventName.PermissionRequest);
|
||||
});
|
||||
|
||||
it('should have 12 events', () => {
|
||||
expect(DISPLAY_HOOK_EVENTS).toHaveLength(12);
|
||||
it('should have 14 events', () => {
|
||||
expect(DISPLAY_HOOK_EVENTS).toHaveLength(14);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
import type { Mock, MockInstance } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, act, waitFor } from '@testing-library/react';
|
||||
import { useGeminiStream } from './useGeminiStream.js';
|
||||
import { useGeminiStream, classifyApiError } from './useGeminiStream.js';
|
||||
import * as atCommandProcessor from './atCommandProcessor.js';
|
||||
import type {
|
||||
TrackedToolCall,
|
||||
|
|
@ -3512,3 +3512,115 @@ describe('useGeminiStream', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyApiError', () => {
|
||||
it('should classify rate limit errors by status code 429', () => {
|
||||
expect(classifyApiError({ message: 'error', status: 429 })).toBe(
|
||||
'rate_limit',
|
||||
);
|
||||
});
|
||||
|
||||
it('should classify rate limit errors by message', () => {
|
||||
expect(classifyApiError({ message: 'Rate limit exceeded' })).toBe(
|
||||
'rate_limit',
|
||||
);
|
||||
});
|
||||
|
||||
it('should classify authentication errors by status code 401', () => {
|
||||
expect(classifyApiError({ message: 'error', status: 401 })).toBe(
|
||||
'authentication_failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('should classify authentication errors by message', () => {
|
||||
expect(classifyApiError({ message: 'Unauthorized access' })).toBe(
|
||||
'authentication_failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('should classify billing errors by status code 402', () => {
|
||||
expect(classifyApiError({ message: 'error', status: 402 })).toBe(
|
||||
'billing_error',
|
||||
);
|
||||
});
|
||||
|
||||
it('should classify billing errors by status code 403', () => {
|
||||
expect(classifyApiError({ message: 'error', status: 403 })).toBe(
|
||||
'billing_error',
|
||||
);
|
||||
});
|
||||
|
||||
it('should classify billing errors by message containing billing', () => {
|
||||
expect(classifyApiError({ message: 'Billing issue detected' })).toBe(
|
||||
'billing_error',
|
||||
);
|
||||
});
|
||||
|
||||
it('should classify billing errors by message containing quota', () => {
|
||||
expect(classifyApiError({ message: 'Quota exceeded' })).toBe(
|
||||
'billing_error',
|
||||
);
|
||||
});
|
||||
|
||||
it('should classify invalid request errors by status code 400', () => {
|
||||
expect(classifyApiError({ message: 'error', status: 400 })).toBe(
|
||||
'invalid_request',
|
||||
);
|
||||
});
|
||||
|
||||
it('should classify invalid request errors by message', () => {
|
||||
expect(classifyApiError({ message: 'Invalid request format' })).toBe(
|
||||
'invalid_request',
|
||||
);
|
||||
});
|
||||
|
||||
it('should classify server errors by status code 500', () => {
|
||||
expect(classifyApiError({ message: 'error', status: 500 })).toBe(
|
||||
'server_error',
|
||||
);
|
||||
});
|
||||
|
||||
it('should classify server errors by status code 502', () => {
|
||||
expect(classifyApiError({ message: 'error', status: 502 })).toBe(
|
||||
'server_error',
|
||||
);
|
||||
});
|
||||
|
||||
it('should classify server errors by status code 503', () => {
|
||||
expect(classifyApiError({ message: 'error', status: 503 })).toBe(
|
||||
'server_error',
|
||||
);
|
||||
});
|
||||
|
||||
it('should classify max output tokens errors by message', () => {
|
||||
expect(classifyApiError({ message: 'max_tokens limit reached' })).toBe(
|
||||
'max_output_tokens',
|
||||
);
|
||||
});
|
||||
|
||||
it('should classify token limit errors by message', () => {
|
||||
expect(classifyApiError({ message: 'Token limit exceeded' })).toBe(
|
||||
'max_output_tokens',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return unknown for unrecognized errors', () => {
|
||||
expect(classifyApiError({ message: 'Some random error' })).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should return unknown for empty message', () => {
|
||||
expect(classifyApiError({ message: '' })).toBe('unknown');
|
||||
});
|
||||
|
||||
it('should handle case insensitive matching', () => {
|
||||
expect(classifyApiError({ message: 'RATE LIMIT exceeded' })).toBe(
|
||||
'rate_limit',
|
||||
);
|
||||
expect(classifyApiError({ message: 'UNAUTHORIZED' })).toBe(
|
||||
'authentication_failed',
|
||||
);
|
||||
expect(classifyApiError({ message: 'BILLING error' })).toBe(
|
||||
'billing_error',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import type {
|
|||
ThoughtSummary,
|
||||
ToolCallRequestInfo,
|
||||
GeminiErrorEventValue,
|
||||
StopFailureErrorType,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
GeminiEventType as ServerGeminiEventType,
|
||||
|
|
@ -78,6 +79,43 @@ import { t } from '../../i18n/index.js';
|
|||
|
||||
const debugLogger = createDebugLogger('GEMINI_STREAM');
|
||||
|
||||
/**
|
||||
* Classify API error to StopFailureErrorType
|
||||
* @internal Exported for testing purposes
|
||||
*/
|
||||
export function classifyApiError(error: {
|
||||
message: string;
|
||||
status?: number;
|
||||
}): StopFailureErrorType {
|
||||
const status = error.status;
|
||||
const message = error.message?.toLowerCase() ?? '';
|
||||
|
||||
if (status === 429 || message.includes('rate limit')) {
|
||||
return 'rate_limit';
|
||||
}
|
||||
if (status === 401 || message.includes('unauthorized')) {
|
||||
return 'authentication_failed';
|
||||
}
|
||||
if (
|
||||
status === 402 ||
|
||||
status === 403 ||
|
||||
message.includes('billing') ||
|
||||
message.includes('quota')
|
||||
) {
|
||||
return 'billing_error';
|
||||
}
|
||||
if (status === 400 || message.includes('invalid')) {
|
||||
return 'invalid_request';
|
||||
}
|
||||
if (status !== undefined && status >= 500) {
|
||||
return 'server_error';
|
||||
}
|
||||
if (message.includes('max_tokens') || message.includes('token limit')) {
|
||||
return 'max_output_tokens';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if image parts have supported formats and returns unsupported ones
|
||||
*/
|
||||
|
|
@ -795,6 +833,12 @@ export const useGeminiStream = (
|
|||
// (auto-retry countdown is shown when retryCountdownTimerRef is active)
|
||||
const isShowingAutoRetry = retryCountdownTimerRef.current !== null;
|
||||
clearRetryCountdown();
|
||||
|
||||
const formattedErrorText = parseAndFormatApiError(
|
||||
eventValue.error,
|
||||
config.getContentGeneratorConfig()?.authType,
|
||||
);
|
||||
|
||||
if (!isShowingAutoRetry) {
|
||||
const retryHint = t('Press Ctrl+Y to retry');
|
||||
// Store error with hint as a pending item (not in history).
|
||||
|
|
@ -802,14 +846,24 @@ export const useGeminiStream = (
|
|||
// since pending items are in the dynamic rendering area (not <Static>).
|
||||
setPendingRetryErrorItem({
|
||||
type: 'error' as const,
|
||||
text: parseAndFormatApiError(
|
||||
eventValue.error,
|
||||
config.getContentGeneratorConfig()?.authType,
|
||||
),
|
||||
text: formattedErrorText,
|
||||
hint: retryHint,
|
||||
});
|
||||
}
|
||||
setThought(null); // Reset thought when there's an error
|
||||
|
||||
// Fire StopFailure hook (fire-and-forget, replaces Stop event for API errors)
|
||||
const errorType = classifyApiError(eventValue.error);
|
||||
config
|
||||
.getHookSystem()
|
||||
?.fireStopFailureEvent(
|
||||
errorType,
|
||||
eventValue.error.message,
|
||||
formattedErrorText,
|
||||
)
|
||||
.catch((err) => {
|
||||
debugLogger.warn(`StopFailure hook failed: ${err}`);
|
||||
});
|
||||
},
|
||||
[
|
||||
addItem,
|
||||
|
|
|
|||
|
|
@ -794,4 +794,179 @@ describe('HookAggregator', () => {
|
|||
expect(hookOutput.isBlockingDecision()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StopFailure - fire-and-forget special handling', () => {
|
||||
it('should always return success true for StopFailure', () => {
|
||||
const results: HookExecutionResult[] = [
|
||||
{
|
||||
hookConfig: { type: HookType.Command, command: 'echo test' },
|
||||
eventName: HookEventName.StopFailure,
|
||||
success: false,
|
||||
error: new Error('Hook failed'),
|
||||
duration: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregator.aggregateResults(
|
||||
results,
|
||||
HookEventName.StopFailure,
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should ignore all outputs for StopFailure', () => {
|
||||
const outputs: HookOutput[] = [
|
||||
{ decision: 'block', reason: 'should be ignored' },
|
||||
{ continue: false, stopReason: 'also ignored' },
|
||||
];
|
||||
|
||||
const results: HookExecutionResult[] = outputs.map((output) => ({
|
||||
hookConfig: { type: HookType.Command, command: 'echo test' },
|
||||
eventName: HookEventName.StopFailure,
|
||||
success: true,
|
||||
output,
|
||||
duration: 100,
|
||||
}));
|
||||
|
||||
const result = aggregator.aggregateResults(
|
||||
results,
|
||||
HookEventName.StopFailure,
|
||||
);
|
||||
expect(result.allOutputs).toEqual([]);
|
||||
expect(result.finalOutput).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should ignore all errors for StopFailure', () => {
|
||||
const results: HookExecutionResult[] = [
|
||||
{
|
||||
hookConfig: { type: HookType.Command, command: 'hook1.sh' },
|
||||
eventName: HookEventName.StopFailure,
|
||||
success: false,
|
||||
error: new Error('First error'),
|
||||
duration: 50,
|
||||
},
|
||||
{
|
||||
hookConfig: { type: HookType.Command, command: 'hook2.sh' },
|
||||
eventName: HookEventName.StopFailure,
|
||||
success: false,
|
||||
error: new Error('Second error'),
|
||||
duration: 75,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregator.aggregateResults(
|
||||
results,
|
||||
HookEventName.StopFailure,
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should calculate total duration for StopFailure', () => {
|
||||
const results: HookExecutionResult[] = [
|
||||
{
|
||||
hookConfig: { type: HookType.Command, command: 'hook1.sh' },
|
||||
eventName: HookEventName.StopFailure,
|
||||
success: true,
|
||||
duration: 100,
|
||||
},
|
||||
{
|
||||
hookConfig: { type: HookType.Command, command: 'hook2.sh' },
|
||||
eventName: HookEventName.StopFailure,
|
||||
success: true,
|
||||
duration: 200,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregator.aggregateResults(
|
||||
results,
|
||||
HookEventName.StopFailure,
|
||||
);
|
||||
expect(result.totalDuration).toBe(300);
|
||||
});
|
||||
|
||||
it('should return empty result for StopFailure with no hooks', () => {
|
||||
const result = aggregator.aggregateResults([], HookEventName.StopFailure);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.allOutputs).toEqual([]);
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.totalDuration).toBe(0);
|
||||
expect(result.finalOutput).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PostCompact - mergeSimple', () => {
|
||||
it('should use mergeSimple for PostCompact event', () => {
|
||||
const outputs: HookOutput[] = [
|
||||
{ reason: 'first', continue: true },
|
||||
{ reason: 'second', continue: false },
|
||||
];
|
||||
|
||||
const results: HookExecutionResult[] = outputs.map((output) => ({
|
||||
hookConfig: { type: HookType.Command, command: 'echo test' },
|
||||
eventName: HookEventName.PostCompact,
|
||||
success: true,
|
||||
output,
|
||||
duration: 100,
|
||||
}));
|
||||
|
||||
const result = aggregator.aggregateResults(
|
||||
results,
|
||||
HookEventName.PostCompact,
|
||||
);
|
||||
// mergeSimple uses later values for simple fields
|
||||
expect(result.finalOutput?.reason).toBe('second');
|
||||
expect(result.finalOutput?.continue).toBe(false);
|
||||
});
|
||||
|
||||
it('should concatenate additionalContext for PostCompact', () => {
|
||||
const outputs: HookOutput[] = [
|
||||
{ hookSpecificOutput: { additionalContext: 'context 1' } },
|
||||
{ hookSpecificOutput: { additionalContext: 'context 2' } },
|
||||
];
|
||||
|
||||
const results: HookExecutionResult[] = outputs.map((output) => ({
|
||||
hookConfig: { type: HookType.Command, command: 'echo test' },
|
||||
eventName: HookEventName.PostCompact,
|
||||
success: true,
|
||||
output,
|
||||
duration: 100,
|
||||
}));
|
||||
|
||||
const result = aggregator.aggregateResults(
|
||||
results,
|
||||
HookEventName.PostCompact,
|
||||
);
|
||||
expect(
|
||||
result.finalOutput?.hookSpecificOutput?.['additionalContext'],
|
||||
).toBe('context 1\ncontext 2');
|
||||
});
|
||||
|
||||
it('should handle single output for PostCompact', () => {
|
||||
const output: HookOutput = {
|
||||
hookSpecificOutput: {
|
||||
hookEventName: 'PostCompact',
|
||||
additionalContext: 'single context',
|
||||
},
|
||||
};
|
||||
const results: HookExecutionResult[] = [
|
||||
{
|
||||
hookConfig: { type: HookType.Command, command: 'echo test' },
|
||||
eventName: HookEventName.PostCompact,
|
||||
success: true,
|
||||
output,
|
||||
duration: 100,
|
||||
},
|
||||
];
|
||||
|
||||
const result = aggregator.aggregateResults(
|
||||
results,
|
||||
HookEventName.PostCompact,
|
||||
);
|
||||
expect(result.finalOutput).toBeDefined();
|
||||
expect(
|
||||
result.finalOutput?.hookSpecificOutput?.['additionalContext'],
|
||||
).toBe('single context');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -40,6 +40,21 @@ export class HookAggregator {
|
|||
results: HookExecutionResult[],
|
||||
eventName: HookEventName,
|
||||
): AggregatedHookResult {
|
||||
// StopFailure special handling: fire-and-forget, ignore all outputs and errors
|
||||
if (eventName === HookEventName.StopFailure) {
|
||||
let totalDuration = 0;
|
||||
for (const result of results) {
|
||||
totalDuration += result.duration;
|
||||
}
|
||||
return {
|
||||
success: true, // Always return success
|
||||
allOutputs: [],
|
||||
errors: [], // Ignore errors
|
||||
totalDuration,
|
||||
finalOutput: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const allOutputs: HookOutput[] = [];
|
||||
const errors: Error[] = [];
|
||||
let totalDuration = 0;
|
||||
|
|
|
|||
|
|
@ -15,8 +15,10 @@ import {
|
|||
PermissionMode,
|
||||
AgentType,
|
||||
PreCompactTrigger,
|
||||
PostCompactTrigger,
|
||||
NotificationType,
|
||||
} from './types.js';
|
||||
import type { StopFailureErrorType } from './types.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type {
|
||||
HookPlanner,
|
||||
|
|
@ -2254,6 +2256,340 @@ describe('HookEventHandler', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('fireStopFailureEvent', () => {
|
||||
it('should execute hooks for StopFailure event', async () => {
|
||||
const mockPlan = createMockExecutionPlan([]);
|
||||
const mockAggregated = createMockAggregatedResult(true);
|
||||
|
||||
vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan);
|
||||
vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]);
|
||||
vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
|
||||
mockAggregated,
|
||||
);
|
||||
|
||||
const result = await hookEventHandler.fireStopFailureEvent(
|
||||
'rate_limit',
|
||||
'429 Too Many Requests',
|
||||
'API Error: Rate limit reached',
|
||||
);
|
||||
|
||||
expect(mockHookPlanner.createExecutionPlan).toHaveBeenCalledWith(
|
||||
HookEventName.StopFailure,
|
||||
{ error: 'rate_limit' },
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should include all parameters in the hook input', async () => {
|
||||
const mockPlan = createMockExecutionPlan([
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo test',
|
||||
source: HooksConfigSource.Project,
|
||||
},
|
||||
]);
|
||||
vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan);
|
||||
vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]);
|
||||
vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
|
||||
createMockAggregatedResult(true),
|
||||
);
|
||||
|
||||
await hookEventHandler.fireStopFailureEvent(
|
||||
'authentication_failed',
|
||||
'401 Unauthorized',
|
||||
'Please check your API key',
|
||||
);
|
||||
|
||||
const mockCalls = (mockHookRunner.executeHooksParallel as Mock).mock
|
||||
.calls;
|
||||
const input = mockCalls[0][2] as {
|
||||
error: StopFailureErrorType;
|
||||
error_details: string;
|
||||
last_assistant_message: string;
|
||||
hook_event_name: string;
|
||||
};
|
||||
|
||||
expect(input.error).toBe('authentication_failed');
|
||||
expect(input.error_details).toBe('401 Unauthorized');
|
||||
expect(input.last_assistant_message).toBe('Please check your API key');
|
||||
expect(input.hook_event_name).toBe(HookEventName.StopFailure);
|
||||
});
|
||||
|
||||
it('should pass error type as context for matcher filtering', async () => {
|
||||
const mockPlan = createMockExecutionPlan([]);
|
||||
const mockAggregated = createMockAggregatedResult(true);
|
||||
|
||||
vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan);
|
||||
vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]);
|
||||
vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
|
||||
mockAggregated,
|
||||
);
|
||||
|
||||
await hookEventHandler.fireStopFailureEvent('server_error');
|
||||
|
||||
expect(mockHookPlanner.createExecutionPlan).toHaveBeenCalledWith(
|
||||
HookEventName.StopFailure,
|
||||
{ error: 'server_error' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle all error types correctly', async () => {
|
||||
const mockPlan = createMockExecutionPlan([
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo test',
|
||||
source: HooksConfigSource.Project,
|
||||
},
|
||||
]);
|
||||
vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan);
|
||||
vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]);
|
||||
vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
|
||||
createMockAggregatedResult(true),
|
||||
);
|
||||
|
||||
const errorTypes: StopFailureErrorType[] = [
|
||||
'rate_limit',
|
||||
'authentication_failed',
|
||||
'billing_error',
|
||||
'invalid_request',
|
||||
'server_error',
|
||||
'max_output_tokens',
|
||||
'unknown',
|
||||
];
|
||||
|
||||
for (const errorType of errorTypes) {
|
||||
await hookEventHandler.fireStopFailureEvent(errorType);
|
||||
const mockCalls = (mockHookRunner.executeHooksParallel as Mock).mock
|
||||
.calls;
|
||||
const input = mockCalls[mockCalls.length - 1][2] as {
|
||||
error: StopFailureErrorType;
|
||||
};
|
||||
expect(input.error).toBe(errorType);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle optional parameters', async () => {
|
||||
const mockPlan = createMockExecutionPlan([
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo test',
|
||||
source: HooksConfigSource.Project,
|
||||
},
|
||||
]);
|
||||
vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan);
|
||||
vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]);
|
||||
vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
|
||||
createMockAggregatedResult(true),
|
||||
);
|
||||
|
||||
// Call with only required parameter
|
||||
await hookEventHandler.fireStopFailureEvent('unknown');
|
||||
|
||||
const mockCalls = (mockHookRunner.executeHooksParallel as Mock).mock
|
||||
.calls;
|
||||
const input = mockCalls[0][2] as {
|
||||
error: StopFailureErrorType;
|
||||
error_details?: string;
|
||||
last_assistant_message?: string;
|
||||
};
|
||||
|
||||
expect(input.error).toBe('unknown');
|
||||
expect(input.error_details).toBeUndefined();
|
||||
expect(input.last_assistant_message).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
vi.mocked(mockHookPlanner.createExecutionPlan).mockImplementation(() => {
|
||||
throw new Error('StopFailure planner error');
|
||||
});
|
||||
|
||||
const result = await hookEventHandler.fireStopFailureEvent('rate_limit');
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].message).toBe('StopFailure planner error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('firePostCompactEvent', () => {
|
||||
it('should execute hooks for PostCompact event with manual trigger', async () => {
|
||||
const mockPlan = createMockExecutionPlan([]);
|
||||
const mockAggregated = createMockAggregatedResult(true);
|
||||
|
||||
vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan);
|
||||
vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]);
|
||||
vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
|
||||
mockAggregated,
|
||||
);
|
||||
|
||||
const result = await hookEventHandler.firePostCompactEvent(
|
||||
PostCompactTrigger.Manual,
|
||||
'Summary of compacted conversation',
|
||||
);
|
||||
|
||||
expect(mockHookPlanner.createExecutionPlan).toHaveBeenCalledWith(
|
||||
HookEventName.PostCompact,
|
||||
{ trigger: PostCompactTrigger.Manual },
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should execute hooks for PostCompact event with auto trigger', async () => {
|
||||
const mockPlan = createMockExecutionPlan([]);
|
||||
const mockAggregated = createMockAggregatedResult(true);
|
||||
|
||||
vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan);
|
||||
vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]);
|
||||
vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
|
||||
mockAggregated,
|
||||
);
|
||||
|
||||
const result = await hookEventHandler.firePostCompactEvent(
|
||||
PostCompactTrigger.Auto,
|
||||
'Auto-generated summary',
|
||||
);
|
||||
|
||||
expect(mockHookPlanner.createExecutionPlan).toHaveBeenCalledWith(
|
||||
HookEventName.PostCompact,
|
||||
{ trigger: PostCompactTrigger.Auto },
|
||||
);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('should include all parameters in the hook input', async () => {
|
||||
const mockPlan = createMockExecutionPlan([
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo test',
|
||||
source: HooksConfigSource.Project,
|
||||
},
|
||||
]);
|
||||
vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan);
|
||||
vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]);
|
||||
vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
|
||||
createMockAggregatedResult(true),
|
||||
);
|
||||
|
||||
const summary = 'The user requested to implement a new feature...';
|
||||
await hookEventHandler.firePostCompactEvent(
|
||||
PostCompactTrigger.Manual,
|
||||
summary,
|
||||
);
|
||||
|
||||
const mockCalls = (mockHookRunner.executeHooksParallel as Mock).mock
|
||||
.calls;
|
||||
const input = mockCalls[0][2] as {
|
||||
trigger: PostCompactTrigger;
|
||||
compact_summary: string;
|
||||
hook_event_name: string;
|
||||
};
|
||||
|
||||
expect(input.trigger).toBe(PostCompactTrigger.Manual);
|
||||
expect(input.compact_summary).toBe(summary);
|
||||
expect(input.hook_event_name).toBe(HookEventName.PostCompact);
|
||||
});
|
||||
|
||||
it('should pass trigger as context for matcher filtering', async () => {
|
||||
const mockPlan = createMockExecutionPlan([]);
|
||||
const mockAggregated = createMockAggregatedResult(true);
|
||||
|
||||
vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan);
|
||||
vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]);
|
||||
vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
|
||||
mockAggregated,
|
||||
);
|
||||
|
||||
await hookEventHandler.firePostCompactEvent(
|
||||
PostCompactTrigger.Auto,
|
||||
'summary',
|
||||
);
|
||||
|
||||
expect(mockHookPlanner.createExecutionPlan).toHaveBeenCalledWith(
|
||||
HookEventName.PostCompact,
|
||||
{ trigger: PostCompactTrigger.Auto },
|
||||
);
|
||||
});
|
||||
|
||||
it('should execute hooks sequentially when plan.sequential is true', async () => {
|
||||
const mockPlan = createMockExecutionPlan(
|
||||
[
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo test',
|
||||
source: HooksConfigSource.Project,
|
||||
},
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan);
|
||||
vi.mocked(mockHookRunner.executeHooksSequential).mockResolvedValue([]);
|
||||
vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
|
||||
createMockAggregatedResult(true),
|
||||
);
|
||||
|
||||
await hookEventHandler.firePostCompactEvent(
|
||||
PostCompactTrigger.Manual,
|
||||
'summary',
|
||||
);
|
||||
|
||||
expect(mockHookRunner.executeHooksSequential).toHaveBeenCalled();
|
||||
expect(mockHookRunner.executeHooksParallel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
vi.mocked(mockHookPlanner.createExecutionPlan).mockImplementation(() => {
|
||||
throw new Error('PostCompact planner error');
|
||||
});
|
||||
|
||||
const result = await hookEventHandler.firePostCompactEvent(
|
||||
PostCompactTrigger.Auto,
|
||||
'summary',
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.errors[0].message).toBe('PostCompact planner error');
|
||||
});
|
||||
|
||||
it('should handle both trigger types correctly', async () => {
|
||||
const mockPlan = createMockExecutionPlan([
|
||||
{
|
||||
type: HookType.Command,
|
||||
command: 'echo test',
|
||||
source: HooksConfigSource.Project,
|
||||
},
|
||||
]);
|
||||
vi.mocked(mockHookPlanner.createExecutionPlan).mockReturnValue(mockPlan);
|
||||
vi.mocked(mockHookRunner.executeHooksParallel).mockResolvedValue([]);
|
||||
vi.mocked(mockHookAggregator.aggregateResults).mockReturnValue(
|
||||
createMockAggregatedResult(true),
|
||||
);
|
||||
|
||||
// Test manual trigger
|
||||
await hookEventHandler.firePostCompactEvent(
|
||||
PostCompactTrigger.Manual,
|
||||
'manual summary',
|
||||
);
|
||||
let mockCalls = (mockHookRunner.executeHooksParallel as Mock).mock.calls;
|
||||
let input = mockCalls[mockCalls.length - 1][2] as {
|
||||
trigger: PostCompactTrigger;
|
||||
};
|
||||
expect(input.trigger).toBe(PostCompactTrigger.Manual);
|
||||
|
||||
// Test auto trigger
|
||||
await hookEventHandler.firePostCompactEvent(
|
||||
PostCompactTrigger.Auto,
|
||||
'auto summary',
|
||||
);
|
||||
mockCalls = (mockHookRunner.executeHooksParallel as Mock).mock.calls;
|
||||
input = mockCalls[mockCalls.length - 1][2] as {
|
||||
trigger: PostCompactTrigger;
|
||||
};
|
||||
expect(input.trigger).toBe(PostCompactTrigger.Auto);
|
||||
});
|
||||
});
|
||||
|
||||
describe('telemetry', () => {
|
||||
const createMockHookExecutionResult = (
|
||||
success: boolean,
|
||||
|
|
|
|||
|
|
@ -25,12 +25,16 @@ import type {
|
|||
PostToolUseFailureInput,
|
||||
PreCompactInput,
|
||||
PreCompactTrigger,
|
||||
PostCompactInput,
|
||||
PostCompactTrigger,
|
||||
NotificationInput,
|
||||
NotificationType,
|
||||
PermissionRequestInput,
|
||||
PermissionSuggestion,
|
||||
SubagentStartInput,
|
||||
SubagentStopInput,
|
||||
StopFailureInput,
|
||||
StopFailureErrorType,
|
||||
} from './types.js';
|
||||
import { PermissionMode } from './types.js';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
|
|
@ -394,6 +398,57 @@ export class HookEventHandler {
|
|||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a StopFailure event
|
||||
* Called when an API error ends the turn (instead of Stop)
|
||||
* Fire-and-forget: output and exit codes are ignored
|
||||
*/
|
||||
async fireStopFailureEvent(
|
||||
error: StopFailureErrorType,
|
||||
errorDetails?: string,
|
||||
lastAssistantMessage?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AggregatedHookResult> {
|
||||
const input: StopFailureInput = {
|
||||
...this.createBaseInput(HookEventName.StopFailure),
|
||||
error,
|
||||
error_details: errorDetails,
|
||||
last_assistant_message: lastAssistantMessage,
|
||||
};
|
||||
|
||||
// Pass error type as context for matcher filtering (fieldToMatch: 'error')
|
||||
return this.executeHooks(
|
||||
HookEventName.StopFailure,
|
||||
input,
|
||||
{ error },
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a PostCompact event
|
||||
* Called after conversation compaction completes
|
||||
*/
|
||||
async firePostCompactEvent(
|
||||
trigger: PostCompactTrigger,
|
||||
compactSummary: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AggregatedHookResult> {
|
||||
const input: PostCompactInput = {
|
||||
...this.createBaseInput(HookEventName.PostCompact),
|
||||
trigger,
|
||||
compact_summary: compactSummary,
|
||||
};
|
||||
|
||||
// Pass trigger as context for matcher filtering
|
||||
return this.executeHooks(
|
||||
HookEventName.PostCompact,
|
||||
input,
|
||||
{ trigger },
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute hooks for a specific event (direct execution without MessageBus)
|
||||
* Used as fallback when MessageBus is not available
|
||||
|
|
|
|||
|
|
@ -713,5 +713,158 @@ describe('HookPlanner', () => {
|
|||
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
// StopFailure matcher tests
|
||||
it('should match error type with exact string for StopFailure', () => {
|
||||
const entry: HookRegistryEntry = {
|
||||
config: { type: HookType.Command, command: 'echo test' },
|
||||
source: HooksConfigSource.Project,
|
||||
eventName: HookEventName.StopFailure,
|
||||
matcher: 'rate_limit',
|
||||
enabled: true,
|
||||
};
|
||||
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
|
||||
|
||||
const result = planner.createExecutionPlan(HookEventName.StopFailure, {
|
||||
error: 'rate_limit',
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should not match error type with different string for StopFailure', () => {
|
||||
const entry: HookRegistryEntry = {
|
||||
config: { type: HookType.Command, command: 'echo test' },
|
||||
source: HooksConfigSource.Project,
|
||||
eventName: HookEventName.StopFailure,
|
||||
matcher: 'rate_limit',
|
||||
enabled: true,
|
||||
};
|
||||
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
|
||||
|
||||
const result = planner.createExecutionPlan(HookEventName.StopFailure, {
|
||||
error: 'authentication_failed',
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should match all error types when no matcher for StopFailure', () => {
|
||||
const entry: HookRegistryEntry = {
|
||||
config: { type: HookType.Command, command: 'echo test' },
|
||||
source: HooksConfigSource.Project,
|
||||
eventName: HookEventName.StopFailure,
|
||||
enabled: true,
|
||||
};
|
||||
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
|
||||
|
||||
const result = planner.createExecutionPlan(HookEventName.StopFailure, {
|
||||
error: 'server_error',
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should match all error types when matcher is wildcard for StopFailure', () => {
|
||||
const entry: HookRegistryEntry = {
|
||||
config: { type: HookType.Command, command: 'echo test' },
|
||||
source: HooksConfigSource.Project,
|
||||
eventName: HookEventName.StopFailure,
|
||||
matcher: '*',
|
||||
enabled: true,
|
||||
};
|
||||
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
|
||||
|
||||
const result = planner.createExecutionPlan(HookEventName.StopFailure, {
|
||||
error: 'billing_error',
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
// PostCompact matcher tests
|
||||
it('should match trigger with exact string for PostCompact', () => {
|
||||
const entry: HookRegistryEntry = {
|
||||
config: { type: HookType.Command, command: 'echo test' },
|
||||
source: HooksConfigSource.Project,
|
||||
eventName: HookEventName.PostCompact,
|
||||
matcher: 'manual',
|
||||
enabled: true,
|
||||
};
|
||||
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
|
||||
|
||||
const result = planner.createExecutionPlan(HookEventName.PostCompact, {
|
||||
trigger: 'manual',
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should not match trigger with different string for PostCompact', () => {
|
||||
const entry: HookRegistryEntry = {
|
||||
config: { type: HookType.Command, command: 'echo test' },
|
||||
source: HooksConfigSource.Project,
|
||||
eventName: HookEventName.PostCompact,
|
||||
matcher: 'manual',
|
||||
enabled: true,
|
||||
};
|
||||
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
|
||||
|
||||
const result = planner.createExecutionPlan(HookEventName.PostCompact, {
|
||||
trigger: 'auto',
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should match all triggers when no matcher for PostCompact', () => {
|
||||
const entry: HookRegistryEntry = {
|
||||
config: { type: HookType.Command, command: 'echo test' },
|
||||
source: HooksConfigSource.Project,
|
||||
eventName: HookEventName.PostCompact,
|
||||
enabled: true,
|
||||
};
|
||||
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
|
||||
|
||||
const result = planner.createExecutionPlan(HookEventName.PostCompact, {
|
||||
trigger: 'auto',
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should match all triggers when matcher is wildcard for PostCompact', () => {
|
||||
const entry: HookRegistryEntry = {
|
||||
config: { type: HookType.Command, command: 'echo test' },
|
||||
source: HooksConfigSource.Project,
|
||||
eventName: HookEventName.PostCompact,
|
||||
matcher: '*',
|
||||
enabled: true,
|
||||
};
|
||||
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
|
||||
|
||||
const result = planner.createExecutionPlan(HookEventName.PostCompact, {
|
||||
trigger: 'manual',
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should match auto trigger for PostCompact', () => {
|
||||
const entry: HookRegistryEntry = {
|
||||
config: { type: HookType.Command, command: 'echo test' },
|
||||
source: HooksConfigSource.Project,
|
||||
eventName: HookEventName.PostCompact,
|
||||
matcher: 'auto',
|
||||
enabled: true,
|
||||
};
|
||||
vi.mocked(mockRegistry.getHooksForEvent).mockReturnValue([entry]);
|
||||
|
||||
const result = planner.createExecutionPlan(HookEventName.PostCompact, {
|
||||
trigger: 'auto',
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -108,6 +108,18 @@ export class HookPlanner {
|
|||
? this.matchesTrigger(matcher, context.trigger)
|
||||
: true;
|
||||
|
||||
// PostCompact: match against trigger
|
||||
case HookEventName.PostCompact:
|
||||
return context.trigger
|
||||
? this.matchesTrigger(matcher, context.trigger)
|
||||
: true;
|
||||
|
||||
// StopFailure: match against error type (fieldToMatch: 'error')
|
||||
case HookEventName.StopFailure:
|
||||
return context.error
|
||||
? this.matchesTrigger(matcher, context.error)
|
||||
: true;
|
||||
|
||||
// Notification: match against notification type
|
||||
case HookEventName.Notification:
|
||||
return context.notificationType
|
||||
|
|
@ -229,4 +241,6 @@ export interface HookEventContext {
|
|||
notificationType?: string;
|
||||
/** Agent type for SubagentStart/SubagentStop matcher filtering */
|
||||
agentType?: string;
|
||||
/** Error type for StopFailure matcher filtering (fieldToMatch: 'error') */
|
||||
error?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ import type {
|
|||
AgentType,
|
||||
PermissionMode,
|
||||
PreCompactTrigger,
|
||||
PostCompactTrigger,
|
||||
NotificationType,
|
||||
PermissionSuggestion,
|
||||
HookEventName,
|
||||
StopFailureErrorType,
|
||||
} from './types.js';
|
||||
|
||||
const debugLogger = createDebugLogger('TRUSTED_HOOKS');
|
||||
|
|
@ -312,6 +314,42 @@ export class HookSystem {
|
|||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a StopFailure event - called when an API error ends the turn
|
||||
* Fire-and-forget: output and exit codes are ignored
|
||||
*/
|
||||
async fireStopFailureEvent(
|
||||
error: StopFailureErrorType,
|
||||
errorDetails?: string,
|
||||
lastAssistantMessage?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AggregatedHookResult> {
|
||||
return this.hookEventHandler.fireStopFailureEvent(
|
||||
error,
|
||||
errorDetails,
|
||||
lastAssistantMessage,
|
||||
signal,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a PostCompact event - called after conversation compaction completes
|
||||
*/
|
||||
async firePostCompactEvent(
|
||||
trigger: PostCompactTrigger,
|
||||
compactSummary: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DefaultHookOutput | undefined> {
|
||||
const result = await this.hookEventHandler.firePostCompactEvent(
|
||||
trigger,
|
||||
compactSummary,
|
||||
signal,
|
||||
);
|
||||
return result.finalOutput
|
||||
? createHookOutput('PostCompact', result.finalOutput)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire a PermissionRequest event
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -38,10 +38,14 @@ export enum HookEventName {
|
|||
SubagentStop = 'SubagentStop',
|
||||
// PreCompact - Before conversation compaction
|
||||
PreCompact = 'PreCompact',
|
||||
// PostCompact - After conversation compaction
|
||||
PostCompact = 'PostCompact',
|
||||
// SessionEnd - When a session is ending
|
||||
SessionEnd = 'SessionEnd',
|
||||
// When a permission dialog is displayed
|
||||
PermissionRequest = 'PermissionRequest',
|
||||
// StopFailure - When the turn ends due to an API error (instead of Stop)
|
||||
StopFailure = 'StopFailure',
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -673,6 +677,36 @@ export interface PreCompactOutput extends HookOutput {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PostCompact trigger types
|
||||
*/
|
||||
export enum PostCompactTrigger {
|
||||
Manual = 'manual',
|
||||
Auto = 'auto',
|
||||
}
|
||||
|
||||
/**
|
||||
* PostCompact hook input
|
||||
* Fired after conversation compaction completes
|
||||
*/
|
||||
export interface PostCompactInput extends HookInput {
|
||||
trigger: PostCompactTrigger;
|
||||
compact_summary: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* PostCompact hook output
|
||||
* Note: PostCompact is not in the official decision mode supported events list,
|
||||
* so hookSpecificOutput / additionalContext do not produce any control effects
|
||||
*/
|
||||
export interface PostCompactOutput extends HookOutput {
|
||||
// All returned JSON is ignored for control purposes
|
||||
hookSpecificOutput?: {
|
||||
hookEventName: 'PostCompact';
|
||||
additionalContext?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export enum AgentType {
|
||||
Bash = 'Bash',
|
||||
Explorer = 'Explorer',
|
||||
|
|
@ -724,6 +758,36 @@ export interface SubagentStopOutput extends HookOutput {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* StopFailure error types
|
||||
* Fires instead of Stop when an API error ended the turn
|
||||
*/
|
||||
export type StopFailureErrorType =
|
||||
| 'rate_limit'
|
||||
| 'authentication_failed'
|
||||
| 'billing_error'
|
||||
| 'invalid_request'
|
||||
| 'server_error'
|
||||
| 'max_output_tokens'
|
||||
| 'unknown';
|
||||
|
||||
/**
|
||||
* StopFailure hook input
|
||||
* Fired when the turn ends due to an API error (instead of Stop)
|
||||
*/
|
||||
export interface StopFailureInput extends HookInput {
|
||||
error: StopFailureErrorType;
|
||||
error_details?: string;
|
||||
last_assistant_message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* StopFailure hook output
|
||||
* Fire-and-forget: hook output and exit codes are ignored
|
||||
* This type alias is used instead of an empty interface to satisfy ESLint rules
|
||||
*/
|
||||
export type StopFailureOutput = HookOutput;
|
||||
|
||||
/**
|
||||
* Hook execution result
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -16,7 +16,11 @@ import { tokenLimit } from '../core/tokenLimits.js';
|
|||
import type { GeminiChat } from '../core/geminiChat.js';
|
||||
import type { Config } from '../config/config.js';
|
||||
import type { ContentGenerator } from '../core/contentGenerator.js';
|
||||
import { SessionStartSource, PreCompactTrigger } from '../hooks/types.js';
|
||||
import {
|
||||
SessionStartSource,
|
||||
PreCompactTrigger,
|
||||
PostCompactTrigger,
|
||||
} from '../hooks/types.js';
|
||||
|
||||
vi.mock('../telemetry/uiTelemetry.js');
|
||||
vi.mock('../core/tokenLimits.js');
|
||||
|
|
@ -813,12 +817,15 @@ describe('ChatCompressionService', () => {
|
|||
|
||||
describe('PreCompact hook', () => {
|
||||
let mockFirePreCompactEvent: ReturnType<typeof vi.fn>;
|
||||
let mockFirePostCompactEvent: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFirePreCompactEvent = vi.fn().mockResolvedValue(undefined);
|
||||
mockFirePostCompactEvent = vi.fn().mockResolvedValue(undefined);
|
||||
mockGetHookSystem.mockReturnValue({
|
||||
fireSessionStartEvent: mockFireSessionStartEvent,
|
||||
firePreCompactEvent: mockFirePreCompactEvent,
|
||||
firePostCompactEvent: mockFirePostCompactEvent,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -1143,6 +1150,327 @@ describe('ChatCompressionService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('PostCompact hook', () => {
|
||||
let mockFirePreCompactEvent: ReturnType<typeof vi.fn>;
|
||||
let mockFirePostCompactEvent: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFirePreCompactEvent = vi.fn().mockResolvedValue(undefined);
|
||||
mockFirePostCompactEvent = vi.fn().mockResolvedValue(undefined);
|
||||
mockGetHookSystem.mockReturnValue({
|
||||
fireSessionStartEvent: mockFireSessionStartEvent,
|
||||
firePreCompactEvent: mockFirePreCompactEvent,
|
||||
firePostCompactEvent: mockFirePostCompactEvent,
|
||||
});
|
||||
});
|
||||
|
||||
it('should fire PostCompact hook with Manual trigger when force=true', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'msg1' }] },
|
||||
{ role: 'model', parts: [{ text: 'msg2' }] },
|
||||
{ role: 'user', parts: [{ text: 'msg3' }] },
|
||||
{ role: 'model', parts: [{ text: 'msg4' }] },
|
||||
];
|
||||
vi.mocked(mockChat.getHistory).mockReturnValue(history);
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
|
||||
100,
|
||||
);
|
||||
vi.mocked(tokenLimit).mockReturnValue(1000);
|
||||
|
||||
const mockGenerateContent = vi.fn().mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Summary' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 1100,
|
||||
candidatesTokenCount: 50,
|
||||
totalTokenCount: 1150,
|
||||
},
|
||||
} as unknown as GenerateContentResponse);
|
||||
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
|
||||
generateContent: mockGenerateContent,
|
||||
} as unknown as ContentGenerator);
|
||||
|
||||
await service.compress(
|
||||
mockChat,
|
||||
mockPromptId,
|
||||
true, // force = true -> Manual trigger
|
||||
mockModel,
|
||||
mockConfig,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(mockFirePostCompactEvent).toHaveBeenCalledWith(
|
||||
PostCompactTrigger.Manual,
|
||||
'Summary',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should fire PostCompact hook with Auto trigger when force=false', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'msg1' }] },
|
||||
{ role: 'model', parts: [{ text: 'msg2' }] },
|
||||
{ role: 'user', parts: [{ text: 'msg3' }] },
|
||||
{ role: 'model', parts: [{ text: 'msg4' }] },
|
||||
];
|
||||
vi.mocked(mockChat.getHistory).mockReturnValue(history);
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
|
||||
800,
|
||||
);
|
||||
vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
|
||||
model: 'gemini-pro',
|
||||
contextWindowSize: 1000,
|
||||
} as unknown as ReturnType<typeof mockConfig.getContentGeneratorConfig>);
|
||||
|
||||
const mockGenerateContent = vi.fn().mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Auto Summary' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 1600,
|
||||
candidatesTokenCount: 50,
|
||||
totalTokenCount: 1650,
|
||||
},
|
||||
} as unknown as GenerateContentResponse);
|
||||
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
|
||||
generateContent: mockGenerateContent,
|
||||
} as unknown as ContentGenerator);
|
||||
|
||||
await service.compress(
|
||||
mockChat,
|
||||
mockPromptId,
|
||||
false, // force = false -> Auto trigger
|
||||
mockModel,
|
||||
mockConfig,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(mockFirePostCompactEvent).toHaveBeenCalledWith(
|
||||
PostCompactTrigger.Auto,
|
||||
'Auto Summary',
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not fire PostCompact hook when compression fails with empty summary', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'msg1' }] },
|
||||
{ role: 'model', parts: [{ text: 'msg2' }] },
|
||||
{ role: 'user', parts: [{ text: 'msg3' }] },
|
||||
{ role: 'model', parts: [{ text: 'msg4' }] },
|
||||
];
|
||||
vi.mocked(mockChat.getHistory).mockReturnValue(history);
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
|
||||
100,
|
||||
);
|
||||
vi.mocked(tokenLimit).mockReturnValue(1000);
|
||||
|
||||
const mockGenerateContent = vi.fn().mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: '' }], // Empty summary
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 1100,
|
||||
candidatesTokenCount: 0,
|
||||
totalTokenCount: 1100,
|
||||
},
|
||||
} as unknown as GenerateContentResponse);
|
||||
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
|
||||
generateContent: mockGenerateContent,
|
||||
} as unknown as ContentGenerator);
|
||||
|
||||
const result = await service.compress(
|
||||
mockChat,
|
||||
mockPromptId,
|
||||
true,
|
||||
mockModel,
|
||||
mockConfig,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.info.compressionStatus).toBe(
|
||||
CompressionStatus.COMPRESSION_FAILED_EMPTY_SUMMARY,
|
||||
);
|
||||
expect(mockFirePostCompactEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle PostCompact hook errors gracefully', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'msg1' }] },
|
||||
{ role: 'model', parts: [{ text: 'msg2' }] },
|
||||
{ role: 'user', parts: [{ text: 'msg3' }] },
|
||||
{ role: 'model', parts: [{ text: 'msg4' }] },
|
||||
];
|
||||
vi.mocked(mockChat.getHistory).mockReturnValue(history);
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
|
||||
800,
|
||||
);
|
||||
vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
|
||||
model: 'gemini-pro',
|
||||
contextWindowSize: 1000,
|
||||
} as unknown as ReturnType<typeof mockConfig.getContentGeneratorConfig>);
|
||||
|
||||
mockFirePostCompactEvent.mockRejectedValue(
|
||||
new Error('PostCompact hook failed'),
|
||||
);
|
||||
|
||||
const mockGenerateContent = vi.fn().mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Summary' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 1600,
|
||||
candidatesTokenCount: 50,
|
||||
totalTokenCount: 1650,
|
||||
},
|
||||
} as unknown as GenerateContentResponse);
|
||||
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
|
||||
generateContent: mockGenerateContent,
|
||||
} as unknown as ContentGenerator);
|
||||
|
||||
const result = await service.compress(
|
||||
mockChat,
|
||||
mockPromptId,
|
||||
false,
|
||||
mockModel,
|
||||
mockConfig,
|
||||
false,
|
||||
);
|
||||
|
||||
// Should still complete compression despite hook error
|
||||
expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
|
||||
expect(result.newHistory).not.toBeNull();
|
||||
expect(mockFirePostCompactEvent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fire hooks in correct order: PreCompact -> SessionStart -> PostCompact', async () => {
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'msg1' }] },
|
||||
{ role: 'model', parts: [{ text: 'msg2' }] },
|
||||
{ role: 'user', parts: [{ text: 'msg3' }] },
|
||||
{ role: 'model', parts: [{ text: 'msg4' }] },
|
||||
];
|
||||
vi.mocked(mockChat.getHistory).mockReturnValue(history);
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
|
||||
800,
|
||||
);
|
||||
vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
|
||||
model: 'gemini-pro',
|
||||
contextWindowSize: 1000,
|
||||
} as unknown as ReturnType<typeof mockConfig.getContentGeneratorConfig>);
|
||||
|
||||
const callOrder: string[] = [];
|
||||
mockFirePreCompactEvent.mockImplementation(async () => {
|
||||
callOrder.push('PreCompact');
|
||||
});
|
||||
mockFireSessionStartEvent.mockImplementation(async () => {
|
||||
callOrder.push('SessionStart');
|
||||
});
|
||||
mockFirePostCompactEvent.mockImplementation(async () => {
|
||||
callOrder.push('PostCompact');
|
||||
});
|
||||
|
||||
const mockGenerateContent = vi.fn().mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Summary' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 1600,
|
||||
candidatesTokenCount: 50,
|
||||
totalTokenCount: 1650,
|
||||
},
|
||||
} as unknown as GenerateContentResponse);
|
||||
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
|
||||
generateContent: mockGenerateContent,
|
||||
} as unknown as ContentGenerator);
|
||||
|
||||
await service.compress(
|
||||
mockChat,
|
||||
mockPromptId,
|
||||
false,
|
||||
mockModel,
|
||||
mockConfig,
|
||||
false,
|
||||
);
|
||||
|
||||
// Hooks should be called in order: PreCompact -> SessionStart -> PostCompact
|
||||
expect(callOrder).toEqual(['PreCompact', 'SessionStart', 'PostCompact']);
|
||||
});
|
||||
|
||||
it('should not fire PostCompact hook when hookSystem is null', async () => {
|
||||
mockGetHookSystem.mockReturnValue(null);
|
||||
|
||||
const history: Content[] = [
|
||||
{ role: 'user', parts: [{ text: 'msg1' }] },
|
||||
{ role: 'model', parts: [{ text: 'msg2' }] },
|
||||
{ role: 'user', parts: [{ text: 'msg3' }] },
|
||||
{ role: 'model', parts: [{ text: 'msg4' }] },
|
||||
];
|
||||
vi.mocked(mockChat.getHistory).mockReturnValue(history);
|
||||
vi.mocked(uiTelemetryService.getLastPromptTokenCount).mockReturnValue(
|
||||
800,
|
||||
);
|
||||
vi.mocked(mockConfig.getContentGeneratorConfig).mockReturnValue({
|
||||
model: 'gemini-pro',
|
||||
contextWindowSize: 1000,
|
||||
} as unknown as ReturnType<typeof mockConfig.getContentGeneratorConfig>);
|
||||
|
||||
const mockGenerateContent = vi.fn().mockResolvedValue({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text: 'Summary' }],
|
||||
},
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 1600,
|
||||
candidatesTokenCount: 50,
|
||||
totalTokenCount: 1650,
|
||||
},
|
||||
} as unknown as GenerateContentResponse);
|
||||
vi.mocked(mockConfig.getContentGenerator).mockReturnValue({
|
||||
generateContent: mockGenerateContent,
|
||||
} as unknown as ContentGenerator);
|
||||
|
||||
const result = await service.compress(
|
||||
mockChat,
|
||||
mockPromptId,
|
||||
false,
|
||||
mockModel,
|
||||
mockConfig,
|
||||
false,
|
||||
);
|
||||
|
||||
// Should still complete compression without hook
|
||||
expect(result.info.compressionStatus).toBe(CompressionStatus.COMPRESSED);
|
||||
expect(result.newHistory).not.toBeNull();
|
||||
// mockFirePostCompactEvent should not be called since hookSystem is null
|
||||
expect(mockFirePostCompactEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('orphaned trailing funcCall handling', () => {
|
||||
it('should compress everything when force=true and last message is an orphaned funcCall', async () => {
|
||||
// Issue #2647: tool-heavy conversation interrupted/crashed while a tool
|
||||
|
|
|
|||
|
|
@ -15,7 +15,11 @@ import { getResponseText } from '../utils/partUtils.js';
|
|||
import { logChatCompression } from '../telemetry/loggers.js';
|
||||
import { makeChatCompressionEvent } from '../telemetry/types.js';
|
||||
import type { PermissionMode } from '../hooks/types.js';
|
||||
import { SessionStartSource, PreCompactTrigger } from '../hooks/types.js';
|
||||
import {
|
||||
SessionStartSource,
|
||||
PreCompactTrigger,
|
||||
PostCompactTrigger,
|
||||
} from '../hooks/types.js';
|
||||
|
||||
/**
|
||||
* Threshold for compression token count as a fraction of the model's token limit.
|
||||
|
|
@ -355,6 +359,18 @@ export class ChatCompressionService {
|
|||
config.getDebugLogger().warn(`SessionStart hook failed: ${err}`);
|
||||
}
|
||||
|
||||
// Fire PostCompact event after successful compression
|
||||
try {
|
||||
const postCompactTrigger = force
|
||||
? PostCompactTrigger.Manual
|
||||
: PostCompactTrigger.Auto;
|
||||
await config
|
||||
.getHookSystem()
|
||||
?.firePostCompactEvent(postCompactTrigger, summary, signal);
|
||||
} catch (err) {
|
||||
config.getDebugLogger().warn(`PostCompact hook failed: ${err}`);
|
||||
}
|
||||
|
||||
return {
|
||||
newHistory: extraHistory,
|
||||
info: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue