Skip to content
Open
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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -243,3 +243,7 @@ download.xml
# ships with a placeholder app id on purpose; a built package carries a real
# tenant's, and this directory is where people naturally build one.
docs/bridges/teams-app/*.zip

# Local-only tooling and agent worktrees
internal/
.claude/worktrees/
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ class AgentHookService implements IInitializable, IDisposable, Hookable<AgentHoo
return;
}

if (parsed.kind === 'subagent') {
// Carries both surfaces of the hook — the activity line and the live
// subagent list — so the turn is reported once rather than twice.
switchNotificationPoller.onSubagent(parsed.ctx.sessionId, parsed);
return;
}

const event = parsed.event;
const appFocused = isAppFocused();
await maybeShowNotification(event, appFocused);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import type { RawHookRequest } from './hook-server';

const ctx: AgentHookContext = {
sessionId: 'session-1',
providerId: 'claude-code',
ptyId: 'claude-code::session-1',
providerId: 'claude',
ptyId: 'claude::session-1',
};

const fixedResolver: ContextResolver = async () => ctx;
Expand Down Expand Up @@ -238,6 +238,75 @@ describe('parseHookEvent', () => {
);
});

/**
* The Claude connector's SubagentStart / SubagentStop hooks. They are the only
* signal that a delegation started or finished — the Agent tool call itself
* names no target — so both the activity line and the live subagent list are
* derived from them.
*/
it('parses a SubagentStart hook into a subagent event', async () => {
const parsed = await parseHookEvent(
raw('subagent', { agent_id: 'sub-1', agent_type: 'Explore' }),
fixedResolver,
log
);

expect(parsed).toEqual({
kind: 'subagent',
ctx,
agentId: 'sub-1',
agentName: 'Explore',
finished: false,
detail: '_Delegating to_ `Explore`',
});
});

it('parses a SubagentStop hook as finished', async () => {
const parsed = await parseHookEvent(
raw('subagent-done', { agent_id: 'sub-1', agent_type: 'Explore' }),
fixedResolver,
log
);

expect(parsed).toEqual({
kind: 'subagent',
ctx,
agentId: 'sub-1',
agentName: 'Explore',
finished: true,
detail: '_Subagent_ `Explore` _finished_',
});
});

it('falls back to the agent type, then to a placeholder, for an unnamed subagent', async () => {
const typed = await parseHookEvent(
raw('subagent', { agent_type: 'Explore' }),
fixedResolver,
log
);
expect(typed).toMatchObject({ kind: 'subagent', agentId: 'Explore', agentName: 'Explore' });

const bare = await parseHookEvent(raw('subagent', {}), fixedResolver, log);
expect(bare).toMatchObject({
kind: 'subagent',
agentId: 'subagent',
agentName: 'subagent',
detail: '_Delegating to a subagent_',
});
});

it('leaves an Agent tool call alone — it names no subagent', async () => {
// The Agent tool's input carries no id for the subagent it starts, so a
// tool-use hook must not be read as one.
const parsed = await parseHookEvent(
raw('tool-use', { tool_name: 'Task', tool_input: { subagent_type: 'Explore' } }),
fixedResolver,
log
);

expect(parsed).toEqual({ kind: 'ignore' });
});

