Skip to content

Commit f4ad944

Browse files
authored
feat(sdk): type guard all the things! (#108)
## What <!-- Brief description of the changes --> Adds additional type guards ## Why <!-- Motivation and context --> ## PR title format <!-- PR titles must follow Conventional Commits: <type>(<scope>): <description> Types: feat | fix | docs | style | refactor | perf | test | build | ci | chore | revert Scopes: sdk | acp | claude | examples | deps | project Examples: feat(sdk): add reconnect support fix(acp): handle timeout on long-running tasks docs(claude): update connection examples deps(sdk): bump @runloop/api-client to 1.5.0 ci(project): add PR title validation workflow --> ## Checklist - [ ] PR title follows `<type>(<scope>): <description>` format (see above) - [ ] `bun run check` passes (lint + format) - [ ] `bun run build` passes - [ ] `bun run test` passes - [ ] SDK documentation updated (if applicable)
1 parent 1f8825b commit f4ad944

11 files changed

Lines changed: 428 additions & 55 deletions

sdk/src/acp/axon-stream.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Axon } from "@runloop/api-client/sdk";
55
import { isSystemError, SystemError } from "../shared/errors/system-error.js";
66
import { makeDefaultOnError } from "../shared/logging.js";
77
import { isFromAgent, isFromUser } from "../shared/origin-guards.js";
8+
import { getJsonRpcId, isNonNullObject } from "../shared/structural-guards.js";
89
import type { LogFn } from "../shared/types.js";
910
import type { AxonStreamOptions } from "./types.js";
1011

