Add /compact skill for manual context compaction (#817)
* feat: add /compact skill for manual context compaction added /compact session command to fight context rot in long-running sessions. Uses Claude Agent SDK's built-in /compact command with auth gating (main-group or is_from_me only). * simplify: remove group-queue modification, streamline denied path confirmed against fresh-clone merge. * refactor: extract handleSessionCommand from index.ts into session-commands.ts Verified: 345/345 tests pass on fresh-clone merge.
This commit is contained in:
214
.claude/skills/add-compact/add/src/session-commands.test.ts
Normal file
214
.claude/skills/add-compact/add/src/session-commands.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { extractSessionCommand, handleSessionCommand, isSessionCommandAllowed } from './session-commands.js';
|
||||
import type { NewMessage } from './types.js';
|
||||
import type { SessionCommandDeps } from './session-commands.js';
|
||||
|
||||
describe('extractSessionCommand', () => {
|
||||
const trigger = /^@Andy\b/i;
|
||||
|
||||
it('detects bare /compact', () => {
|
||||
expect(extractSessionCommand('/compact', trigger)).toBe('/compact');
|
||||
});
|
||||
|
||||
it('detects /compact with trigger prefix', () => {
|
||||
expect(extractSessionCommand('@Andy /compact', trigger)).toBe('/compact');
|
||||
});
|
||||
|
||||
it('rejects /compact with extra text', () => {
|
||||
expect(extractSessionCommand('/compact now please', trigger)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects partial matches', () => {
|
||||
expect(extractSessionCommand('/compaction', trigger)).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects regular messages', () => {
|
||||
expect(extractSessionCommand('please compact the conversation', trigger)).toBeNull();
|
||||
});
|
||||
|
||||
it('handles whitespace', () => {
|
||||
expect(extractSessionCommand(' /compact ', trigger)).toBe('/compact');
|
||||
});
|
||||
|
||||
it('is case-sensitive for the command', () => {
|
||||
expect(extractSessionCommand('/Compact', trigger)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSessionCommandAllowed', () => {
|
||||
it('allows main group regardless of sender', () => {
|
||||
expect(isSessionCommandAllowed(true, false)).toBe(true);
|
||||
});
|
||||
|
||||
it('allows trusted/admin sender (is_from_me) in non-main group', () => {
|
||||
expect(isSessionCommandAllowed(false, true)).toBe(true);
|
||||
});
|
||||
|
||||
it('denies untrusted sender in non-main group', () => {
|
||||
expect(isSessionCommandAllowed(false, false)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows trusted sender in main group', () => {
|
||||
expect(isSessionCommandAllowed(true, true)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
function makeMsg(content: string, overrides: Partial<NewMessage> = {}): NewMessage {
|
||||
return {
|
||||
id: 'msg-1',
|
||||
chat_jid: 'group@test',
|
||||
sender: 'user@test',
|
||||
sender_name: 'User',
|
||||
content,
|
||||
timestamp: '100',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDeps(overrides: Partial<SessionCommandDeps> = {}): SessionCommandDeps {
|
||||
return {
|
||||
sendMessage: vi.fn().mockResolvedValue(undefined),
|
||||
setTyping: vi.fn().mockResolvedValue(undefined),
|
||||
runAgent: vi.fn().mockResolvedValue('success'),
|
||||
closeStdin: vi.fn(),
|
||||
advanceCursor: vi.fn(),
|
||||
formatMessages: vi.fn().mockReturnValue('<formatted>'),
|
||||
canSenderInteract: vi.fn().mockReturnValue(true),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const trigger = /^@Andy\b/i;
|
||||
|
||||
describe('handleSessionCommand', () => {
|
||||
it('returns handled:false when no session command found', async () => {
|
||||
const deps = makeDeps();
|
||||
const result = await handleSessionCommand({
|
||||
missedMessages: [makeMsg('hello')],
|
||||
isMainGroup: true,
|
||||
groupName: 'test',
|
||||
triggerPattern: trigger,
|
||||
timezone: 'UTC',
|
||||
deps,
|
||||
});
|
||||
expect(result.handled).toBe(false);
|
||||
});
|
||||
|
||||
it('handles authorized /compact in main group', async () => {
|
||||
const deps = makeDeps();
|
||||
const result = await handleSessionCommand({
|
||||
missedMessages: [makeMsg('/compact')],
|
||||
isMainGroup: true,
|
||||
groupName: 'test',
|
||||
triggerPattern: trigger,
|
||||
timezone: 'UTC',
|
||||
deps,
|
||||
});
|
||||
expect(result).toEqual({ handled: true, success: true });
|
||||
expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function));
|
||||
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
|
||||
});
|
||||
|
||||
it('sends denial to interactable sender in non-main group', async () => {
|
||||
const deps = makeDeps();
|
||||
const result = await handleSessionCommand({
|
||||
missedMessages: [makeMsg('/compact', { is_from_me: false })],
|
||||
isMainGroup: false,
|
||||
groupName: 'test',
|
||||
triggerPattern: trigger,
|
||||
timezone: 'UTC',
|
||||
deps,
|
||||
});
|
||||
expect(result).toEqual({ handled: true, success: true });
|
||||
expect(deps.sendMessage).toHaveBeenCalledWith('Session commands require admin access.');
|
||||
expect(deps.runAgent).not.toHaveBeenCalled();
|
||||
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
|
||||
});
|
||||
|
||||
it('silently consumes denied command when sender cannot interact', async () => {
|
||||
const deps = makeDeps({ canSenderInteract: vi.fn().mockReturnValue(false) });
|
||||
const result = await handleSessionCommand({
|
||||
missedMessages: [makeMsg('/compact', { is_from_me: false })],
|
||||
isMainGroup: false,
|
||||
groupName: 'test',
|
||||
triggerPattern: trigger,
|
||||
timezone: 'UTC',
|
||||
deps,
|
||||
});
|
||||
expect(result).toEqual({ handled: true, success: true });
|
||||
expect(deps.sendMessage).not.toHaveBeenCalled();
|
||||
expect(deps.advanceCursor).toHaveBeenCalledWith('100');
|
||||
});
|
||||
|
||||
it('processes pre-compact messages before /compact', async () => {
|
||||
const deps = makeDeps();
|
||||
const msgs = [
|
||||
makeMsg('summarize this', { timestamp: '99' }),
|
||||
makeMsg('/compact', { timestamp: '100' }),
|
||||
];
|
||||
const result = await handleSessionCommand({
|
||||
missedMessages: msgs,
|
||||
isMainGroup: true,
|
||||
groupName: 'test',
|
||||
triggerPattern: trigger,
|
||||
timezone: 'UTC',
|
||||
deps,
|
||||
});
|
||||
expect(result).toEqual({ handled: true, success: true });
|
||||
expect(deps.formatMessages).toHaveBeenCalledWith([msgs[0]], 'UTC');
|
||||
// Two runAgent calls: pre-compact + /compact
|
||||
expect(deps.runAgent).toHaveBeenCalledTimes(2);
|
||||
expect(deps.runAgent).toHaveBeenCalledWith('<formatted>', expect.any(Function));
|
||||
expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function));
|
||||
});
|
||||
|
||||
it('allows is_from_me sender in non-main group', async () => {
|
||||
const deps = makeDeps();
|
||||
const result = await handleSessionCommand({
|
||||
missedMessages: [makeMsg('/compact', { is_from_me: true })],
|
||||
isMainGroup: false,
|
||||
groupName: 'test',
|
||||
triggerPattern: trigger,
|
||||
timezone: 'UTC',
|
||||
deps,
|
||||
});
|
||||
expect(result).toEqual({ handled: true, success: true });
|
||||
expect(deps.runAgent).toHaveBeenCalledWith('/compact', expect.any(Function));
|
||||
});
|
||||
|
||||
it('reports failure when command-stage runAgent returns error without streamed status', async () => {
|
||||
// runAgent resolves 'error' but callback never gets status: 'error'
|
||||
const deps = makeDeps({ runAgent: vi.fn().mockImplementation(async (prompt, onOutput) => {
|
||||
await onOutput({ status: 'success', result: null });
|
||||
return 'error';
|
||||
})});
|
||||
const result = await handleSessionCommand({
|
||||
missedMessages: [makeMsg('/compact')],
|
||||
isMainGroup: true,
|
||||
groupName: 'test',
|
||||
triggerPattern: trigger,
|
||||
timezone: 'UTC',
|
||||
deps,
|
||||
});
|
||||
expect(result).toEqual({ handled: true, success: true });
|
||||
expect(deps.sendMessage).toHaveBeenCalledWith(expect.stringContaining('failed'));
|
||||
});
|
||||
|
||||
it('returns success:false on pre-compact failure with no output', async () => {
|
||||
const deps = makeDeps({ runAgent: vi.fn().mockResolvedValue('error') });
|
||||
const msgs = [
|
||||
makeMsg('summarize this', { timestamp: '99' }),
|
||||
makeMsg('/compact', { timestamp: '100' }),
|
||||
];
|
||||
const result = await handleSessionCommand({
|
||||
missedMessages: msgs,
|
||||
isMainGroup: true,
|
||||
groupName: 'test',
|
||||
triggerPattern: trigger,
|
||||
timezone: 'UTC',
|
||||
deps,
|
||||
});
|
||||
expect(result).toEqual({ handled: true, success: false });
|
||||
expect(deps.sendMessage).toHaveBeenCalledWith(expect.stringContaining('Failed to process'));
|
||||
});
|
||||
});
|
||||
143
.claude/skills/add-compact/add/src/session-commands.ts
Normal file
143
.claude/skills/add-compact/add/src/session-commands.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { NewMessage } from './types.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* Extract a session slash command from a message, stripping the trigger prefix if present.
|
||||
* Returns the slash command (e.g., '/compact') or null if not a session command.
|
||||
*/
|
||||
export function extractSessionCommand(content: string, triggerPattern: RegExp): string | null {
|
||||
let text = content.trim();
|
||||
text = text.replace(triggerPattern, '').trim();
|
||||
if (text === '/compact') return '/compact';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a session command sender is authorized.
|
||||
* Allowed: main group (any sender), or trusted/admin sender (is_from_me) in any group.
|
||||
*/
|
||||
export function isSessionCommandAllowed(isMainGroup: boolean, isFromMe: boolean): boolean {
|
||||
return isMainGroup || isFromMe;
|
||||
}
|
||||
|
||||
/** Minimal agent result interface — matches the subset of ContainerOutput used here. */
|
||||
export interface AgentResult {
|
||||
status: 'success' | 'error';
|
||||
result?: string | object | null;
|
||||
}
|
||||
|
||||
/** Dependencies injected by the orchestrator. */
|
||||
export interface SessionCommandDeps {
|
||||
sendMessage: (text: string) => Promise<void>;
|
||||
setTyping: (typing: boolean) => Promise<void>;
|
||||
runAgent: (
|
||||
prompt: string,
|
||||
onOutput: (result: AgentResult) => Promise<void>,
|
||||
) => Promise<'success' | 'error'>;
|
||||
closeStdin: () => void;
|
||||
advanceCursor: (timestamp: string) => void;
|
||||
formatMessages: (msgs: NewMessage[], timezone: string) => string;
|
||||
/** Whether the denied sender would normally be allowed to interact (for denial messages). */
|
||||
canSenderInteract: (msg: NewMessage) => boolean;
|
||||
}
|
||||
|
||||
function resultToText(result: string | object | null | undefined): string {
|
||||
if (!result) return '';
|
||||
const raw = typeof result === 'string' ? result : JSON.stringify(result);
|
||||
return raw.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle session command interception in processGroupMessages.
|
||||
* Scans messages for a session command, handles auth + execution.
|
||||
* Returns { handled: true, success } if a command was found; { handled: false } otherwise.
|
||||
* success=false means the caller should retry (cursor was not advanced).
|
||||
*/
|
||||
export async function handleSessionCommand(opts: {
|
||||
missedMessages: NewMessage[];
|
||||
isMainGroup: boolean;
|
||||
groupName: string;
|
||||
triggerPattern: RegExp;
|
||||
timezone: string;
|
||||
deps: SessionCommandDeps;
|
||||
}): Promise<{ handled: false } | { handled: true; success: boolean }> {
|
||||
const { missedMessages, isMainGroup, groupName, triggerPattern, timezone, deps } = opts;
|
||||
|
||||
const cmdMsg = missedMessages.find(
|
||||
(m) => extractSessionCommand(m.content, triggerPattern) !== null,
|
||||
);
|
||||
const command = cmdMsg ? extractSessionCommand(cmdMsg.content, triggerPattern) : null;
|
||||
|
||||
if (!command || !cmdMsg) return { handled: false };
|
||||
|
||||
if (!isSessionCommandAllowed(isMainGroup, cmdMsg.is_from_me === true)) {
|
||||
// DENIED: send denial if the sender would normally be allowed to interact,
|
||||
// then silently consume the command by advancing the cursor past it.
|
||||
// Trade-off: other messages in the same batch are also consumed (cursor is
|
||||
// a high-water mark). Acceptable for this narrow edge case.
|
||||
if (deps.canSenderInteract(cmdMsg)) {
|
||||
await deps.sendMessage('Session commands require admin access.');
|
||||
}
|
||||
deps.advanceCursor(cmdMsg.timestamp);
|
||||
return { handled: true, success: true };
|
||||
}
|
||||
|
||||
// AUTHORIZED: process pre-compact messages first, then run the command
|
||||
logger.info({ group: groupName, command }, 'Session command');
|
||||
|
||||
const cmdIndex = missedMessages.indexOf(cmdMsg);
|
||||
const preCompactMsgs = missedMessages.slice(0, cmdIndex);
|
||||
|
||||
// Send pre-compact messages to the agent so they're in the session context.
|
||||
if (preCompactMsgs.length > 0) {
|
||||
const prePrompt = deps.formatMessages(preCompactMsgs, timezone);
|
||||
let hadPreError = false;
|
||||
let preOutputSent = false;
|
||||
|
||||
const preResult = await deps.runAgent(prePrompt, async (result) => {
|
||||
if (result.status === 'error') hadPreError = true;
|
||||
const text = resultToText(result.result);
|
||||
if (text) {
|
||||
await deps.sendMessage(text);
|
||||
preOutputSent = true;
|
||||
}
|
||||
// Close stdin on session-update marker — emitted after query completes,
|
||||
// so all results (including multi-result runs) are already written.
|
||||
if (result.status === 'success' && result.result === null) {
|
||||
deps.closeStdin();
|
||||
}
|
||||
});
|
||||
|
||||
if (preResult === 'error' || hadPreError) {
|
||||
logger.warn({ group: groupName }, 'Pre-compact processing failed, aborting session command');
|
||||
await deps.sendMessage(`Failed to process messages before ${command}. Try again.`);
|
||||
if (preOutputSent) {
|
||||
// Output was already sent — don't retry or it will duplicate.
|
||||
// Advance cursor past pre-compact messages, leave command pending.
|
||||
deps.advanceCursor(preCompactMsgs[preCompactMsgs.length - 1].timestamp);
|
||||
return { handled: true, success: true };
|
||||
}
|
||||
return { handled: true, success: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Forward the literal slash command as the prompt (no XML formatting)
|
||||
await deps.setTyping(true);
|
||||
|
||||
let hadCmdError = false;
|
||||
const cmdOutput = await deps.runAgent(command, async (result) => {
|
||||
if (result.status === 'error') hadCmdError = true;
|
||||
const text = resultToText(result.result);
|
||||
if (text) await deps.sendMessage(text);
|
||||
});
|
||||
|
||||
// Advance cursor to the command — messages AFTER it remain pending for next poll.
|
||||
deps.advanceCursor(cmdMsg.timestamp);
|
||||
await deps.setTyping(false);
|
||||
|
||||
if (cmdOutput === 'error' || hadCmdError) {
|
||||
await deps.sendMessage(`${command} failed. The session is unchanged.`);
|
||||
}
|
||||
|
||||
return { handled: true, success: true };
|
||||
}
|
||||
Reference in New Issue
Block a user