Skip to content

Commit 7c9cc58

Browse files
committed
feat: surface hook lifecycle events
1 parent c2ed020 commit 7c9cc58

13 files changed

Lines changed: 644 additions & 3 deletions

docs/app-server-events.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ subscriptions.
6262
- `account/updated`
6363
- `app/list/updated`
6464
- `error`
65+
- `hook/completed`
66+
- `hook/started`
6567
- `item/agentMessage/delta`
6668
- `item/commandExecution/outputDelta`
6769
- `item/commandExecution/terminalInteraction`
@@ -123,8 +125,6 @@ events are currently not routed:
123125
- `deprecationNotice`
124126
- `fuzzyFileSearch/sessionCompleted`
125127
- `fuzzyFileSearch/sessionUpdated`
126-
- `hook/completed`
127-
- `hook/started`
128128
- `item/mcpToolCall/progress`
129129
- `mcpServer/oauthLogin/completed`
130130
- `model/rerouted`

src-tauri/src/backend/app_server.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1125,6 +1125,18 @@ mod tests {
11251125
assert_eq!(extract_thread_id(&value), Some("thread-456".to_string()));
11261126
}
11271127

1128+
#[test]
1129+
fn extract_thread_id_reads_hook_notification_thread_id() {
1130+
let value = json!({
1131+
"method": "hook/started",
1132+
"params": {
1133+
"threadId": "thread-hook-1",
1134+
"run": { "id": "hook-1" }
1135+
}
1136+
});
1137+
assert_eq!(extract_thread_id(&value), Some("thread-hook-1".to_string()));
1138+
}
1139+
11281140
#[test]
11291141
fn extract_thread_id_returns_none_when_missing() {
11301142
let value = json!({ "params": {} });

src/features/app/hooks/useAppServerEvents.test.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ describe("useAppServerEvents", () => {
4747
const handlers: Handlers = {
4848
onAppServerEvent: vi.fn(),
4949
onWorkspaceConnected: vi.fn(),
50+
onHookStarted: vi.fn(),
51+
onHookCompleted: vi.fn(),
5052
onThreadStarted: vi.fn(),
5153
onThreadNameUpdated: vi.fn(),
5254
onThreadStatusChanged: vi.fn(),
@@ -121,6 +123,45 @@ describe("useAppServerEvents", () => {
121123
"- Step 1",
122124
);
123125

126+
act(() => {
127+
listener?.({
128+
workspace_id: "ws-1",
129+
message: {
130+
method: "hook/started",
131+
params: {
132+
threadId: "thread-1",
133+
turnId: "turn-1",
134+
run: { id: "hook-1", eventName: "session-start", statusMessage: "Preparing" },
135+
},
136+
},
137+
});
138+
});
139+
expect(handlers.onHookStarted).toHaveBeenCalledWith({
140+
workspaceId: "ws-1",
141+
threadId: "thread-1",
142+
turnId: "turn-1",
143+
run: { id: "hook-1", eventName: "session-start", statusMessage: "Preparing" },
144+
});
145+
146+
act(() => {
147+
listener?.({
148+
workspace_id: "ws-1",
149+
message: {
150+
method: "hook/completed",
151+
params: {
152+
threadId: "thread-1",
153+
run: { id: "hook-1", eventName: "session-start", status: "completed" },
154+
},
155+
},
156+
});
157+
});
158+
expect(handlers.onHookCompleted).toHaveBeenCalledWith({
159+
workspaceId: "ws-1",
160+
threadId: "thread-1",
161+
turnId: null,
162+
run: { id: "hook-1", eventName: "session-start", status: "completed" },
163+
});
164+
124165
act(() => {
125166
listener?.({
126167
workspace_id: "ws-1",

src/features/app/hooks/useAppServerEvents.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ type AgentCompleted = {
2828
text: string;
2929
};
3030

31+
type HookEvent = {
32+
workspaceId: string;
33+
threadId: string;
34+
turnId: string | null;
35+
run: Record<string, unknown>;
36+
};
37+
3138
type AppServerEventHandlers = {
3239
onWorkspaceConnected?: (workspaceId: string) => void;
3340
onThreadStarted?: (workspaceId: string, thread: Record<string, unknown>) => void;
@@ -67,6 +74,8 @@ type AppServerEventHandlers = {
6774
turnId: string,
6875
payload: { explanation: unknown; plan: unknown },
6976
) => void;
77+
onHookStarted?: (event: HookEvent) => void;
78+
onHookCompleted?: (event: HookEvent) => void;
7079
onItemStarted?: (workspaceId: string, threadId: string, item: Record<string, unknown>) => void;
7180
onItemCompleted?: (workspaceId: string, threadId: string, item: Record<string, unknown>) => void;
7281
onReasoningSummaryDelta?: (workspaceId: string, threadId: string, itemId: string, delta: string) => void;
@@ -105,6 +114,8 @@ export const METHODS_ROUTED_IN_USE_APP_SERVER_EVENTS = [
105114
"codex/backgroundThread",
106115
"codex/connected",
107116
"error",
117+
"hook/completed",
118+
"hook/started",
108119
"item/agentMessage/delta",
109120
"item/commandExecution/outputDelta",
110121
"item/commandExecution/terminalInteraction",
@@ -129,6 +140,31 @@ export const METHODS_ROUTED_IN_USE_APP_SERVER_EVENTS = [
129140
"turn/started",
130141
] as const satisfies readonly SupportedAppServerMethod[];
131142

143+
function parseHookEvent(
144+
workspaceId: string,
145+
params: Record<string, unknown>,
146+
): HookEvent | null {
147+
const threadId = String(params.threadId ?? params.thread_id ?? "").trim();
148+
if (!threadId) {
149+
return null;
150+
}
151+
const run = params.run;
152+
if (!run || typeof run !== "object" || Array.isArray(run)) {
153+
return null;
154+
}
155+
const turnIdRaw = params.turnId ?? params.turn_id ?? null;
156+
const turnId =
157+
typeof turnIdRaw === "string" && turnIdRaw.trim().length > 0
158+
? turnIdRaw.trim()
159+
: null;
160+
return {
161+
workspaceId,
162+
threadId,
163+
turnId,
164+
run: run as Record<string, unknown>,
165+
};
166+
}
167+
132168
export function useAppServerEvents(handlers: AppServerEventHandlers) {
133169
// Use ref to keep handlers current without triggering re-subscription
134170
const handlersRef = useRef(handlers);
@@ -238,6 +274,22 @@ export function useAppServerEvents(handlers: AppServerEventHandlers) {
238274
return;
239275
}
240276

277+
if (method === "hook/started") {
278+
const event = parseHookEvent(workspace_id, params);
279+
if (event) {
280+
currentHandlers.onHookStarted?.(event);
281+
}
282+
return;
283+
}
284+
285+
if (method === "hook/completed") {
286+
const event = parseHookEvent(workspace_id, params);
287+
if (event) {
288+
currentHandlers.onHookCompleted?.(event);
289+
}
290+
return;
291+
}
292+
241293
if (method === "thread/started") {
242294
const thread = (params.thread as Record<string, unknown> | undefined) ?? null;
243295
const threadId = String(thread?.id ?? "");

src/features/messages/components/MessageRows.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
buildToolSummary,
2525
exploreKindLabel,
2626
formatDurationMs,
27+
formatToolStatusLabel,
2728
normalizeMessageImageSrc,
2829
toolNameFromTitle,
2930
toolStatusTone,
@@ -703,6 +704,7 @@ export const ToolRow = memo(function ToolRow({
703704
: isCommand
704705
? ""
705706
: summary.label;
707+
const inlineStatus = formatToolStatusLabel(item);
706708
const summaryValue = isFileChange
707709
? changeNames.length > 1
708710
? `${changeNames[0]} +${changeNames.length - 1}`
@@ -806,6 +808,9 @@ export const ToolRow = memo(function ToolRow({
806808
)}
807809
</span>
808810
)}
811+
{inlineStatus && (
812+
<span className="tool-inline-status">{inlineStatus}</span>
813+
)}
809814
</button>
810815
{isExpanded && summary.detail && !isFileChange && (
811816
<div className="tool-inline-detail">{summary.detail}</div>

src/features/messages/components/Messages.test.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1589,4 +1589,40 @@ describe("Messages", () => {
15891589
expect(screen.getByText("Input requested")).toBeTruthy();
15901590
expect(screen.queryByText("Plan ready")).toBeNull();
15911591
});
1592+
1593+
it("renders hook rows through the standard tool renderer", () => {
1594+
const items: ConversationItem[] = [
1595+
{
1596+
id: "hook-hook-1",
1597+
kind: "tool",
1598+
toolType: "hook",
1599+
title: "Hook: session-start",
1600+
detail: "command • sync • thread • session-start.sh • Preparing",
1601+
status: "failed",
1602+
output: "[error] Missing config",
1603+
durationMs: 3100,
1604+
},
1605+
];
1606+
1607+
render(
1608+
<Messages
1609+
items={items}
1610+
threadId="thread-1"
1611+
workspaceId="ws-1"
1612+
isThinking={false}
1613+
openTargets={[]}
1614+
selectedOpenAppId=""
1615+
/>,
1616+
);
1617+
1618+
expect(screen.getByText("hook:")).toBeTruthy();
1619+
expect(screen.getByText("session-start")).toBeTruthy();
1620+
expect(screen.getByText("failed • 0:03")).toBeTruthy();
1621+
1622+
fireEvent.click(screen.getByRole("button", { name: "Toggle tool details" }));
1623+
expect(
1624+
screen.getByText("command • sync • thread • session-start.sh • Preparing"),
1625+
).toBeTruthy();
1626+
expect(screen.getByText("[error] Missing config")).toBeTruthy();
1627+
});
15921628
});

src/features/messages/utils/messageRenderUtils.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,15 @@ export function buildToolSummary(
365365
};
366366
}
367367

368+
if (item.toolType === "hook") {
369+
return {
370+
label: "hook",
371+
value: item.title.replace(/^Hook:\s*/i, "").trim() || item.title || "hook",
372+
detail: item.detail || "",
373+
output: item.output || "",
374+
};
375+
}
376+
368377
if (item.toolType === "collabToolCall") {
369378
return {
370379
label: summarizeCollabLabel(item.title, item.status),
@@ -448,6 +457,23 @@ export function toolStatusTone(
448457
return "processing";
449458
}
450459

460+
export function formatToolStatusLabel(
461+
item: Extract<ConversationItem, { kind: "tool" }>,
462+
) {
463+
if (item.toolType !== "hook") {
464+
return "";
465+
}
466+
const parts: string[] = [];
467+
const status = (item.status ?? "").trim().toLowerCase();
468+
if (status) {
469+
parts.push(status.replace(/[_-]+/g, " "));
470+
}
471+
if (typeof item.durationMs === "number" && Number.isFinite(item.durationMs)) {
472+
parts.push(formatDurationMs(item.durationMs));
473+
}
474+
return parts.join(" • ");
475+
}
476+
451477

452478
export type PlanFollowupState = {
453479
shouldShow: boolean;

src/features/threads/hooks/useThreadEventHandlers.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
import { useCallback, useMemo } from "react";
22
import type { Dispatch, MutableRefObject } from "react";
3-
import type { AppServerEvent, DebugEntry, RateLimitSnapshot, TurnPlan } from "@/types";
3+
import type {
4+
AppServerEvent,
5+
ConversationItem,
6+
DebugEntry,
7+
RateLimitSnapshot,
8+
TurnPlan,
9+
} from "@/types";
410
import { getAppServerRawMethod } from "@utils/appServerEvents";
511
import { useThreadApprovalEvents } from "./useThreadApprovalEvents";
12+
import { useThreadHookEvents } from "./useThreadHookEvents";
613
import { useThreadItemEvents } from "./useThreadItemEvents";
714
import { useThreadTurnEvents } from "./useThreadTurnEvents";
815
import { useThreadUserInputEvents } from "./useThreadUserInputEvents";
@@ -11,6 +18,7 @@ import type { ThreadAction } from "./useThreadsReducer";
1118
type ThreadEventHandlersOptions = {
1219
activeThreadId: string | null;
1320
dispatch: Dispatch<ThreadAction>;
21+
getItemsForThread: (threadId: string) => ConversationItem[];
1422
planByThreadRef: MutableRefObject<Record<string, TurnPlan | null>>;
1523
getCurrentRateLimits?: (workspaceId: string) => RateLimitSnapshot | null;
1624
getCustomName: (workspaceId: string, threadId: string) => string | undefined;
@@ -47,6 +55,7 @@ type ThreadEventHandlersOptions = {
4755
export function useThreadEventHandlers({
4856
activeThreadId,
4957
dispatch,
58+
getItemsForThread,
5059
planByThreadRef,
5160
getCurrentRateLimits,
5261
getCustomName,
@@ -72,6 +81,46 @@ export function useThreadEventHandlers({
7281
approvalAllowlistRef,
7382
});
7483
const onRequestUserInput = useThreadUserInputEvents({ dispatch });
84+
const {
85+
onHookStarted: handleHookStarted,
86+
onHookCompleted: handleHookCompleted,
87+
} = useThreadHookEvents({
88+
dispatch,
89+
getItemsForThread,
90+
safeMessageActivity,
91+
});
92+
const onHookStarted = useCallback(
93+
({
94+
workspaceId,
95+
threadId,
96+
turnId,
97+
run,
98+
}: {
99+
workspaceId: string;
100+
threadId: string;
101+
turnId: string | null;
102+
run: Record<string, unknown>;
103+
}) => {
104+
handleHookStarted(workspaceId, threadId, turnId, run);
105+
},
106+
[handleHookStarted],
107+
);
108+
const onHookCompleted = useCallback(
109+
({
110+
workspaceId,
111+
threadId,
112+
turnId,
113+
run,
114+
}: {
115+
workspaceId: string;
116+
threadId: string;
117+
turnId: string | null;
118+
run: Record<string, unknown>;
119+
}) => {
120+
handleHookCompleted(workspaceId, threadId, turnId, run);
121+
},
122+
[handleHookCompleted],
123+
);
75124

76125
const {
77126
onAgentMessageDelta,
@@ -159,6 +208,8 @@ export function useThreadEventHandlers({
159208
onWorkspaceConnected,
160209
onApprovalRequest,
161210
onRequestUserInput,
211+
onHookStarted,
212+
onHookCompleted,
162213
onBackgroundThreadAction,
163214
onAppServerEvent,
164215
onAgentMessageDelta,
@@ -190,6 +241,8 @@ export function useThreadEventHandlers({
190241
onWorkspaceConnected,
191242
onApprovalRequest,
192243
onRequestUserInput,
244+
onHookStarted,
245+
onHookCompleted,
193246
onBackgroundThreadAction,
194247
onAppServerEvent,
195248
onAgentMessageDelta,

0 commit comments

Comments
 (0)