Skip to content

Commit dd177ee

Browse files
committed
🤖 feat: well-typed extension payload with discriminated union
- Created ToolUsePayload discriminated union by toolName - Each tool has specific args and result types (BashToolArgs, BashToolResult, etc.) - PostToolUseHookPayload = ToolUsePayload & { runtime: Runtime } - TypeScript narrows args/result types based on toolName check - Updated docs with type-safe example showing discrimination - Removed unknown types in favor of specific tool types - Tests pass, types are fully type-safe
1 parent 78717e0 commit dd177ee

File tree

4 files changed

+171
-37
lines changed

4 files changed

+171
-37
lines changed

docs/extensions.md

Lines changed: 65 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -58,28 +58,71 @@ interface Extension {
5858
onPostToolUse?: (payload: PostToolUseHookPayload) => Promise<unknown> | unknown;
5959
}
6060

61-
interface PostToolUseHookPayload {
62-
/** Tool name (e.g., "bash", "file_edit_replace_string") */
63-
toolName: string;
64-
65-
/** Unique ID for this tool invocation */
66-
toolCallId: string;
67-
68-
/** Tool-specific arguments (structure varies by tool) */
69-
args: unknown;
70-
71-
/** Tool result (structure varies by tool) - can be modified and returned */
72-
result: unknown;
73-
74-
/** Workspace identifier */
75-
workspaceId: string;
76-
77-
/** Unix timestamp in milliseconds */
78-
timestamp: number;
79-
80-
/** Full workspace runtime access (see Runtime API below) */
81-
runtime: Runtime;
82-
}
61+
// PostToolUseHookPayload is a discriminated union by toolName
62+
// Each tool has specific arg and result types:
63+
64+
type PostToolUseHookPayload =
65+
| {
66+
toolName: "bash";
67+
args: { script: string; timeout_secs?: number };
68+
result: { success: true; output: string; exitCode: 0; wall_duration_ms: number }
69+
| { success: false; output?: string; exitCode: number; error: string; wall_duration_ms: number };
70+
toolCallId: string;
71+
workspaceId: string;
72+
timestamp: number;
73+
runtime: Runtime;
74+
}
75+
| {
76+
toolName: "file_read";
77+
args: { filePath: string; offset?: number; limit?: number };
78+
result: { success: true; file_size: number; modifiedTime: string; lines_read: number; content: string }
79+
| { success: false; error: string };
80+
toolCallId: string;
81+
workspaceId: string;
82+
timestamp: number;
83+
runtime: Runtime;
84+
}
85+
| {
86+
toolName: "file_edit_replace_string";
87+
args: { file_path: string; old_string: string; new_string: string; replace_count?: number };
88+
result: { success: true; diff: string; edits_applied: number }
89+
| { success: false; error: string };
90+
toolCallId: string;
91+
workspaceId: string;
92+
timestamp: number;
93+
runtime: Runtime;
94+
}
95+
// ... other tools (file_edit_insert, propose_plan, todo_write, status_set, etc.)
96+
| {
97+
// Catch-all for unknown tools
98+
toolName: string;
99+
args: unknown;
100+
result: unknown;
101+
toolCallId: string;
102+
workspaceId: string;
103+
timestamp: number;
104+
runtime: Runtime;
105+
};
106+
```
107+
108+
**Type safety**: When you check `payload.toolName`, TypeScript narrows the `args` and `result` types automatically:
109+
110+
```typescript
111+
const extension: Extension = {
112+
async onPostToolUse(payload) {
113+
if (payload.toolName === "bash") {
114+
// TypeScript knows: payload.args is { script: string; timeout_secs?: number }
115+
// TypeScript knows: payload.result has { success, output?, error?, exitCode, wall_duration_ms }
116+
const command = payload.args.script;
117+
118+
if (!payload.result.success) {
119+
const errorMsg = payload.result.error; // Type-safe access
120+
}
121+
}
122+
123+
return payload.result;
124+
}
125+
};
83126
```
84127

85128
## Runtime API

src/services/extensions/extensionHost.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import type {
1515
Extension,
1616
ExtensionInfo,
1717
ExtensionHostApi,
18-
PostToolUseHookPayload,
18+
ToolUsePayload,
1919
} from "../../types/extensions";
2020
import { NodeIpcProcessTransport } from "./nodeIpcTransport";
2121

@@ -101,7 +101,7 @@ class ExtensionHostImpl extends RpcTarget implements ExtensionHostApi {
101101
* Dispatch post-tool-use hook to the extension
102102
* @returns The (possibly modified) tool result, or undefined if unchanged
103103
*/
104-
async onPostToolUse(payload: Omit<PostToolUseHookPayload, "runtime">): Promise<unknown> {
104+
async onPostToolUse(payload: ToolUsePayload): Promise<unknown> {
105105
if (!this.extensionModule || !this.extensionModule.onPostToolUse) {
106106
// Extension doesn't have this hook - return result unchanged
107107
return payload.result;

src/services/extensions/extensionManager.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { promises as fs } from "fs";
1717
import type { WorkspaceMetadata } from "@/types/workspace";
1818
import type { RuntimeConfig } from "@/types/runtime";
1919
import type {
20-
PostToolUseHookPayload,
20+
ToolUsePayload,
2121
ExtensionInfo,
2222
ExtensionHostApi,
2323
} from "@/types/extensions";
@@ -338,7 +338,7 @@ export class ExtensionManager {
338338
*/
339339
async postToolUse(
340340
workspaceId: string,
341-
payload: Omit<PostToolUseHookPayload, "runtime">
341+
payload: ToolUsePayload
342342
): Promise<unknown> {
343343
if (this.hosts.size === 0) {
344344
// No extensions loaded - return original result

src/types/extensions.ts

Lines changed: 102 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,24 @@
11
import type { Runtime } from "@/runtime/Runtime";
22
import type { RuntimeConfig } from "./runtime";
33
import type { RpcTarget } from "capnweb";
4+
import type {
5+
BashToolArgs,
6+
BashToolResult,
7+
FileReadToolArgs,
8+
FileReadToolResult,
9+
FileEditReplaceStringToolArgs,
10+
FileEditReplaceStringToolResult,
11+
FileEditReplaceLinesToolArgs,
12+
FileEditReplaceLinesToolResult,
13+
FileEditInsertToolArgs,
14+
FileEditInsertToolResult,
15+
ProposePlanToolArgs,
16+
ProposePlanToolResult,
17+
TodoWriteToolArgs,
18+
TodoWriteToolResult,
19+
StatusSetToolArgs,
20+
StatusSetToolResult,
21+
} from "./tools";
422

523
/**
624
* Extension manifest structure (manifest.json)
@@ -10,17 +28,90 @@ export interface ExtensionManifest {
1028
}
1129

1230
/**
13-
* Hook payload for post-tool-use hook
31+
* Tool execution payload - discriminated union by tool name
1432
*/
15-
export interface PostToolUseHookPayload {
16-
toolName: string;
17-
toolCallId: string;
18-
args: unknown;
19-
result: unknown;
20-
workspaceId: string;
21-
timestamp: number;
33+
export type ToolUsePayload =
34+
| {
35+
toolName: "bash";
36+
toolCallId: string;
37+
args: BashToolArgs;
38+
result: BashToolResult;
39+
workspaceId: string;
40+
timestamp: number;
41+
}
42+
| {
43+
toolName: "file_read";
44+
toolCallId: string;
45+
args: FileReadToolArgs;
46+
result: FileReadToolResult;
47+
workspaceId: string;
48+
timestamp: number;
49+
}
50+
| {
51+
toolName: "file_edit_replace_string";
52+
toolCallId: string;
53+
args: FileEditReplaceStringToolArgs;
54+
result: FileEditReplaceStringToolResult;
55+
workspaceId: string;
56+
timestamp: number;
57+
}
58+
| {
59+
toolName: "file_edit_replace_lines";
60+
toolCallId: string;
61+
args: FileEditReplaceLinesToolArgs;
62+
result: FileEditReplaceLinesToolResult;
63+
workspaceId: string;
64+
timestamp: number;
65+
}
66+
| {
67+
toolName: "file_edit_insert";
68+
toolCallId: string;
69+
args: FileEditInsertToolArgs;
70+
result: FileEditInsertToolResult;
71+
workspaceId: string;
72+
timestamp: number;
73+
}
74+
| {
75+
toolName: "propose_plan";
76+
toolCallId: string;
77+
args: ProposePlanToolArgs;
78+
result: ProposePlanToolResult;
79+
workspaceId: string;
80+
timestamp: number;
81+
}
82+
| {
83+
toolName: "todo_write";
84+
toolCallId: string;
85+
args: TodoWriteToolArgs;
86+
result: TodoWriteToolResult;
87+
workspaceId: string;
88+
timestamp: number;
89+
}
90+
| {
91+
toolName: "status_set";
92+
toolCallId: string;
93+
args: StatusSetToolArgs;
94+
result: StatusSetToolResult;
95+
workspaceId: string;
96+
timestamp: number;
97+
}
98+
| {
99+
// Catch-all for unknown tools
100+
toolName: string;
101+
toolCallId: string;
102+
args: unknown;
103+
result: unknown;
104+
workspaceId: string;
105+
timestamp: number;
106+
};
107+
108+
/**
109+
* Hook payload for post-tool-use hook with Runtime access
110+
* This adds the runtime field to each variant of ToolUsePayload
111+
*/
112+
export type PostToolUseHookPayload = ToolUsePayload & {
22113
runtime: Runtime; // Extensions get full workspace access via Runtime
23-
}
114+
};
24115

25116
/**
26117
* Extension export interface - what extensions must export as default
@@ -31,7 +122,7 @@ export interface Extension {
31122
* Extensions can monitor, log, or modify the tool result.
32123
*
33124
* @param payload - Tool execution context with full Runtime access
34-
* @returns The tool result (can be modified) or undefined to leave unchanged
125+
* @returns The tool result (modified or unmodified). Return undefined to leave unchanged.
35126
*/
36127
onPostToolUse?: (payload: PostToolUseHookPayload) => Promise<unknown> | unknown;
37128
}
@@ -81,7 +172,7 @@ export interface ExtensionHostApi extends RpcTarget {
81172
* @param payload Hook payload (runtime will be added by host)
82173
* @returns The (possibly modified) tool result, or undefined if unchanged
83174
*/
84-
onPostToolUse(payload: Omit<PostToolUseHookPayload, "runtime">): Promise<unknown>;
175+
onPostToolUse(payload: ToolUsePayload): Promise<unknown>;
85176

86177
/**
87178
* Gracefully shutdown the extension host

0 commit comments

Comments
 (0)