Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion packages/hooks/src/handlers/stop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ export async function stop(store: MemoryStore, input: HookInput): Promise<void>

function detectUsageLimitReason(input: HookInput): string | null {
for (const signal of collectReasonSignals(input)) {
if (USAGE_LIMIT_PATTERNS.some((pattern) => pattern.test(signal))) {
const normalized = normalizeSignalForMatch(signal);
if (USAGE_LIMIT_PATTERNS.some((pattern) => pattern.test(signal) || pattern.test(normalized))) {
return compactOneLine(signal, 220) ?? 'usage limit reached';
}
}
Expand All @@ -89,6 +90,10 @@ function collectNarrativeSignals(input: HookInput): string[] {
);
}

function normalizeSignalForMatch(value: string): string {
return value.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim();
}

function extractStringValues(value: unknown, depth = 0): string[] {
if (depth > 2 || value == null) return [];
if (typeof value === 'string') return [value];
Expand Down
50 changes: 50 additions & 0 deletions packages/hooks/test/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,56 @@ describe('runHook', () => {
expect(store.storage.taskObservationsByKind(taskId, 'handoff', 10)).toHaveLength(1);
});

it('stop treats RATE_LIMIT_EXCEEDED code as usage-limit trigger', async () => {
const repo = join(dir, 'repo-limit-code');
mkdirSync(join(repo, '.git'), { recursive: true });
writeFileSync(join(repo, '.git', 'HEAD'), 'ref: refs/heads/agent/codex/limit-code\n', 'utf8');

await runHook(
'user-prompt-submit',
{
session_id: 'codex@limit-code',
ide: 'codex',
cwd: repo,
prompt: 'continue migration lane',
},
{ store },
);

await runHook(
'stop',
{
session_id: 'codex@limit-code',
ide: 'codex',
cwd: repo,
stop_reason: 'RATE_LIMIT_EXCEEDED',
},
{ store },
);

const taskId = store.storage.findActiveTaskForSession('codex@limit-code');
expect(taskId).toBeDefined();
if (taskId === undefined) throw new Error('task should exist for rate-limit code takeover');

const handoffs = store.storage.taskObservationsByKind(taskId, 'handoff', 10);
const blockers = store.storage.taskObservationsByKind(taskId, 'blocker', 10);
const turns = store.storage.listSummaries('codex@limit-code').filter((s) => s.scope === 'turn');

expect(handoffs).toHaveLength(1);
expect(blockers).toHaveLength(1);
expect(turns).toHaveLength(0);

const handoffMeta = JSON.parse(handoffs[0]?.metadata ?? '{}') as Record<string, unknown>;
expect(handoffMeta).toMatchObject({
kind: 'handoff',
to_agent: 'any',
status: 'pending',
summary: 'Session hit usage limit; takeover requested.',
});
expect((handoffMeta.blockers as string[])[0]).toContain('RATE_LIMIT_EXCEEDED');
expect(blockers[0]?.content).toContain('USAGE LIMIT: RATE_LIMIT_EXCEEDED');
});

it('hot-path hooks stay under a generous 150ms budget on a warm runtime', async () => {
await runHook('session-start', { session_id: 'sess-perf', ide: 'claude-code' }, { store });
// Warm up JIT / prepared-statement cache.
Expand Down