feat: Created pure-function event formatters (10 functions) mapping RPC…
- "packages/daemon/src/event-formatter.ts" - "packages/daemon/src/verbosity.ts" - "packages/daemon/src/event-formatter.test.ts" - "packages/daemon/src/verbosity.test.ts" - "packages/daemon/src/types.ts" - "packages/daemon/src/config.ts" GSD-Task: S04/T01
This commit is contained in:
parent
b5adaf2d9f
commit
4c8bbca46f
7 changed files with 1173 additions and 2 deletions
|
|
@ -115,15 +115,101 @@ GSD creates and manages all state in `.gsd/`:
|
|||
PROJECT.md # What this project is
|
||||
REQUIREMENTS.md # Capability contract
|
||||
DECISIONS.md # Architectural decisions (append-only)
|
||||
KNOWLEDGE.md # Persistent project knowledge (patterns, rules, lessons)
|
||||
STATE.md # Current phase and next action
|
||||
milestones/
|
||||
M001-xxxxx/
|
||||
M001-xxxxx-CONTEXT.md # Scope, constraints, assumptions
|
||||
M001-xxxxx-ROADMAP.md # Slices with checkboxes
|
||||
M001-xxxxx-SUMMARY.md # Completion summary
|
||||
slices/S01/
|
||||
S01-PLAN.md # Tasks
|
||||
tasks/T01-PLAN.md # Individual task spec
|
||||
S01-SUMMARY.md # Slice summary
|
||||
tasks/
|
||||
T01-PLAN.md # Individual task spec
|
||||
T01-SUMMARY.md # Task completion summary
|
||||
```
|
||||
|
||||
You never need to edit these files. GSD manages them. But you can read them to understand progress.
|
||||
State is derived from files on disk — checkboxes in ROADMAP.md and PLAN.md are the source of truth for completion. You never need to edit these files. GSD manages them. But you can read them to understand progress.
|
||||
</project_structure>
|
||||
|
||||
<flags>
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--output-format <fmt>` | `text` (default), `json` (structured result at exit), `stream-json` (JSONL events) |
|
||||
| `--json` | Alias for `--output-format stream-json` — JSONL event stream to stdout |
|
||||
| `--bare` | Skip CLAUDE.md, AGENTS.md, user settings, user skills. Use for CI/ecosystem runs. |
|
||||
| `--resume <id>` | Resume a prior headless session by its session ID |
|
||||
| `--timeout N` | Overall timeout in ms (default: 300000, use 0 to disable) |
|
||||
| `--model ID` | Override LLM model |
|
||||
| `--supervised` | Forward interactive UI requests to orchestrator via stdout/stdin |
|
||||
| `--response-timeout N` | Timeout (ms) for orchestrator response in supervised mode (default: 30000) |
|
||||
| `--answers <path>` | Pre-supply answers and secrets from JSON file |
|
||||
| `--events <types>` | Filter JSONL to specific event types (comma-separated, implies `--json`) |
|
||||
| `--verbose` | Show tool calls in progress output |
|
||||
| `--context <path>` | Spec file path for `new-milestone` (use `-` for stdin) |
|
||||
| `--context-text <text>` | Inline spec text for `new-milestone` |
|
||||
| `--auto` | Chain into auto-mode after `new-milestone` |
|
||||
</flags>
|
||||
|
||||
<answer_injection>
|
||||
Pre-supply answers and secrets for fully autonomous runs:
|
||||
|
||||
```bash
|
||||
gsd headless --answers answers.json --output-format json auto 2>/dev/null
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"questions": { "question_id": "selected_option" },
|
||||
"secrets": { "API_KEY": "sk-..." },
|
||||
"defaults": { "strategy": "first_option" }
|
||||
}
|
||||
```
|
||||
|
||||
- **questions** — question ID to answer (string for single-select, string[] for multi-select)
|
||||
- **secrets** — env var to value, injected into child process environment
|
||||
- **defaults.strategy** — `"first_option"` (default) or `"cancel"` for unmatched questions
|
||||
|
||||
See `references/answer-injection.md` for the full mechanism.
|
||||
</answer_injection>
|
||||
|
||||
<event_streaming>
|
||||
For real-time monitoring, use JSONL event streaming:
|
||||
|
||||
```bash
|
||||
gsd headless --json auto 2>/dev/null | while read -r line; do
|
||||
TYPE=$(echo "$line" | jq -r '.type')
|
||||
case "$TYPE" in
|
||||
tool_execution_start) echo "Tool: $(echo "$line" | jq -r '.toolName')" ;;
|
||||
extension_ui_request) echo "GSD: $(echo "$line" | jq -r '.message // .title // empty')" ;;
|
||||
agent_end) echo "Session ended" ;;
|
||||
esac
|
||||
done
|
||||
```
|
||||
|
||||
Filter to specific events: `--events agent_end,execution_complete,extension_ui_request`
|
||||
|
||||
Available types: `agent_start`, `agent_end`, `tool_execution_start`, `tool_execution_end`,
|
||||
`tool_execution_update`, `extension_ui_request`, `message_start`, `message_end`,
|
||||
`message_update`, `turn_start`, `turn_end`, `cost_update`, `execution_complete`.
|
||||
</event_streaming>
|
||||
|
||||
<all_commands>
|
||||
| Command | Purpose |
|
||||
|---------|---------|
|
||||
| `auto` | Run all queued units until milestone complete or blocked (default) |
|
||||
| `next` | Run exactly one unit, then exit |
|
||||
| `query` | Instant JSON snapshot — state, next dispatch, costs (no LLM, ~50ms) |
|
||||
| `new-milestone` | Create milestone from spec file |
|
||||
| `dispatch <phase>` | Force specific phase (research, plan, execute, complete, reassess, uat, replan) |
|
||||
| `stop` / `pause` | Control auto-mode |
|
||||
| `steer <desc>` | Hard-steer plan mid-execution |
|
||||
| `skip` / `undo` | Unit control |
|
||||
| `queue` | Queue/reorder milestones |
|
||||
| `history` | View execution history |
|
||||
| `doctor` | Health check + auto-fix |
|
||||
| `knowledge <rule>` | Add persistent project knowledge |
|
||||
|
||||
See `references/commands.md` for the complete reference.
|
||||
</all_commands>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export function validateConfig(raw: unknown): DaemonConfig {
|
|||
token: typeof d['token'] === 'string' ? d['token'] : '',
|
||||
guild_id: typeof d['guild_id'] === 'string' ? d['guild_id'] : '',
|
||||
owner_id: typeof d['owner_id'] === 'string' ? d['owner_id'] : '',
|
||||
...(typeof d['dm_on_blocker'] === 'boolean' ? { dm_on_blocker: d['dm_on_blocker'] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
402
packages/daemon/src/event-formatter.test.ts
Normal file
402
packages/daemon/src/event-formatter.test.ts
Normal file
|
|
@ -0,0 +1,402 @@
|
|||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder } from 'discord.js';
|
||||
import type { SdkAgentEvent } from '@gsd-build/rpc-client';
|
||||
import type { PendingBlocker, FormattedEvent } from './types.js';
|
||||
import type { RpcExtensionUIRequest } from '@gsd-build/rpc-client';
|
||||
import {
|
||||
formatToolStart,
|
||||
formatToolEnd,
|
||||
formatMessage,
|
||||
formatBlocker,
|
||||
formatCompletion,
|
||||
formatError,
|
||||
formatCostUpdate,
|
||||
formatSessionStarted,
|
||||
formatTaskTransition,
|
||||
formatGenericEvent,
|
||||
formatEvent,
|
||||
} from './event-formatter.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function embedColor(fe: FormattedEvent): number | null {
|
||||
return fe.embed?.data.color ?? null;
|
||||
}
|
||||
|
||||
function embedTitle(fe: FormattedEvent): string | undefined {
|
||||
return fe.embed?.data.title;
|
||||
}
|
||||
|
||||
function embedDescription(fe: FormattedEvent): string | undefined {
|
||||
return fe.embed?.data.description;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatToolStart
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatToolStart', () => {
|
||||
it('produces grey embed with tool name', () => {
|
||||
const result = formatToolStart({ type: 'tool_execution_start', name: 'read_file' });
|
||||
assert.ok(result.content.includes('read_file'));
|
||||
assert.equal(embedColor(result), 0x95a5a6); // grey
|
||||
assert.ok(embedTitle(result)?.includes('read_file'));
|
||||
});
|
||||
|
||||
it('handles missing name gracefully', () => {
|
||||
const result = formatToolStart({ type: 'tool_execution_start' });
|
||||
assert.ok(result.content.includes('unknown'));
|
||||
});
|
||||
|
||||
it('includes input in description when present', () => {
|
||||
const result = formatToolStart({ type: 'tool_execution_start', name: 'bash', input: 'ls -la' });
|
||||
assert.ok(embedDescription(result)?.includes('ls -la'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatToolEnd
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatToolEnd', () => {
|
||||
it('shows success icon for normal completion', () => {
|
||||
const result = formatToolEnd({ type: 'tool_execution_end', name: 'read_file', output: 'done' });
|
||||
assert.ok(result.content.includes('✅'));
|
||||
assert.equal(embedColor(result), 0x95a5a6); // grey
|
||||
});
|
||||
|
||||
it('shows error icon and red color for errored tool', () => {
|
||||
const result = formatToolEnd({ type: 'tool_execution_end', name: 'bash', isError: true });
|
||||
assert.ok(result.content.includes('❌'));
|
||||
assert.equal(embedColor(result), 0xe74c3c); // red
|
||||
});
|
||||
|
||||
it('includes duration when present', () => {
|
||||
const result = formatToolEnd({ type: 'tool_execution_end', name: 'bash', duration: 3500 });
|
||||
assert.ok(result.embed?.data.footer?.text?.includes('3.5s'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatMessage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatMessage', () => {
|
||||
it('extracts text from content blocks', () => {
|
||||
const result = formatMessage({
|
||||
type: 'message',
|
||||
content: [{ type: 'text', text: 'Hello world' }],
|
||||
});
|
||||
assert.ok(embedDescription(result)?.includes('Hello world'));
|
||||
assert.equal(embedColor(result), 0x3498db); // blue
|
||||
});
|
||||
|
||||
it('falls back to message field when content is a string', () => {
|
||||
const result = formatMessage({ type: 'message', message: 'plain text' });
|
||||
assert.ok(embedDescription(result)?.includes('plain text'));
|
||||
});
|
||||
|
||||
it('handles empty content blocks', () => {
|
||||
const result = formatMessage({ type: 'message', content: [] });
|
||||
assert.ok(result.content.includes('empty message'));
|
||||
assert.equal(result.embed, undefined);
|
||||
});
|
||||
|
||||
it('handles null content gracefully', () => {
|
||||
const result = formatMessage({ type: 'message' });
|
||||
assert.ok(result.content.includes('empty message'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatBlocker — select
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatBlocker', () => {
|
||||
it('produces ActionRow with numbered buttons for select', () => {
|
||||
const blocker: PendingBlocker = {
|
||||
id: 'req-1',
|
||||
method: 'select',
|
||||
message: 'Choose an option',
|
||||
event: {
|
||||
type: 'extension_ui_request',
|
||||
id: 'req-1',
|
||||
method: 'select',
|
||||
title: 'Choose',
|
||||
options: ['Option A', 'Option B', 'Option C'],
|
||||
},
|
||||
};
|
||||
|
||||
const result = formatBlocker(blocker, '12345');
|
||||
assert.ok(result.content.includes('<@12345>'));
|
||||
assert.equal(embedColor(result), 0xf1c40f); // yellow
|
||||
assert.ok(result.components);
|
||||
assert.ok(result.components!.length > 0);
|
||||
|
||||
// Check buttons
|
||||
const row = result.components![0];
|
||||
const buttons = row.components;
|
||||
assert.equal(buttons.length, 3);
|
||||
});
|
||||
|
||||
it('handles empty options array for select', () => {
|
||||
const blocker: PendingBlocker = {
|
||||
id: 'req-2',
|
||||
method: 'select',
|
||||
message: 'Pick one',
|
||||
event: {
|
||||
type: 'extension_ui_request',
|
||||
id: 'req-2',
|
||||
method: 'select',
|
||||
title: 'Pick',
|
||||
options: [],
|
||||
},
|
||||
};
|
||||
|
||||
const result = formatBlocker(blocker, '12345');
|
||||
// No components when no options
|
||||
assert.equal(result.components, undefined);
|
||||
// Embed should show 'No options'
|
||||
const fields = result.embed?.data.fields;
|
||||
assert.ok(fields?.some((f) => f.value.includes('No options')));
|
||||
});
|
||||
|
||||
it('produces Yes/No buttons for confirm', () => {
|
||||
const blocker: PendingBlocker = {
|
||||
id: 'req-3',
|
||||
method: 'confirm',
|
||||
message: 'Are you sure?',
|
||||
event: {
|
||||
type: 'extension_ui_request',
|
||||
id: 'req-3',
|
||||
method: 'confirm',
|
||||
title: 'Confirm',
|
||||
message: 'This will delete everything',
|
||||
},
|
||||
};
|
||||
|
||||
const result = formatBlocker(blocker, '99999');
|
||||
assert.ok(result.components);
|
||||
assert.equal(result.components!.length, 1);
|
||||
const buttons = result.components![0].components;
|
||||
assert.equal(buttons.length, 2);
|
||||
});
|
||||
|
||||
it('produces text instructions for input method', () => {
|
||||
const blocker: PendingBlocker = {
|
||||
id: 'req-4',
|
||||
method: 'input',
|
||||
message: 'Enter your name',
|
||||
event: {
|
||||
type: 'extension_ui_request',
|
||||
id: 'req-4',
|
||||
method: 'input',
|
||||
title: 'Name',
|
||||
placeholder: 'John Doe',
|
||||
},
|
||||
};
|
||||
|
||||
const result = formatBlocker(blocker, '12345');
|
||||
// No interactive buttons for input — text instructions only
|
||||
assert.equal(result.components, undefined);
|
||||
const fields = result.embed?.data.fields;
|
||||
assert.ok(fields?.some((f) => f.value.includes('Reply in this channel')));
|
||||
});
|
||||
|
||||
it('produces text instructions for editor method', () => {
|
||||
const blocker: PendingBlocker = {
|
||||
id: 'req-5',
|
||||
method: 'editor',
|
||||
message: 'Edit the config',
|
||||
event: {
|
||||
type: 'extension_ui_request',
|
||||
id: 'req-5',
|
||||
method: 'editor',
|
||||
title: 'Config',
|
||||
prefill: 'key: value',
|
||||
},
|
||||
};
|
||||
|
||||
const result = formatBlocker(blocker, '12345');
|
||||
assert.equal(result.components, undefined);
|
||||
const fields = result.embed?.data.fields;
|
||||
assert.ok(fields?.some((f) => f.value.includes('Reply in this channel')));
|
||||
assert.ok(fields?.some((f) => f.value.includes('key: value')));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatCompletion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatCompletion', () => {
|
||||
it('shows green for completed', () => {
|
||||
const result = formatCompletion({ type: 'execution_complete', status: 'completed' });
|
||||
assert.equal(embedColor(result), 0x2ecc71); // green
|
||||
assert.ok(result.content.includes('🏁'));
|
||||
});
|
||||
|
||||
it('shows red for error status', () => {
|
||||
const result = formatCompletion({
|
||||
type: 'execution_complete',
|
||||
status: 'error',
|
||||
reason: 'Out of tokens',
|
||||
});
|
||||
assert.equal(embedColor(result), 0xe74c3c); // red
|
||||
assert.ok(embedDescription(result)?.includes('Out of tokens'));
|
||||
});
|
||||
|
||||
it('includes stats when present', () => {
|
||||
const result = formatCompletion({
|
||||
type: 'execution_complete',
|
||||
status: 'completed',
|
||||
stats: { cost: 0.42, tokens: { total: 10000 } },
|
||||
});
|
||||
const fields = result.embed?.data.fields;
|
||||
assert.ok(fields?.some((f) => f.value.includes('$0.42')));
|
||||
assert.ok(fields?.some((f) => f.value.includes('10,000')));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatError
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatError', () => {
|
||||
it('includes session ID and error message', () => {
|
||||
const result = formatError('sess-abc', 'Connection refused');
|
||||
assert.equal(embedColor(result), 0xe74c3c); // red
|
||||
assert.ok(embedDescription(result)?.includes('Connection refused'));
|
||||
assert.ok(result.embed?.data.footer?.text?.includes('sess-abc'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatCostUpdate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatCostUpdate', () => {
|
||||
it('formats cumulative cost', () => {
|
||||
const result = formatCostUpdate({
|
||||
type: 'cost_update',
|
||||
cumulativeCost: 1.23,
|
||||
tokens: { input: 5000, output: 2000 },
|
||||
});
|
||||
assert.ok(result.content.includes('$1.23'));
|
||||
assert.equal(embedColor(result), 0x3498db); // blue
|
||||
});
|
||||
|
||||
it('handles zero cost', () => {
|
||||
const result = formatCostUpdate({
|
||||
type: 'cost_update',
|
||||
cumulativeCost: 0,
|
||||
tokens: { input: 0, output: 0 },
|
||||
});
|
||||
assert.ok(result.content.includes('$0.0000'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatSessionStarted
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatSessionStarted', () => {
|
||||
it('includes project name', () => {
|
||||
const result = formatSessionStarted('my-project');
|
||||
assert.ok(result.content.includes('my-project'));
|
||||
assert.ok(embedDescription(result)?.includes('my-project'));
|
||||
assert.equal(embedColor(result), 0x3498db); // blue
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatTaskTransition
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatTaskTransition', () => {
|
||||
it('shows complete icon for completed tasks', () => {
|
||||
const result = formatTaskTransition({
|
||||
type: 'task_transition',
|
||||
taskId: 'T01',
|
||||
sliceId: 'S01',
|
||||
status: 'complete',
|
||||
});
|
||||
assert.ok(result.content.includes('✅'));
|
||||
assert.equal(embedColor(result), 0x2ecc71); // green
|
||||
});
|
||||
|
||||
it('shows error icon for errored tasks', () => {
|
||||
const result = formatTaskTransition({
|
||||
type: 'task_transition',
|
||||
taskId: 'T02',
|
||||
status: 'error',
|
||||
});
|
||||
assert.ok(result.content.includes('❌'));
|
||||
assert.equal(embedColor(result), 0xe74c3c); // red
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatGenericEvent
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatGenericEvent', () => {
|
||||
it('renders unknown event type as grey embed', () => {
|
||||
const result = formatGenericEvent({ type: 'some_custom_event', data: 'hello' });
|
||||
assert.equal(embedColor(result), 0x95a5a6); // grey
|
||||
assert.ok(embedTitle(result)?.includes('some_custom_event'));
|
||||
});
|
||||
|
||||
it('handles events with no extra fields', () => {
|
||||
const result = formatGenericEvent({ type: 'bare_event' });
|
||||
assert.ok(result.content.includes('bare_event'));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// formatEvent — dispatch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('formatEvent', () => {
|
||||
it('dispatches tool_execution_start', () => {
|
||||
const result = formatEvent({ type: 'tool_execution_start', name: 'read' });
|
||||
assert.ok(result.content.includes('🔧'));
|
||||
});
|
||||
|
||||
it('dispatches execution_complete', () => {
|
||||
const result = formatEvent({ type: 'execution_complete', status: 'completed' });
|
||||
assert.ok(result.content.includes('🏁'));
|
||||
});
|
||||
|
||||
it('falls back to generic for unknown types', () => {
|
||||
const result = formatEvent({ type: 'totally_unknown' });
|
||||
assert.ok(result.content.includes('📡'));
|
||||
});
|
||||
|
||||
it('dispatches cost_update', () => {
|
||||
const result = formatEvent({ type: 'cost_update', cumulativeCost: 0.5 });
|
||||
assert.ok(result.content.includes('💰'));
|
||||
});
|
||||
|
||||
it('dispatches message types', () => {
|
||||
for (const type of ['message_start', 'message_end', 'message']) {
|
||||
const result = formatEvent({ type, message: 'hi' });
|
||||
assert.ok(result.content.includes('💬'), `Failed for type: ${type}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Negative: missing type field
|
||||
it('handles event with missing type gracefully', () => {
|
||||
const result = formatEvent({} as SdkAgentEvent);
|
||||
assert.ok(result.content); // should not throw
|
||||
});
|
||||
|
||||
// Negative: null fields
|
||||
it('handles event with null fields gracefully', () => {
|
||||
const result = formatEvent({ type: 'tool_execution_start', name: null } as unknown as SdkAgentEvent);
|
||||
assert.ok(result.content);
|
||||
});
|
||||
});
|
||||
386
packages/daemon/src/event-formatter.ts
Normal file
386
packages/daemon/src/event-formatter.ts
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
/**
|
||||
* event-formatter.ts — Pure functions mapping RPC event types to Discord embeds.
|
||||
*
|
||||
* Each formatter returns a FormattedEvent (content string + optional EmbedBuilder +
|
||||
* optional ActionRow components). Distinct embed colors per category:
|
||||
* green = success / completion
|
||||
* red = error
|
||||
* yellow = blocker (needs attention)
|
||||
* blue = info / session lifecycle
|
||||
* grey = tool / generic
|
||||
*/
|
||||
|
||||
import { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js';
|
||||
import type { SdkAgentEvent } from '@gsd-build/rpc-client';
|
||||
import type { RpcExtensionUIRequest } from '@gsd-build/rpc-client';
|
||||
import type { FormattedEvent, PendingBlocker } from './types.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Color palette
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const COLOR = {
|
||||
success: 0x2ecc71, // green
|
||||
error: 0xe74c3c, // red
|
||||
blocker: 0xf1c40f, // yellow
|
||||
info: 0x3498db, // blue
|
||||
tool: 0x95a5a6, // grey
|
||||
} as const;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Truncate a string to maxLen, appending ellipsis if truncated. */
|
||||
function truncate(s: string, maxLen: number): string {
|
||||
if (s.length <= maxLen) return s;
|
||||
return s.slice(0, maxLen - 1) + '…';
|
||||
}
|
||||
|
||||
/** Safe string extraction from an unknown field. */
|
||||
function str(value: unknown, fallback = ''): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (value == null) return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
/** Safe number extraction. */
|
||||
function num(value: unknown, fallback = 0): number {
|
||||
if (typeof value === 'number' && !Number.isNaN(value)) return value;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
/** Format a cost value to a readable string. */
|
||||
function formatCost(cost: number): string {
|
||||
if (cost < 0.01) return `$${cost.toFixed(4)}`;
|
||||
return `$${cost.toFixed(2)}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formatToolStart(event: SdkAgentEvent): FormattedEvent {
|
||||
const toolName = str(event.name || event.toolName, 'unknown');
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(COLOR.tool)
|
||||
.setTitle(`🔧 ${truncate(toolName, 60)}`)
|
||||
.setTimestamp();
|
||||
|
||||
const input = str(event.input || event.args);
|
||||
if (input) {
|
||||
embed.setDescription(`\`\`\`\n${truncate(input, 300)}\n\`\`\``);
|
||||
}
|
||||
|
||||
return { content: `🔧 Tool: ${toolName}`, embed };
|
||||
}
|
||||
|
||||
export function formatToolEnd(event: SdkAgentEvent): FormattedEvent {
|
||||
const toolName = str(event.name || event.toolName, 'unknown');
|
||||
const isError = event.isError === true || event.error != null;
|
||||
const color = isError ? COLOR.error : COLOR.tool;
|
||||
const icon = isError ? '❌' : '✅';
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(color)
|
||||
.setTitle(`${icon} ${truncate(toolName, 60)}`)
|
||||
.setTimestamp();
|
||||
|
||||
const output = str(event.output || event.result);
|
||||
if (output) {
|
||||
embed.setDescription(`\`\`\`\n${truncate(output, 300)}\n\`\`\``);
|
||||
}
|
||||
|
||||
const duration = num(event.duration || event.durationMs);
|
||||
if (duration > 0) {
|
||||
embed.setFooter({ text: `${(duration / 1000).toFixed(1)}s` });
|
||||
}
|
||||
|
||||
return { content: `${icon} Tool done: ${toolName}`, embed };
|
||||
}
|
||||
|
||||
export function formatMessage(event: SdkAgentEvent): FormattedEvent {
|
||||
// Extract text from content blocks or message field
|
||||
let text = '';
|
||||
if (Array.isArray(event.content)) {
|
||||
const blocks = event.content as Array<{ type?: string; text?: string }>;
|
||||
text = blocks
|
||||
.filter((b) => b.type === 'text' && typeof b.text === 'string')
|
||||
.map((b) => b.text!)
|
||||
.join('\n');
|
||||
} else {
|
||||
text = str(event.message || event.text || event.content);
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return { content: '💬 (empty message)' };
|
||||
}
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(COLOR.info)
|
||||
.setDescription(truncate(text, 2000))
|
||||
.setTimestamp();
|
||||
|
||||
const role = str(event.role);
|
||||
if (role) {
|
||||
embed.setAuthor({ name: role });
|
||||
}
|
||||
|
||||
return { content: `💬 ${truncate(text, 200)}`, embed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a blocker (extension_ui_request needing user response).
|
||||
* Produces an embed with @mention and interactive buttons for select/confirm,
|
||||
* or text instructions for input/editor.
|
||||
*/
|
||||
export function formatBlocker(
|
||||
blocker: PendingBlocker,
|
||||
ownerId: string,
|
||||
): FormattedEvent {
|
||||
const mention = `<@${ownerId}>`;
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(COLOR.blocker)
|
||||
.setTitle('⚠️ Blocker — Response Needed')
|
||||
.setDescription(truncate(blocker.message, 2000))
|
||||
.setTimestamp();
|
||||
|
||||
const components: ActionRowBuilder<ButtonBuilder>[] = [];
|
||||
|
||||
switch (blocker.method) {
|
||||
case 'select': {
|
||||
const evt = blocker.event as { options?: string[] };
|
||||
const options = Array.isArray(evt.options) ? evt.options : [];
|
||||
|
||||
if (options.length > 0) {
|
||||
// Discord ActionRow max 5 buttons, so chunk
|
||||
const chunks = chunkArray(options.slice(0, 25), 5);
|
||||
for (const chunk of chunks) {
|
||||
const row = new ActionRowBuilder<ButtonBuilder>();
|
||||
chunk.forEach((opt, i) => {
|
||||
const globalIndex = options.indexOf(opt);
|
||||
row.addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`blocker:${blocker.id}:select:${globalIndex}`)
|
||||
.setLabel(truncate(`${globalIndex + 1}. ${opt}`, 80))
|
||||
.setStyle(ButtonStyle.Primary),
|
||||
);
|
||||
});
|
||||
components.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
embed.addFields({
|
||||
name: 'Options',
|
||||
value: options.map((o, i) => `**${i + 1}.** ${truncate(o, 100)}`).join('\n') || 'No options',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'confirm': {
|
||||
const row = new ActionRowBuilder<ButtonBuilder>().addComponents(
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`blocker:${blocker.id}:confirm:true`)
|
||||
.setLabel('Yes')
|
||||
.setStyle(ButtonStyle.Success),
|
||||
new ButtonBuilder()
|
||||
.setCustomId(`blocker:${blocker.id}:confirm:false`)
|
||||
.setLabel('No')
|
||||
.setStyle(ButtonStyle.Danger),
|
||||
);
|
||||
components.push(row);
|
||||
|
||||
const msg = str((blocker.event as { message?: string }).message);
|
||||
if (msg) {
|
||||
embed.addFields({ name: 'Details', value: truncate(msg, 1024) });
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'input': {
|
||||
const placeholder = str((blocker.event as { placeholder?: string }).placeholder);
|
||||
embed.addFields({
|
||||
name: 'How to respond',
|
||||
value: `Reply in this channel with your answer.${placeholder ? `\n*Hint: ${placeholder}*` : ''}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'editor': {
|
||||
const prefill = str((blocker.event as { prefill?: string }).prefill);
|
||||
embed.addFields({
|
||||
name: 'How to respond',
|
||||
value: 'Reply in this channel with the full text.' +
|
||||
(prefill ? `\n\nCurrent value:\n\`\`\`\n${truncate(prefill, 500)}\n\`\`\`` : ''),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
embed.addFields({
|
||||
name: 'How to respond',
|
||||
value: `Reply in this channel (method: ${blocker.method}).`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
content: `${mention} ⚠️ **Blocker** — ${truncate(blocker.message, 150)}`,
|
||||
embed,
|
||||
components: components.length > 0 ? components : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatCompletion(event: SdkAgentEvent): FormattedEvent {
|
||||
const status = str(event.status, 'completed');
|
||||
const isError = status === 'error' || status === 'cancelled';
|
||||
const color = isError ? COLOR.error : COLOR.success;
|
||||
const icon = isError ? '⚠️' : '🏁';
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(color)
|
||||
.setTitle(`${icon} Execution ${status}`)
|
||||
.setTimestamp();
|
||||
|
||||
const reason = str(event.reason);
|
||||
if (reason) {
|
||||
embed.setDescription(truncate(reason, 2000));
|
||||
}
|
||||
|
||||
// Include final stats if present
|
||||
const stats = event.stats as { cost?: number; tokens?: { total?: number } } | undefined;
|
||||
if (stats) {
|
||||
const fields: string[] = [];
|
||||
if (stats.cost != null) fields.push(`Cost: ${formatCost(num(stats.cost))}`);
|
||||
if (stats.tokens?.total != null) fields.push(`Tokens: ${num(stats.tokens.total).toLocaleString()}`);
|
||||
if (fields.length) embed.addFields({ name: 'Summary', value: fields.join(' · ') });
|
||||
}
|
||||
|
||||
return { content: `${icon} Execution ${status}`, embed };
|
||||
}
|
||||
|
||||
export function formatError(sessionId: string, error: string): FormattedEvent {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(COLOR.error)
|
||||
.setTitle('❌ Session Error')
|
||||
.setDescription(`\`\`\`\n${truncate(error, 2000)}\n\`\`\``)
|
||||
.setFooter({ text: `Session: ${sessionId}` })
|
||||
.setTimestamp();
|
||||
|
||||
return { content: `❌ Error: ${truncate(error, 200)}`, embed };
|
||||
}
|
||||
|
||||
export function formatCostUpdate(event: SdkAgentEvent): FormattedEvent {
|
||||
const cost = num(event.cumulativeCost ?? event.totalCost);
|
||||
const tokens = event.tokens as
|
||||
| { input?: number; output?: number; cacheRead?: number; cacheWrite?: number }
|
||||
| undefined;
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(COLOR.info)
|
||||
.setTitle('💰 Cost Update')
|
||||
.setTimestamp();
|
||||
|
||||
const fields: string[] = [`Total: ${formatCost(cost)}`];
|
||||
if (tokens) {
|
||||
const input = num(tokens.input);
|
||||
const output = num(tokens.output);
|
||||
if (input || output) {
|
||||
fields.push(`Tokens: ${input.toLocaleString()} in / ${output.toLocaleString()} out`);
|
||||
}
|
||||
}
|
||||
embed.setDescription(fields.join('\n'));
|
||||
|
||||
return { content: `💰 Cost: ${formatCost(cost)}`, embed };
|
||||
}
|
||||
|
||||
export function formatSessionStarted(projectName: string): FormattedEvent {
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(COLOR.info)
|
||||
.setTitle('🚀 Session Started')
|
||||
.setDescription(`Project: **${truncate(projectName, 200)}**`)
|
||||
.setTimestamp();
|
||||
|
||||
return { content: `🚀 Session started: ${projectName}`, embed };
|
||||
}
|
||||
|
||||
export function formatTaskTransition(event: SdkAgentEvent): FormattedEvent {
|
||||
const taskId = str(event.taskId || event.task);
|
||||
const sliceId = str(event.sliceId || event.slice);
|
||||
const status = str(event.status || event.state);
|
||||
const icon = status === 'complete' ? '✅' : status === 'error' ? '❌' : '📋';
|
||||
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(status === 'complete' ? COLOR.success : status === 'error' ? COLOR.error : COLOR.info)
|
||||
.setTitle(`${icon} Task Transition`)
|
||||
.setTimestamp();
|
||||
|
||||
const fields: string[] = [];
|
||||
if (sliceId) fields.push(`Slice: ${sliceId}`);
|
||||
if (taskId) fields.push(`Task: ${taskId}`);
|
||||
if (status) fields.push(`Status: ${status}`);
|
||||
embed.setDescription(fields.join('\n'));
|
||||
|
||||
return { content: `${icon} ${taskId || 'Task'} → ${status || 'unknown'}`, embed };
|
||||
}
|
||||
|
||||
export function formatGenericEvent(event: SdkAgentEvent): FormattedEvent {
|
||||
const type = str(event.type, 'unknown');
|
||||
const embed = new EmbedBuilder()
|
||||
.setColor(COLOR.tool)
|
||||
.setTitle(`📡 ${truncate(type, 60)}`)
|
||||
.setTimestamp();
|
||||
|
||||
// Include a JSON preview of the event, stripping the type field
|
||||
const { type: _t, ...rest } = event;
|
||||
const preview = JSON.stringify(rest);
|
||||
if (preview.length > 2) { // more than '{}'
|
||||
embed.setDescription(`\`\`\`json\n${truncate(preview, 1000)}\n\`\`\``);
|
||||
}
|
||||
|
||||
return { content: `📡 Event: ${type}`, embed };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dispatch — maps event type to the right formatter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Format any SdkAgentEvent for Discord. Falls back to formatGenericEvent
|
||||
* for unknown types.
|
||||
*/
|
||||
export function formatEvent(event: SdkAgentEvent, ownerId?: string): FormattedEvent {
|
||||
const type = str(event.type);
|
||||
|
||||
switch (type) {
|
||||
case 'tool_execution_start':
|
||||
return formatToolStart(event);
|
||||
case 'tool_execution_end':
|
||||
return formatToolEnd(event);
|
||||
case 'message_start':
|
||||
case 'message_end':
|
||||
case 'message':
|
||||
return formatMessage(event);
|
||||
case 'execution_complete':
|
||||
return formatCompletion(event);
|
||||
case 'cost_update':
|
||||
return formatCostUpdate(event);
|
||||
case 'task_transition':
|
||||
return formatTaskTransition(event);
|
||||
default:
|
||||
return formatGenericEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Utility
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function chunkArray<T>(arr: T[], size: number): T[][] {
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < arr.length; i += size) {
|
||||
chunks.push(arr.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
|
@ -5,6 +5,14 @@ import type { RpcClient, SdkAgentEvent, RpcExtensionUIRequest } from '@gsd-build
|
|||
*/
|
||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
/**
|
||||
* Per-channel verbosity for Discord event streaming.
|
||||
* - 'default': tool calls, messages, transitions, blockers, errors, completions
|
||||
* - 'verbose': everything including cost_update and status events
|
||||
* - 'quiet': only blockers, errors, completions
|
||||
*/
|
||||
export type VerbosityLevel = 'default' | 'verbose' | 'quiet';
|
||||
|
||||
/**
|
||||
* A single structured log entry written as JSON-lines.
|
||||
*/
|
||||
|
|
@ -24,6 +32,8 @@ export interface DaemonConfig {
|
|||
token: string;
|
||||
guild_id: string;
|
||||
owner_id: string;
|
||||
/** When true, DM the owner on blocker events in addition to channel messages */
|
||||
dm_on_blocker?: boolean;
|
||||
};
|
||||
projects: {
|
||||
scan_roots: string[];
|
||||
|
|
@ -157,6 +167,20 @@ export interface StartSessionOptions {
|
|||
cliPath?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Formatted Event — output of event-formatter.ts
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Formatted Discord message payload for a GSD event.
|
||||
* content is the plain-text fallback; embeds and components are optional.
|
||||
*/
|
||||
export interface FormattedEvent {
|
||||
content: string;
|
||||
embed?: import('discord.js').EmbedBuilder;
|
||||
components?: import('discord.js').ActionRowBuilder<import('discord.js').ButtonBuilder>[];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
171
packages/daemon/src/verbosity.test.ts
Normal file
171
packages/daemon/src/verbosity.test.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { describe, it, beforeEach } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { VerbosityManager, shouldShowAtLevel } from './verbosity.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VerbosityManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('VerbosityManager', () => {
|
||||
let vm: VerbosityManager;
|
||||
|
||||
beforeEach(() => {
|
||||
vm = new VerbosityManager();
|
||||
});
|
||||
|
||||
it('returns default level for unknown channel', () => {
|
||||
assert.equal(vm.getLevel('chan-1'), 'default');
|
||||
});
|
||||
|
||||
it('set/get round-trips', () => {
|
||||
vm.setLevel('chan-1', 'quiet');
|
||||
assert.equal(vm.getLevel('chan-1'), 'quiet');
|
||||
vm.setLevel('chan-1', 'verbose');
|
||||
assert.equal(vm.getLevel('chan-1'), 'verbose');
|
||||
});
|
||||
|
||||
it('different channels are independent', () => {
|
||||
vm.setLevel('chan-a', 'quiet');
|
||||
vm.setLevel('chan-b', 'verbose');
|
||||
assert.equal(vm.getLevel('chan-a'), 'quiet');
|
||||
assert.equal(vm.getLevel('chan-b'), 'verbose');
|
||||
assert.equal(vm.getLevel('chan-c'), 'default');
|
||||
});
|
||||
|
||||
it('shouldShow delegates to the level-based filter', () => {
|
||||
vm.setLevel('chan-q', 'quiet');
|
||||
assert.equal(vm.shouldShow('chan-q', 'tool_execution_start'), false);
|
||||
assert.equal(vm.shouldShow('chan-q', 'extension_ui_request'), true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shouldShowAtLevel — quiet
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('shouldShowAtLevel — quiet', () => {
|
||||
const level = 'quiet' as const;
|
||||
|
||||
it('shows blockers', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'extension_ui_request'), true);
|
||||
});
|
||||
|
||||
it('shows execution_complete', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'execution_complete'), true);
|
||||
});
|
||||
|
||||
it('shows error', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'error'), true);
|
||||
});
|
||||
|
||||
it('shows session_error', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'session_error'), true);
|
||||
});
|
||||
|
||||
it('hides tool calls', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'tool_execution_start'), false);
|
||||
assert.equal(shouldShowAtLevel(level, 'tool_execution_end'), false);
|
||||
});
|
||||
|
||||
it('hides messages', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'message_start'), false);
|
||||
assert.equal(shouldShowAtLevel(level, 'message'), false);
|
||||
});
|
||||
|
||||
it('hides cost_update', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'cost_update'), false);
|
||||
});
|
||||
|
||||
it('hides task_transition', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'task_transition'), false);
|
||||
});
|
||||
|
||||
it('hides unknown events', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'totally_random'), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shouldShowAtLevel — default
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('shouldShowAtLevel — default', () => {
|
||||
const level = 'default' as const;
|
||||
|
||||
it('shows blockers', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'extension_ui_request'), true);
|
||||
});
|
||||
|
||||
it('shows execution_complete', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'execution_complete'), true);
|
||||
});
|
||||
|
||||
it('shows error', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'error'), true);
|
||||
});
|
||||
|
||||
it('shows tool calls', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'tool_execution_start'), true);
|
||||
assert.equal(shouldShowAtLevel(level, 'tool_execution_end'), true);
|
||||
});
|
||||
|
||||
it('shows messages', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'message_start'), true);
|
||||
assert.equal(shouldShowAtLevel(level, 'message_end'), true);
|
||||
assert.equal(shouldShowAtLevel(level, 'message'), true);
|
||||
});
|
||||
|
||||
it('shows task_transition', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'task_transition'), true);
|
||||
});
|
||||
|
||||
it('shows session_started', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'session_started'), true);
|
||||
});
|
||||
|
||||
it('hides cost_update', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'cost_update'), false);
|
||||
});
|
||||
|
||||
it('hides status events', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'state_update'), false);
|
||||
assert.equal(shouldShowAtLevel(level, 'status'), false);
|
||||
});
|
||||
|
||||
it('hides unknown events', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'something_weird'), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// shouldShowAtLevel — verbose
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('shouldShowAtLevel — verbose', () => {
|
||||
const level = 'verbose' as const;
|
||||
|
||||
it('shows everything that quiet/default show', () => {
|
||||
const events = [
|
||||
'extension_ui_request', 'execution_complete', 'error', 'session_error',
|
||||
'tool_execution_start', 'tool_execution_end', 'message_start', 'message_end',
|
||||
'message', 'task_transition', 'session_started',
|
||||
];
|
||||
for (const e of events) {
|
||||
assert.equal(shouldShowAtLevel(level, e), true, `Expected verbose to show ${e}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('shows cost_update', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'cost_update'), true);
|
||||
});
|
||||
|
||||
it('shows status events', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'state_update'), true);
|
||||
assert.equal(shouldShowAtLevel(level, 'status'), true);
|
||||
assert.equal(shouldShowAtLevel(level, 'set_status'), true);
|
||||
});
|
||||
|
||||
it('shows unknown/arbitrary events', () => {
|
||||
assert.equal(shouldShowAtLevel(level, 'something_arbitrary'), true);
|
||||
});
|
||||
});
|
||||
101
packages/daemon/src/verbosity.ts
Normal file
101
packages/daemon/src/verbosity.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* verbosity.ts — Per-channel verbosity filter for Discord event streaming.
|
||||
*
|
||||
* Controls which RPC event types reach each Discord channel.
|
||||
* Three levels:
|
||||
* - 'quiet': blockers, errors, completions only
|
||||
* - 'default': tool calls, messages, transitions, blockers, errors, completions
|
||||
* - 'verbose': everything (adds cost_update, status, generic events)
|
||||
*/
|
||||
|
||||
import type { VerbosityLevel } from './types.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Event classification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Event types that are always shown (even in quiet mode). */
|
||||
const ALWAYS_SHOWN: ReadonlySet<string> = new Set([
|
||||
'extension_ui_request', // blockers
|
||||
'execution_complete',
|
||||
'error',
|
||||
'session_error',
|
||||
]);
|
||||
|
||||
/** Event types shown at default level and above. */
|
||||
const DEFAULT_SHOWN: ReadonlySet<string> = new Set([
|
||||
'tool_execution_start',
|
||||
'tool_execution_end',
|
||||
'message_start',
|
||||
'message_end',
|
||||
'message',
|
||||
'task_transition',
|
||||
'session_started',
|
||||
]);
|
||||
|
||||
/** Event types shown only at verbose level. */
|
||||
const VERBOSE_ONLY: ReadonlySet<string> = new Set([
|
||||
'cost_update',
|
||||
'state_update',
|
||||
'status',
|
||||
'set_status',
|
||||
'set_widget',
|
||||
'set_title',
|
||||
]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VerbosityManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class VerbosityManager {
|
||||
private levels: Map<string, VerbosityLevel> = new Map();
|
||||
|
||||
/** Get the verbosity level for a channel. Defaults to 'default'. */
|
||||
getLevel(channelId: string): VerbosityLevel {
|
||||
return this.levels.get(channelId) ?? 'default';
|
||||
}
|
||||
|
||||
/** Set the verbosity level for a channel. */
|
||||
setLevel(channelId: string, level: VerbosityLevel): void {
|
||||
this.levels.set(channelId, level);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether an event of the given type should be shown
|
||||
* in the specified channel.
|
||||
*/
|
||||
shouldShow(channelId: string, eventType: string): boolean {
|
||||
const level = this.getLevel(channelId);
|
||||
return shouldShowAtLevel(level, eventType);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure filter — exported for direct use and testability
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pure predicate: should an event of this type be shown at the given verbosity level?
|
||||
*/
|
||||
export function shouldShowAtLevel(level: VerbosityLevel, eventType: string): boolean {
|
||||
// Always-shown events pass through regardless of level
|
||||
if (ALWAYS_SHOWN.has(eventType)) return true;
|
||||
|
||||
switch (level) {
|
||||
case 'quiet':
|
||||
// Quiet only shows ALWAYS_SHOWN events
|
||||
return false;
|
||||
|
||||
case 'default':
|
||||
// Default shows ALWAYS_SHOWN + DEFAULT_SHOWN
|
||||
return DEFAULT_SHOWN.has(eventType);
|
||||
|
||||
case 'verbose':
|
||||
// Verbose shows everything
|
||||
return true;
|
||||
|
||||
default:
|
||||
// Unknown level → treat as default
|
||||
return DEFAULT_SHOWN.has(eventType);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue