Skip to content

Commit 89e5614

Browse files
dines-rlReflexclaude
authored
feat(sdk): make the published event source configurable (#131)
## Problem The SDK transports hardcoded the `source` field when publishing user prompts to the Axon API: - `sdk/src/claude/transport.ts` → `"claude-sdk-client"` - `sdk/src/acp/axon-stream.ts` → `"acp-sdk-client"` Callers had no way to override it, so events from different integrations writing to the same channel were indistinguishable. ## Approach Rather than threading `source` through every `send()` / `prompt()` call (the ACP `prompt()` path delegates to the ACP SDK's `ClientSideConnection` and has no clean per-message hook), the source is now a value the **client holds and can change at any time**. The transport/stream resolves it **lazily at each publish**, so: ```ts conn.setSource("my-integration"); await conn.send("hello"); // published with source "my-integration" conn.setSource("other"); await conn.prompt({ ... }); // published with source "other" ``` This works uniformly for both Claude and ACP without touching the SDK send methods. ## Changes - **`BaseConnectionOptions.source`** — optional initial `source` for both connection types. - **`ClaudeAxonConnection` / `ACPAxonConnection`** — new `setSource(source)` method and `source` getter; hold a mutable `currentSource` and pass a resolver (`() => this.currentSource`) to the transport/stream. - **`AxonTransportOptions.source` / `AxonStreamOptions.source`** — accept `string | (() => string | undefined)`, resolved at publish time. - Defaults unchanged: omit `source` (or return `undefined`) and the built-in identifiers are still used. ## Tests Added coverage for: custom static source, lazy per-message resolution, `undefined` → default fallback (transport + stream), and the `setSource()` / `source` accessors on both connections. - `npm run typecheck`, `npm run check` (biome), and `vitest` (512 tests) all pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Reflex <reflex@runloop.ai> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d4edbca commit 89e5614

10 files changed

Lines changed: 273 additions & 3 deletions

File tree

sdk/src/acp/axon-stream.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,50 @@ describe("axonStream", () => {
764764
expect(published[0].source).toBe("acp-sdk-client");
765765
});
766766

767+
it("publishes with a custom source when provided via options", async () => {
768+
const { writable } = axonStream({ axon: axon as never, source: "my-custom-client" });
769+
770+
await writeMessage(writable, {
771+
jsonrpc: "2.0",
772+
method: "session/cancel",
773+
params: { sessionId: "s1" },
774+
});
775+
776+
expect(published[0].source).toBe("my-custom-client");
777+
});
778+
779+
it("resolves the source lazily on each publish when given a resolver", async () => {
780+
let current = "first";
781+
const { writable } = axonStream({ axon: axon as never, source: () => current });
782+
783+
await writeMessage(writable, {
784+
jsonrpc: "2.0",
785+
method: "session/cancel",
786+
params: { sessionId: "s1" },
787+
});
788+
current = "second";
789+
await writeMessage(writable, {
790+
jsonrpc: "2.0",
791+
method: "session/cancel",
792+
params: { sessionId: "s2" },
793+
});
794+
795+
expect(published[0].source).toBe("first");
796+
expect(published[1].source).toBe("second");
797+
});
798+
799+
it("falls back to the default when the resolver returns undefined", async () => {
800+
const { writable } = axonStream({ axon: axon as never, source: () => undefined });
801+
802+
await writeMessage(writable, {
803+
jsonrpc: "2.0",
804+
method: "session/cancel",
805+
params: { sessionId: "s1" },
806+
});
807+
808+
expect(published[0].source).toBe("acp-sdk-client");
809+
});
810+
767811
it("publishes JSON-RPC notifications with method as event_type", async () => {
768812
const { writable } = axonStream({ axon: axon as never });
769813

sdk/src/acp/axon-stream.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@ const NOTIFICATION_TYPES = new Set<string>([
2121
CLIENT_METHODS.session_elicitation_complete,
2222
]);
2323

24+
/** Default `source` used when publishing events from the ACP SDK stream. */
25+
const DEFAULT_SOURCE = "acp-sdk-client";
26+
2427
/**
2528
* Creates an ACP-compatible `Stream` backed by an Axon channel from
2629
* `@runloop/api-client`.
@@ -42,6 +45,13 @@ const NOTIFICATION_TYPES = new Set<string>([
4245
export function axonStream(options: AxonStreamOptions): Stream {
4346
const { axon, signal, onAxonEvent, log, afterSequence, replayTargetSequence } = options;
4447
const onError = options.onError ?? makeDefaultOnError("axonStream");
48+
const optionSource = options.source;
49+
// Resolve lazily on each publish so callers can change the source between
50+
// messages (e.g. via a resolver closing over mutable state).
51+
const resolveSource = (): string => {
52+
const value = typeof optionSource === "function" ? optionSource() : optionSource;
53+
return value ?? DEFAULT_SOURCE;
54+
};
4555

4656
// Maps outbound JSON-RPC request method -> id so we can correlate
4757
// the broker's response (which only carries event_type, not an id).
@@ -66,7 +76,14 @@ export function axonStream(options: AxonStreamOptions): Stream {
6676
replayTargetSequence,
6777
);
6878

69-
const writable = createWritable(axon, pendingRequests, pendingClientRequests, onError, log);
79+
const writable = createWritable(
80+
axon,
81+
pendingRequests,
82+
pendingClientRequests,
83+
onError,
84+
log,
85+
resolveSource,
86+
);
7087

7188
return { readable, writable };
7289
}
@@ -447,6 +464,7 @@ function axonEventToJsonRpc(
447464
* @param axon - Axon channel to publish to.
448465
* @param pendingRequests - Shared map tracking outbound request method → JSON-RPC ID.
449466
* @param pendingClientRequests - Shared map tracking agent-to-client request ID → method.
467+
* @param resolveSource - Resolver invoked per publish to obtain the `source`.
450468
* @returns A `WritableStream` that accepts JSON-RPC messages.
451469
*/
452470
function createWritable(
@@ -455,6 +473,7 @@ function createWritable(
455473
pendingClientRequests: Map<string | number, string>,
456474
onError: (error: unknown) => void,
457475
log: LogFn | undefined,
476+
resolveSource: () => string,
458477
): WritableStream<AnyMessage> {
459478
return new WritableStream<AnyMessage>({
460479
async write(message) {
@@ -465,7 +484,7 @@ function createWritable(
465484
event_type: eventType,
466485
origin: "USER_EVENT",
467486
payload,
468-
source: "acp-sdk-client",
487+
source: resolveSource(),
469488
});
470489
} catch (err) {
471490
onError(err);

sdk/src/acp/connection.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,40 @@ describe("ACPAxonConnection", () => {
8181
});
8282
});
8383

84+
describe("source / setSource()", () => {
85+
it("defaults to undefined (stream supplies the built-in default)", () => {
86+
const ctrl = createControllableStream();
87+
const { axon } = createMockAxon(ctrl);
88+
89+
const conn = new ACPAxonConnection(axon as never, { id: "dbx-test" } as never);
90+
expect(conn.source).toBeUndefined();
91+
conn.disconnect();
92+
});
93+
94+
it("reflects the value passed via options", () => {
95+
const ctrl = createControllableStream();
96+
const { axon } = createMockAxon(ctrl);
97+
98+
const conn = new ACPAxonConnection(axon as never, { id: "dbx-test" } as never, {
99+
source: "from-options",
100+
});
101+
expect(conn.source).toBe("from-options");
102+
conn.disconnect();
103+
});
104+
105+
it("updates the current source via setSource()", () => {
106+
const ctrl = createControllableStream();
107+
const { axon } = createMockAxon(ctrl);
108+
109+
const conn = new ACPAxonConnection(axon as never, { id: "dbx-test" } as never);
110+
conn.setSource("my-client");
111+
expect(conn.source).toBe("my-client");
112+
conn.setSource(undefined);
113+
expect(conn.source).toBeUndefined();
114+
conn.disconnect();
115+
});
116+
});
117+
84118
describe("default permission handler", () => {
85119
it("prefers allow_always when available", async () => {
86120
const ctrl = createControllableStream();

sdk/src/acp/connection.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,14 @@ export class ACPAxonConnection {
136136
/** Optional teardown callback invoked by {@link disconnect}. */
137137
private disconnectFn: (() => void | Promise<void>) | undefined;
138138

139+
/**
140+
* The `source` attached to outbound events. Read by the stream at publish
141+
* time, so mutating it via {@link setSource} affects subsequent messages
142+
* without reconnecting. `undefined` means the stream's built-in default
143+
* is used.
144+
*/
145+
private currentSource: string | undefined;
146+
139147
private log: LogFn;
140148

141149
/**
@@ -159,6 +167,7 @@ export class ACPAxonConnection {
159167
this.handleError = options?.onError ?? makeDefaultOnError("ACPAxonConnection");
160168
this.handlePermission = options?.requestPermission;
161169
this.disconnectFn = options?.onDisconnect;
170+
this.currentSource = options?.source;
162171
this.axonEventListeners = new ListenerSet<AxonEventListener>(this.handleError);
163172
this.timelineEventListeners = new ListenerSet<TimelineEventListener<ACPTimelineEvent>>(
164173
this.handleError,
@@ -200,6 +209,7 @@ export class ACPAxonConnection {
200209
log: verbose ? (tag, ...args) => this.log(tag, ...args) : undefined,
201210
afterSequence: this.options.afterSequence,
202211
replayTargetSequence,
212+
source: () => this.currentSource,
203213
});
204214

205215
const customCreateClient = this.options.createClient;
@@ -291,6 +301,34 @@ export class ACPAxonConnection {
291301
return this.protocol.prompt(params);
292302
}
293303

304+
/**
305+
* The `source` string currently attached to events published by this
306+
* connection (e.g. prompts via {@link prompt}). `undefined` means the
307+
* built-in default (`"acp-sdk-client"`) is used.
308+
*/
309+
get source(): string | undefined {
310+
return this.currentSource;
311+
}
312+
313+
/**
314+
* Sets the `source` string attached to subsequently published events.
315+
*
316+
* Takes effect immediately and applies to every later message published
317+
* through the stream (such as {@link prompt}) until changed again — set it
318+
* before a message to control that message's `source`. Pass `undefined` to
319+
* restore the built-in default.
320+
*
321+
* Because prompts share one underlying stream, set the source before
322+
* issuing a prompt and avoid overlapping prompts that need different
323+
* sources — concurrent in-flight messages observe whichever value is
324+
* current when each is published.
325+
*
326+
* @param source - The source identifier, or `undefined` for the default.
327+
*/
328+
setSource(source: string | undefined): void {
329+
this.currentSource = source;
330+
}
331+
294332
/**
295333
* Cancels an ongoing prompt turn for a session.
296334
*

sdk/src/acp/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,17 @@ export interface AxonStreamOptions {
5454
* enqueued. After replay ends, only unresolved requests are enqueued.
5555
*/
5656
replayTargetSequence?: number;
57+
58+
/**
59+
* The `source` string attached to every published Axon event, or a
60+
* resolver invoked at publish time to obtain it. Use the resolver form to
61+
* change the `source` between messages without recreating the stream
62+
* (e.g. `() => this.currentSource`). When omitted, or when the resolver
63+
* returns `undefined`, the default is used.
64+
*
65+
* @defaultValue `"acp-sdk-client"`
66+
*/
67+
source?: string | (() => string | undefined);
5768
}
5869

5970
/**

sdk/src/claude/connection.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,30 @@ describe("ClaudeAxonConnection", () => {
260260
});
261261
});
262262

263+
describe("source / setSource()", () => {
264+
it("defaults to undefined (transport supplies the built-in default)", async () => {
265+
const conn = await createConnectedClient(transport);
266+
expect(conn.source).toBeUndefined();
267+
});
268+
269+
it("reflects the value passed via options", async () => {
270+
const axon = createMockAxon();
271+
const conn = new ClaudeAxonConnection(axon as never, { id: "dbx-test" } as never, {
272+
source: "from-options",
273+
replay: false,
274+
});
275+
expect(conn.source).toBe("from-options");
276+
});
277+
278+
it("updates the current source via setSource()", async () => {
279+
const conn = await createConnectedClient(transport);
280+
conn.setSource("my-client");
281+
expect(conn.source).toBe("my-client");
282+
conn.setSource(undefined);
283+
expect(conn.source).toBeUndefined();
284+
});
285+
});
286+
263287
describe("publish()", () => {
264288
it("delegates to axon.publish() with the provided params", async () => {
265289
const conn = await createConnectedClient(transport);

sdk/src/claude/connection.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,14 @@ export class ClaudeAxonConnection {
205205
/** Optional teardown callback invoked by {@link disconnect}. */
206206
private disconnectFn: (() => void | Promise<void>) | undefined;
207207

208+
/**
209+
* The `source` attached to outbound events. Read by the transport at
210+
* publish time, so mutating it via {@link setSource} affects subsequent
211+
* messages without recreating the transport. `undefined` means the
212+
* transport's built-in default is used.
213+
*/
214+
private currentSource: string | undefined;
215+
208216
private static readonly MESSAGE_QUEUE_HIGH_WATER_MARK = 1000;
209217

210218
/** Buffer of SDK messages that arrived before a consumer called {@link nextMessage}. */
@@ -282,6 +290,7 @@ export class ClaudeAxonConnection {
282290
this.options = options ?? {};
283291
this.handleError = options?.onError ?? makeDefaultOnError("ClaudeAxonConnection");
284292
this.disconnectFn = options?.onDisconnect;
293+
this.currentSource = options?.source;
285294
this.log = makeLogger("claude-sdk", options?.verbose ?? false);
286295
this.axonEventListeners = new ListenerSet<AxonEventListener>(this.handleError);
287296
this.timelineEventListeners = new ListenerSet<TimelineEventListener<ClaudeTimelineEvent>>(
@@ -335,6 +344,7 @@ export class ClaudeAxonConnection {
335344
},
336345
afterSequence: this.options.afterSequence,
337346
replayTargetSequence,
347+
source: () => this.currentSource,
338348
});
339349
}
340350

@@ -888,6 +898,30 @@ export class ClaudeAxonConnection {
888898
* await conn.send("What files are in this directory?");
889899
* ```
890900
*/
901+
/**
902+
* The `source` string currently attached to events published by this
903+
* connection (e.g. user prompts via {@link send}). `undefined` means the
904+
* built-in default (`"claude-sdk-client"`) is used.
905+
*/
906+
get source(): string | undefined {
907+
return this.currentSource;
908+
}
909+
910+
/**
911+
* Sets the `source` string attached to subsequently published events.
912+
*
913+
* Takes effect immediately and applies to every later message published
914+
* through the transport (such as {@link send}) until changed again — set
915+
* it before a message to control that message's `source`. Pass `undefined`
916+
* to restore the built-in default. (The lower-level {@link publish} method
917+
* is unaffected; it sets `source` directly from its params.)
918+
*
919+
* @param source - The source identifier, or `undefined` for the default.
920+
*/
921+
setSource(source: string | undefined): void {
922+
this.currentSource = source;
923+
}
924+
891925
async send(prompt: string | SDKUserMessage): Promise<void> {
892926
if (this.fatal) {
893927
throw new ConnectionStateError(

sdk/src/claude/transport.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,35 @@ describe("AxonTransport", () => {
117117
});
118118
});
119119

120+
it("uses a custom source when provided via options", async () => {
121+
const custom = new AxonTransport(axon as never, { source: "my-custom-client" });
122+
await custom.connect();
123+
await custom.write(JSON.stringify({ type: "user" }));
124+
125+
expect(axon.publish.mock.calls[0][0].source).toBe("my-custom-client");
126+
});
127+
128+
it("resolves the source lazily on each write when given a resolver", async () => {
129+
let current = "first";
130+
const custom = new AxonTransport(axon as never, { source: () => current });
131+
await custom.connect();
132+
133+
await custom.write(JSON.stringify({ type: "user" }));
134+
current = "second";
135+
await custom.write(JSON.stringify({ type: "user" }));
136+
137+
expect(axon.publish.mock.calls[0][0].source).toBe("first");
138+
expect(axon.publish.mock.calls[1][0].source).toBe("second");
139+
});
140+
141+
it("falls back to the default when the resolver returns undefined", async () => {
142+
const custom = new AxonTransport(axon as never, { source: () => undefined });
143+
await custom.connect();
144+
await custom.write(JSON.stringify({ type: "user" }));
145+
146+
expect(axon.publish.mock.calls[0][0].source).toBe("claude-sdk-client");
147+
});
148+
120149
it("uses the type value itself when not in mapping table", async () => {
121150
await transport.connect();
122151
await transport.write(JSON.stringify({ type: "custom_type" }));

0 commit comments

Comments
 (0)