it('throws when the context resolver cannot resolve the ptyId', async () => {
const nullResolver: ContextResolver = async () => null;
await expect(parseHookEvent(raw('Stop', {}), nullResolver, log)).rejects.toThrow(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,14 @@ export type ParsedHookEvent =
agentId: string;
roomName: string | null;
}
| {
kind: 'subagent';
ctx: AgentHookContext;
agentId: string;
agentName: string;
finished: boolean;
detail: string;
}
| { kind: 'ignore' };

/**
Expand Down Expand Up @@ -57,6 +65,18 @@ export interface HookEventLogger {
*/
const SWITCH_ROOM_CONNECT_EVENT = 'switch_room_connect';

/**
* Event types the Claude connector's `SubagentStart` / `SubagentStop` hooks
* report. Their body carries `agent_id` and `agent_type`; no other provider
* emits them.
*/
const SUBAGENT_START_EVENT = 'subagent';
const SUBAGENT_DONE_EVENT = 'subagent-done';

function isSubagentEvent(type: string): boolean {
return type === SUBAGENT_START_EVENT || type === SUBAGENT_DONE_EVENT;
}

/** The value as a plain object, or null for anything else. */
function asRecord(value: unknown): Record<string, unknown> | null {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
Expand Down Expand Up @@ -197,6 +217,19 @@ export async function parseHookEvent(
const parser = plugin?.behavior.hooks?.parseHookEvent ?? defaultHookEventParser;
const canonical = parser(raw.type, body);

if (isSubagentEvent(raw.type) && canonical.kind === 'activity') {
const agentType = typeof body.agent_type === 'string' ? body.agent_type.trim() : '';
const agentId = typeof body.agent_id === 'string' ? body.agent_id.trim() : '';
return {
kind: 'subagent',
ctx,
agentId: agentId || agentType || 'subagent',
agentName: agentType || 'subagent',
finished: raw.type === SUBAGENT_DONE_EVENT,
detail: canonical.detail,
};
}

if (canonical.kind === 'ignore') return { kind: 'ignore' };

if (canonical.kind === 'session') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ function runtimeDetails(fetchMock: ReturnType<typeof makeFetch>): (string | null
.map((c) => JSON.parse((c[1] as RequestInit).body as string).detail);
}

/** The `active_subagents` list carried on each runtime-state post. */
function runtimeSubagents(fetchMock: ReturnType<typeof makeFetch>): unknown[][] {
return fetchMock.mock.calls
.filter((c) => String(c[0]).includes('/runtime-state'))
.map((c) => JSON.parse((c[1] as RequestInit).body as string).active_subagents);
}

function runtimeAnchors(fetchMock: ReturnType<typeof makeFetch>): (string | null)[] {
return fetchMock.mock.calls
.filter((c) => String(c[0]).includes('/runtime-state'))
Expand Down Expand Up @@ -749,6 +756,137 @@ describe('RoomConnection', () => {
conn.stop();
});

/**
* Subagents a session delegates to (CHOO-2555). Claude Code's
* SubagentStart/SubagentStop hooks are the only signal that one is live, and
* each hook carries both surfaces — the activity line and the list — so it
* reports the turn once rather than twice.
*/
it('reports a spawned subagent in one post, and drops it in one more', async () => {
const target: InjectionTarget = { write: vi.fn() };
const { conn, fetchMock } = connect({ acquire: () => target }, [messageEvent(true)]);
await flush();
const before = runtimeSubagents(fetchMock).length;

conn.reportSubagent({
agentId: 'sub-1',
agentName: 'Explore',
finished: false,
detail: '_Delegating to_ `Explore`',
});
await flush();

expect(runtimeSubagents(fetchMock).length).toBe(before + 1);
expect(runtimeSubagents(fetchMock).at(-1)).toEqual([
{ agent_id: 'sub-1', agent_name: 'Explore', state: 'working', detail: null },
]);
expect(runtimeDetails(fetchMock).at(-1)).toMatch(/^_Delegating to_ `Explore`/);

conn.reportSubagent({
agentId: 'sub-1',
agentName: 'Explore',
finished: true,
detail: '_Subagent_ `Explore` _finished_',
});
await flush();

expect(runtimeSubagents(fetchMock).length).toBe(before + 2);
expect(runtimeSubagents(fetchMock).at(-1)).toEqual([]);
expect(runtimeDetails(fetchMock).at(-1)).toMatch(/^_Subagent_ `Explore` _finished_/);
conn.stop();
});

it('reports every live subagent, oldest first', async () => {
const target: InjectionTarget = { write: vi.fn() };
const { conn, fetchMock } = connect({ acquire: () => target }, [messageEvent(true)]);
await flush();

conn.reportSubagent({
agentId: 'sub-1',
agentName: 'Explore',
finished: false,
detail: '_Delegating to_ `Explore`',
});
conn.reportSubagent({
agentId: 'sub-2',
agentName: 'Plan',
finished: false,
detail: '_Delegating to_ `Plan`',
});
await flush();

expect(runtimeSubagents(fetchMock).at(-1)).toEqual([
{ agent_id: 'sub-1', agent_name: 'Explore', state: 'working', detail: null },
{ agent_id: 'sub-2', agent_name: 'Plan', state: 'working', detail: null },
]);
conn.stop();
});

it('clears the oldest live subagent on finish, even when the id does not match its start', async () => {
// Claude Code does not report the same `agent_id` on a named subagent's
// SubagentStart and its matching SubagentStop — observed against a real
// session: Stop carried a different id than Start, and no agent_type at
// all. Matching by id would silently never clear the entry.
const target: InjectionTarget = { write: vi.fn() };
const { conn, fetchMock } = connect({ acquire: () => target }, [messageEvent(true)]);
await flush();

conn.reportSubagent({
agentId: 'a49d9f4615dafd11c',
agentName: 'Explore',
finished: false,
detail: '_Delegating to_ `Explore`',
});
conn.reportSubagent({
agentId: 'a3afb248729cd8edf',
agentName: 'subagent',
finished: true,
detail: '_Subagent finished_',
});
await flush();

expect(runtimeSubagents(fetchMock).at(-1)).toEqual([]);
conn.stop();
});

it('clears the subagents when the turn ends', async () => {
const target: InjectionTarget = { write: vi.fn() };
const { conn, fetchMock } = connect({ acquire: () => target }, [messageEvent(true)]);
await flush();
conn.reportSubagent({
agentId: 'sub-1',
agentName: 'Explore',
finished: false,
detail: '_Delegating to_ `Explore`',
});
await flush();

conn.onAgentStatusChange('idle');
await flush();

expect(runtimeSubagents(fetchMock).at(-1)).toEqual([]);
conn.stop();
});

it('does not push a subagent outside a working room turn', async () => {
const target: InjectionTarget = { write: vi.fn() };
// No addressed message → no active turn.
const { conn, fetchMock } = connect({ acquire: () => target }, []);
await flush();
const before = runtimeSubagents(fetchMock).length;

conn.reportSubagent({
agentId: 'sub-1',
agentName: 'Explore',
finished: false,
detail: '_Delegating to_ `Explore`',
});
await flush();

expect(runtimeSubagents(fetchMock).length).toBe(before);
conn.stop();
});

it('executes an interrupt command as a raw ESC keystroke, not injected text', async () => {
const target: InjectionTarget = { write: vi.fn() };
const { conn } = connect({ acquire: () => target }, [commandEvent('interrupt')]);
Expand Down
Loading
Loading