Skip to content

Commit ef9fe9e

Browse files
florintimbucErickGross-19ZenidX
authored
feat: add Agent Teams visualization (#218)
* feat: add team metadata extraction, token tracking, and teammate lifecycle management * feat: add Agent Teams visualization with role badges, token fuel gauge, and teammate lifecycle * fix: suppress ghost sub-agent characters for team leads * fix: detect tmux vs inline team mode to correctly handle sub-agent characters * fix: persist teamUsesTmux flag * fix: abstraction inline subagents, team agents and hook provider Co-authored-by: ErickGross-19 <72448923+ErickGross-19@users.noreply.github.com> Co-authored-by: ZenidX <111122142+ZenidX@users.noreply.github.com> * refactor: use normalized event fields in hookEventHandler --------- Co-authored-by: ErickGross-19 <72448923+ErickGross-19@users.noreply.github.com> Co-authored-by: ZenidX <111122142+ZenidX@users.noreply.github.com>
1 parent 8d42255 commit ef9fe9e

28 files changed

Lines changed: 2300 additions & 219 deletions

server/__tests__/claude.test.ts

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import { describe, expect, it } from 'vitest';
2+
3+
import { claudeProvider } from '../src/providers/hook/claude/claude.js';
4+
5+
describe('claudeProvider', () => {
6+
describe('identity', () => {
7+
it('has kind "hook"', () => {
8+
expect(claudeProvider.kind).toBe('hook');
9+
});
10+
it('has id "claude"', () => {
11+
expect(claudeProvider.id).toBe('claude');
12+
});
13+
it('has a displayName', () => {
14+
expect(claudeProvider.displayName).toBe('Claude Code');
15+
});
16+
it('has Task and Agent in subagentToolNames', () => {
17+
expect(claudeProvider.subagentToolNames.has('Task')).toBe(true);
18+
expect(claudeProvider.subagentToolNames.has('Agent')).toBe(true);
19+
});
20+
it('has a linked TeamProvider', () => {
21+
expect(claudeProvider.team).toBeDefined();
22+
expect(claudeProvider.team?.providerId).toBe('claude');
23+
});
24+
});
25+
26+
describe('normalizeHookEvent', () => {
27+
it('returns null when hook_event_name is missing', () => {
28+
expect(claudeProvider.normalizeHookEvent({ session_id: 'x' })).toBeNull();
29+
});
30+
it('returns null when session_id is missing', () => {
31+
expect(claudeProvider.normalizeHookEvent({ hook_event_name: 'Stop' })).toBeNull();
32+
});
33+
it('returns null for unknown hook event names', () => {
34+
expect(
35+
claudeProvider.normalizeHookEvent({
36+
hook_event_name: 'SomethingWeird',
37+
session_id: 'x',
38+
}),
39+
).toBeNull();
40+
});
41+
42+
it('normalizes PreToolUse with tool_name + tool_input', () => {
43+
const result = claudeProvider.normalizeHookEvent({
44+
hook_event_name: 'PreToolUse',
45+
session_id: 'sess-1',
46+
tool_name: 'Read',
47+
tool_input: { file_path: '/foo.ts' },
48+
});
49+
expect(result?.sessionId).toBe('sess-1');
50+
expect(result?.event.kind).toBe('toolStart');
51+
if (result?.event.kind === 'toolStart') {
52+
expect(result.event.toolName).toBe('Read');
53+
expect(result.event.toolId.startsWith('hook-')).toBe(true);
54+
expect(result.event.input).toEqual({ file_path: '/foo.ts' });
55+
}
56+
});
57+
58+
it('normalizes PostToolUse to toolEnd with sentinel toolId', () => {
59+
const result = claudeProvider.normalizeHookEvent({
60+
hook_event_name: 'PostToolUse',
61+
session_id: 'sess-1',
62+
});
63+
expect(result?.event.kind).toBe('toolEnd');
64+
});
65+
66+
it('normalizes PostToolUseFailure to toolEnd (same as PostToolUse)', () => {
67+
const result = claudeProvider.normalizeHookEvent({
68+
hook_event_name: 'PostToolUseFailure',
69+
session_id: 'sess-1',
70+
});
71+
expect(result?.event.kind).toBe('toolEnd');
72+
});
73+
74+
it('normalizes Stop to turnEnd', () => {
75+
const result = claudeProvider.normalizeHookEvent({
76+
hook_event_name: 'Stop',
77+
session_id: 'sess-1',
78+
});
79+
expect(result?.event.kind).toBe('turnEnd');
80+
});
81+
82+
it('normalizes UserPromptSubmit to userTurn', () => {
83+
const result = claudeProvider.normalizeHookEvent({
84+
hook_event_name: 'UserPromptSubmit',
85+
session_id: 'sess-1',
86+
});
87+
expect(result?.event.kind).toBe('userTurn');
88+
});
89+
90+
it('normalizes SubagentStart with agent_type as toolName', () => {
91+
const result = claudeProvider.normalizeHookEvent({
92+
hook_event_name: 'SubagentStart',
93+
session_id: 'sess-1',
94+
agent_type: 'web-researcher',
95+
});
96+
expect(result?.event.kind).toBe('subagentStart');
97+
if (result?.event.kind === 'subagentStart') {
98+
expect(result.event.toolName).toBe('web-researcher');
99+
expect(result.event.toolId.startsWith('hook-sub-web-researcher-')).toBe(true);
100+
}
101+
});
102+
103+
it('normalizes SubagentStop to subagentEnd', () => {
104+
const result = claudeProvider.normalizeHookEvent({
105+
hook_event_name: 'SubagentStop',
106+
session_id: 'sess-1',
107+
});
108+
expect(result?.event.kind).toBe('subagentEnd');
109+
});
110+
111+
it('normalizes PermissionRequest to permissionRequest', () => {
112+
const result = claudeProvider.normalizeHookEvent({
113+
hook_event_name: 'PermissionRequest',
114+
session_id: 'sess-1',
115+
});
116+
expect(result?.event.kind).toBe('permissionRequest');
117+
});
118+
119+
it('normalizes Notification(permission_prompt) to permissionRequest', () => {
120+
const result = claudeProvider.normalizeHookEvent({
121+
hook_event_name: 'Notification',
122+
session_id: 'sess-1',
123+
notification_type: 'permission_prompt',
124+
});
125+
expect(result?.event.kind).toBe('permissionRequest');
126+
});
127+
128+
it('normalizes Notification(idle_prompt) to turnEnd', () => {
129+
const result = claudeProvider.normalizeHookEvent({
130+
hook_event_name: 'Notification',
131+
session_id: 'sess-1',
132+
notification_type: 'idle_prompt',
133+
});
134+
expect(result?.event.kind).toBe('turnEnd');
135+
});
136+
137+
it('returns null for Notification with unknown type', () => {
138+
expect(
139+
claudeProvider.normalizeHookEvent({
140+
hook_event_name: 'Notification',
141+
session_id: 'sess-1',
142+
notification_type: 'other',
143+
}),
144+
).toBeNull();
145+
});
146+
147+
it('normalizes SessionStart with source', () => {
148+
const result = claudeProvider.normalizeHookEvent({
149+
hook_event_name: 'SessionStart',
150+
session_id: 'sess-1',
151+
source: 'startup',
152+
});
153+
expect(result?.event.kind).toBe('sessionStart');
154+
if (result?.event.kind === 'sessionStart') {
155+
expect(result.event.source).toBe('startup');
156+
}
157+
});
158+
159+
it('normalizes SessionEnd with reason', () => {
160+
const result = claudeProvider.normalizeHookEvent({
161+
hook_event_name: 'SessionEnd',
162+
session_id: 'sess-1',
163+
reason: 'clear',
164+
});
165+
expect(result?.event.kind).toBe('sessionEnd');
166+
if (result?.event.kind === 'sessionEnd') {
167+
expect(result.event.reason).toBe('clear');
168+
}
169+
});
170+
171+
it('normalizes TeammateIdle to subagentTurnEnd', () => {
172+
const result = claudeProvider.normalizeHookEvent({
173+
hook_event_name: 'TeammateIdle',
174+
session_id: 'sess-1',
175+
agent_type: 'web-researcher',
176+
});
177+
expect(result?.event.kind).toBe('subagentTurnEnd');
178+
});
179+
180+
it('normalizes TaskCompleted to subagentTurnEnd', () => {
181+
const result = claudeProvider.normalizeHookEvent({
182+
hook_event_name: 'TaskCompleted',
183+
session_id: 'sess-1',
184+
subject: 'Code review',
185+
});
186+
expect(result?.event.kind).toBe('subagentTurnEnd');
187+
});
188+
189+
it('returns null for TaskCreated (informational only)', () => {
190+
expect(
191+
claudeProvider.normalizeHookEvent({
192+
hook_event_name: 'TaskCreated',
193+
session_id: 'sess-1',
194+
subject: 'Code review',
195+
}),
196+
).toBeNull();
197+
});
198+
});
199+
200+
describe('formatToolStatus', () => {
201+
it('formats Read', () => {
202+
expect(claudeProvider.formatToolStatus('Read', { file_path: '/a/b.ts' })).toBe(
203+
'Reading b.ts',
204+
);
205+
});
206+
it('formats Task/Agent with description', () => {
207+
expect(claudeProvider.formatToolStatus('Task', { description: 'Code review' })).toBe(
208+
'Subtask: Code review',
209+
);
210+
expect(claudeProvider.formatToolStatus('Agent', { description: 'Research' })).toBe(
211+
'Subtask: Research',
212+
);
213+
});
214+
it('falls back to "Using X" for unknown tools', () => {
215+
expect(claudeProvider.formatToolStatus('FancyTool', {})).toBe('Using FancyTool');
216+
});
217+
it('handles undefined input', () => {
218+
expect(claudeProvider.formatToolStatus('Read', undefined)).toBe('Reading ');
219+
});
220+
});
221+
});

server/__tests__/claudeHookInstaller.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ vi.mock('os', async () => {
1111
});
1212

1313
const { areHooksInstalled, installHooks, uninstallHooks, copyHookScript } =
14-
await import('../src/providers/file/claudeHookInstaller.js');
14+
await import('../src/providers/hook/claude/claudeHookInstaller.js');
1515

1616
function readSettings(): Record<string, unknown> {
1717
const p = path.join(tmpBase, '.claude', 'settings.json');

0 commit comments

Comments
 (0)