@@ -348,8 +349,9 @@ function axonEventToJsonRpc(
348349
// entry so subsequent calls to the same method aren't rejected as duplicates.
349350
if (isJsonRpcMessage(parsed)) {
350351
if ("result" in parsed || "error" in parsed) {
352+
const parsedId = getJsonRpcId(parsed);
351353
for (const [method, id] of pendingRequests) {
352-
if (id === (parsed as Record<string, unknown>).id) {
354+
if (id === parsedId) {
353355
pendingRequests.delete(method);
354356
break;
355357
}
@@ -546,9 +548,6 @@ function isAgentMethod(eventType: string): boolean {
546548
* @returns `true` if `obj` looks like a JSON-RPC 2.0 message.
547549
*/
548550
function isJsonRpcMessage(obj: unknown): obj is AnyMessage {
549-
if (typeof obj !== "object" || obj === null) return false;
550-
const record = obj as Record<string, unknown>;
551-
return (
552-
record.jsonrpc === "2.0" && ("method" in record || "result" in record || "error" in record)
553-
);
551+
if (!isNonNullObject(obj)) return false;
552+
return obj.jsonrpc === "2.0" && ("method" in obj || "result" in obj || "error" in obj);
554553
}

sdk/src/acp/index.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,12 @@ export {
6262
ClientSideConnection,
6363
PROTOCOL_VERSION,
6464
} from "@agentclientprotocol/sdk";
65-
export { isFromAgent, isFromUser } from "../shared/origin-guards.js";
65+
export {
66+
type AgentOriginEvent,
67+
isFromAgent,
68+
isFromUser,
69+
type UserOriginEvent,
70+
} from "../shared/origin-guards.js";
6671
export { tryParseSystemEvent, tryParseTimelinePayload } from "../shared/timeline.js";
6772
export type {
6873
AxonEventListener,

sdk/src/claude/classify-claude-axon-event.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { hasStringType } from "../shared/structural-guards.js";
12
import { createClassifier } from "../shared/timeline.js";
23
import { MESSAGE_TYPE_TO_EVENT_TYPE } from "./transport.js";
34
import type { ClaudeProtocolTimelineEvent } from "./types.js";
@@ -30,7 +31,7 @@ export const classifyClaudeAxonEvent = createClassifier<ClaudeProtocolTimelineEv
3031
label: "classifyClaudeAxonEvent",
3132
isProtocolEventType: isClaudeProtocolEventType,
3233
toProtocolEvent: (data, ev) => {
33-
if (data && typeof data === "object" && "type" in data) {
34+
if (hasStringType(data)) {
3435
return {
3536
kind: "claude_protocol",
3637
eventType: ev.event_type,

sdk/src/claude/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,12 @@ export type {
6464
SDKUserMessage,
6565
SDKUserMessageReplay,
6666
} from "@anthropic-ai/claude-agent-sdk";
67-
export { isFromAgent, isFromUser } from "../shared/origin-guards.js";
67+
export {
68+
type AgentOriginEvent,
69+
isFromAgent,
70+
isFromUser,
71+
type UserOriginEvent,
72+
} from "../shared/origin-guards.js";
6873
export { tryParseSystemEvent, tryParseTimelinePayload } from "../shared/timeline.js";
6974
export type {
7075
AxonEventListener,
@@ -122,6 +127,8 @@ export {
122127
export {
123128
AxonTransport,
124129
type AxonTransportOptions,
130+
type ControlRequestEvent,
131+
type ControlResponseEvent,
125132
isControlRequest,
126133
isControlResponse,
127134
type Transport,

sdk/src/claude/timeline-event-guards.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
* @module
2424
*/
2525

26+
import { isTextContentBlock } from "../shared/structural-guards.js";
2627
import type {
2728
ClaudeAssistantTimelineEvent,
2829
ClaudeControlRequestTimelineEvent,
@@ -116,16 +117,7 @@ export function isClaudeAssistantTextEvent(
116117
if (!Array.isArray(content)) {
117118
return false;
118119
}
119-
return content.some(
120-
(block) =>
121-
block != null &&
122-
typeof block === "object" &&
123-
"type" in block &&
124-
block.type === "text" &&
125-
"text" in block &&
126-
typeof block.text === "string" &&
127-
block.text.trim().length > 0,
128-
);
120+
return content.some((block) => isTextContentBlock(block) && block.text.trim().length > 0);
129121
}
130122

131123
/**

sdk/src/claude/transport.ts

Lines changed: 24 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { Stream } from "@runloop/api-client/streaming";
1414
import { isSystemError, SystemError } from "../shared/errors/system-error.js";
1515
import { makeLogger } from "../shared/logging.js";
1616
import { isFromAgent, isFromUser } from "../shared/origin-guards.js";
17+
import { getRequestId, isNonNullObject } from "../shared/structural-guards.js";
1718
import type { LogFn } from "../shared/types.js";
1819
import type { WireData } from "./types.js";
1920

@@ -22,7 +23,19 @@ import type { WireData } from "./types.js";
2223
// ---------------------------------------------------------------------------
2324

2425
/**
25-
* Returns `true` if the event is a `control_request` (agent requesting permission).
26+
* AxonEventView narrowed to `control_request` event type.
27+
* @category Transport
28+
*/
29+
export type ControlRequestEvent = AxonEventView & { event_type: "control_request" };
30+
31+
/**
32+
* AxonEventView narrowed to `control_response` event type.
33+
* @category Transport
34+
*/
35+
export type ControlResponseEvent = AxonEventView & { event_type: "control_response" };
36+
37+
/**
38+
* Type guard that narrows an event to `control_request`.
2639
*
2740
* This checks the event *type* only; pair with {@link isFromAgent} to confirm
2841
* direction (control requests originate from the agent).
@@ -31,12 +44,12 @@ import type { WireData } from "./types.js";
3144
* @returns `true` if `event_type === "control_request"`.
3245
* @category Transport
3346
*/
34-
export function isControlRequest(event: AxonEventView): boolean {
47+
export function isControlRequest(event: AxonEventView): event is ControlRequestEvent {
3548
return event.event_type === "control_request";
3649
}
3750

3851
/**
39-
* Returns `true` if the event is a `control_response` (client responding to permission request).
52+
* Type guard that narrows an event to `control_response`.
4053
*
4154
* This checks the event *type* only; pair with {@link isFromUser} to confirm
4255
* direction (control responses originate from the client).
@@ -45,7 +58,7 @@ export function isControlRequest(event: AxonEventView): boolean {
4558
* @returns `true` if `event_type === "control_response"`.
4659
* @category Transport
4760
*/
48-
export function isControlResponse(event: AxonEventView): boolean {
61+
export function isControlResponse(event: AxonEventView): event is ControlResponseEvent {
4962
return event.event_type === "control_response";
5063
}
5164

@@ -281,10 +294,10 @@ export class AxonTransport implements Transport {
281294
if (event.payload != null) {
282295
try {
283296
const parsed = JSON.parse(event.payload);
284-
if (parsed != null && typeof parsed === "object") {
285-
const requestId: string | undefined = parsed.request_id;
297+
if (isNonNullObject(parsed)) {
298+
const requestId = getRequestId(parsed);
286299
if (requestId) {
287-
replayBuffer.set(requestId, parsed);
300+
replayBuffer.set(requestId, parsed as WireData);
288301
this.log("read", `#${eventCount} REPLAY buffered control_request ${requestId}`);
289302
}
290303
}
@@ -297,8 +310,8 @@ export class AxonTransport implements Transport {
297310
if (event.payload != null) {
298311
try {
299312
const parsed = JSON.parse(event.payload);
300-
const response = parsed?.response;
301-
const requestId: string | undefined = response?.request_id;
313+
const response = isNonNullObject(parsed) ? parsed.response : undefined;
314+
const requestId = getRequestId(response);
302315
if (requestId && replayBuffer.has(requestId)) {
303316
replayBuffer.delete(requestId);
304317
this.log("read", `#${eventCount} REPLAY resolved control_request ${requestId}`);
@@ -344,11 +357,11 @@ export class AxonTransport implements Transport {
344357
}
345358
try {
346359
const parsed = JSON.parse(event.payload);
347-
if (parsed == null || typeof parsed !== "object") {
360+
if (!isNonNullObject(parsed)) {
348361
this.log("read", `#${eventCount} skipping non-object payload`);
349362
continue;
350363
}
351-
yield parsed;
364+
yield parsed as WireData;
352365
} catch (err) {
353366
this.log(
354367
"read",

sdk/src/shared/index.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,23 @@ export { SystemError, type SystemErrorEventInfo } from "./errors/system-error.js
2121
export { runDisconnectHook } from "./lifecycle.js";
2222
export { ListenerSet } from "./listener-set.js";
2323
export { makeDefaultOnError, makeLogger } from "./logging.js";
24-
export { isFromAgent, isFromUser } from "./origin-guards.js";
24+
export {
25+
type AgentOriginEvent,
26+
isFromAgent,
27+
isFromUser,
28+
type UserOriginEvent,
29+
} from "./origin-guards.js";
2530
export { getLastSequence } from "./replay.js";
31+
export {
32+
getJsonRpcId,
33+
getRequestId,
34+
getStringProp,
35+
hasJsonRpcId,
36+
hasRequestId,
37+
hasStringType,
38+
isNonNullObject,
39+
isTextContentBlock,
40+
} from "./structural-guards.js";
2641
export {
2742
type ClassifyConfig,
2843
createClassifier,
@@ -51,7 +66,6 @@ export {
5166
isUnknownTimelineEvent,
5267
} from "./timeline-event-guards.js";
5368
export { timelineEventGenerator } from "./timeline-generator.js";
54-
/** @category Types */
5569
/** @category Timeline */
5670
export type {
5771
AgentErrorEvent,

sdk/src/shared/origin-guards.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,32 @@ import type { BaseTimelineEvent } from "./types.js";
3434
*/
3535
type HasOrigin = AxonEventView | BaseTimelineEvent;
3636

37+
/**
38+
* Narrows an event to agent origin.
39+
*
40+
* - For `AxonEventView` (default): narrows the top-level `origin` field.
41+
* - For `BaseTimelineEvent` subtypes: narrows `axonEvent.origin` instead,
42+
* since timeline events carry the origin on their nested `axonEvent`.
43+
*
44+
* @category Origin
45+
*/
46+
export type AgentOriginEvent<T extends HasOrigin = AxonEventView> = T extends BaseTimelineEvent
47+
? T & { axonEvent: { origin: "AGENT_EVENT" } }
48+
: T & { origin: "AGENT_EVENT" };
49+
50+
/**
51+
* Narrows an event to user origin.
52+
*
53+
* - For `AxonEventView` (default): narrows the top-level `origin` field.
54+
* - For `BaseTimelineEvent` subtypes: narrows `axonEvent.origin` instead,
55+
* since timeline events carry the origin on their nested `axonEvent`.
56+
*
57+
* @category Origin
58+
*/
59+
export type UserOriginEvent<T extends HasOrigin = AxonEventView> = T extends BaseTimelineEvent
60+
? T & { axonEvent: { origin: "USER_EVENT" } }
61+
: T & { origin: "USER_EVENT" };
62+
3763
/**
3864
* Extracts the origin string from either an AxonEventView or a timeline event.
3965
*/
@@ -42,27 +68,27 @@ function getOrigin(eventOrTimeline: HasOrigin): string {
4268
}
4369

4470
/**
45-
* Returns `true` if the event originated from the agent.
71+
* Type guard that narrows an event to agent origin.
4672
*
4773
* Works with both raw `AxonEventView` objects and timeline events.
4874
*
4975
* @param event - An AxonEventView or a timeline event containing one.
5076
* @returns `true` if `origin === "AGENT_EVENT"`.
5177
* @category Origin
5278
*/
53-
export function isFromAgent(event: HasOrigin): boolean {
79+
export function isFromAgent<T extends HasOrigin>(event: T): event is AgentOriginEvent<T> {
5480
return getOrigin(event) === "AGENT_EVENT";
5581
}
5682

5783
/**
58-
* Returns `true` if the event originated from the user/client.
84+
* Type guard that narrows an event to user origin.
5985
*
6086
* Works with both raw `AxonEventView` objects and timeline events.
6187
*
6288
* @param event - An AxonEventView or a timeline event containing one.
6389
* @returns `true` if `origin === "USER_EVENT"`.
6490
* @category Origin
6591
*/
66-
export function isFromUser(event: HasOrigin): boolean {
92+
export function isFromUser<T extends HasOrigin>(event: T): event is UserOriginEvent<T> {
6793
return getOrigin(event) === "USER_EVENT";
6894
}

0 commit comments

Comments
 (0)