From a6427cd9ba5524963ba7da9aa6892e97554c2fdb Mon Sep 17 00:00:00 2001 From: Paul Jakob Kroker Date: Fri, 19 Jun 2026 15:36:11 +0200 Subject: [PATCH 1/5] fix(bb-agent): reject empty channelId in stream() An empty channelId produced an invalid Realtime channel path, causing client subscriptions to silently receive no chunks. Now throws ValidationFailedException immediately. --- .changeset/fix-bb-agent-empty-channel-id.md | 7 +++++++ packages/bb-agent/src/agent.ts | 1 + packages/bb-agent/src/errors.ts | 1 + packages/bb-agent/src/index.test.ts | 13 +++++++++++++ 4 files changed, 22 insertions(+) create mode 100644 .changeset/fix-bb-agent-empty-channel-id.md diff --git a/.changeset/fix-bb-agent-empty-channel-id.md b/.changeset/fix-bb-agent-empty-channel-id.md new file mode 100644 index 00000000..c9704b60 --- /dev/null +++ b/.changeset/fix-bb-agent-empty-channel-id.md @@ -0,0 +1,7 @@ +--- +"@aws-blocks/bb-agent": patch +--- + +fix(bb-agent): reject empty channelId in stream() + +An empty `channelId` produced an invalid Realtime channel path, causing the client subscription to silently receive no chunks. Now throws `ValidationFailedException` immediately. diff --git a/packages/bb-agent/src/agent.ts b/packages/bb-agent/src/agent.ts index 211c81d6..95387de6 100644 --- a/packages/bb-agent/src/agent.ts +++ b/packages/bb-agent/src/agent.ts @@ -402,6 +402,7 @@ export class AgentBase extends Scope { * Subscribe to chunks via result.channel, or await result.complete() for the final response. */ async stream(message: string, options?: StreamOptions): Promise { + if (options?.channelId !== undefined && !options.channelId) throw blocksAgentError(AgentErrors.ValidationFailed, 'channelId must not be empty when provided'); const conversationId = options?.conversationId; const channelId = options?.channelId ?? conversationId ?? crypto.randomUUID(); if (!options?.userId && !this.config.inferenceOnly) throw blocksAgentError(AgentErrors.PersistenceRequired, 'userId is required when persistence is enabled. Pass it via options.userId.'); diff --git a/packages/bb-agent/src/errors.ts b/packages/bb-agent/src/errors.ts index 6d3d7fcf..9dab8dd3 100644 --- a/packages/bb-agent/src/errors.ts +++ b/packages/bb-agent/src/errors.ts @@ -25,6 +25,7 @@ export const AgentErrors = { BrowserNotSupported: 'BrowserNotSupportedException', StreamFailed: 'StreamFailedException', InterruptRequired: 'InterruptRequiredException', + ValidationFailed: 'ValidationFailedException', } as const; export function blocksAgentError(name: string, message: string): Error { diff --git a/packages/bb-agent/src/index.test.ts b/packages/bb-agent/src/index.test.ts index 4c053a58..49b1dec5 100644 --- a/packages/bb-agent/src/index.test.ts +++ b/packages/bb-agent/src/index.test.ts @@ -115,6 +115,19 @@ describe('needsApproval and interrupt mutual exclusivity', () => { }); }); +// ── stream() input validation ──────────────────────────────────────────────── + +describe('stream() input validation', () => { + test('rejects empty channelId', async () => { + const scope = new Scope('test-empty-ch'); + const agent = new Agent(scope, 'ec', { systemPrompt: 'test', model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } } }); + await assert.rejects( + () => agent.stream('hello', { userId: 'test-user', channelId: '' }), + (err: any) => err.name === 'ValidationFailedException', + ); + }); +}); + // ── tool factory enforcement (compile-time) ────────────────────────────────── describe('tool factory enforcement', () => { From 4237ef6e6f19a9552121d766252b5dc116d4caf7 Mon Sep 17 00:00:00 2001 From: Paul Jakob Kroker Date: Fri, 19 Jun 2026 15:59:06 +0200 Subject: [PATCH 2/5] chore: regenerate bb-agent API report --- packages/bb-agent/API.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/bb-agent/API.md b/packages/bb-agent/API.md index 8b90feb1..74deefc2 100644 --- a/packages/bb-agent/API.md +++ b/packages/bb-agent/API.md @@ -54,6 +54,7 @@ export const AgentErrors: { readonly BrowserNotSupported: "BrowserNotSupportedException"; readonly StreamFailed: "StreamFailedException"; readonly InterruptRequired: "InterruptRequiredException"; + readonly ValidationFailed: "ValidationFailedException"; }; // @public (undocumented) From f0a32da06627fbcc92a6ff72641d62147b011081 Mon Sep 17 00:00:00 2001 From: Paul Jakob Kroker Date: Fri, 19 Jun 2026 18:26:07 +0200 Subject: [PATCH 3/5] fix(bb-agent): treat empty channelId/conversationId as unset in stream() Empty strings now fall back to the next value in the chain (channelId || conversationId || randomUUID) instead of being used literally. Prevents accidental channel collision. --- .changeset/fix-bb-agent-empty-channel-id.md | 4 ++-- packages/bb-agent/README.md | 2 +- packages/bb-agent/src/agent.ts | 3 +-- packages/bb-agent/src/errors.ts | 1 - packages/bb-agent/src/index.test.ts | 19 ++++++++++++------- packages/bb-agent/src/types.ts | 2 +- 6 files changed, 17 insertions(+), 14 deletions(-) diff --git a/.changeset/fix-bb-agent-empty-channel-id.md b/.changeset/fix-bb-agent-empty-channel-id.md index c9704b60..995073d0 100644 --- a/.changeset/fix-bb-agent-empty-channel-id.md +++ b/.changeset/fix-bb-agent-empty-channel-id.md @@ -2,6 +2,6 @@ "@aws-blocks/bb-agent": patch --- -fix(bb-agent): reject empty channelId in stream() +fix(bb-agent): treat empty channelId as unset in stream() -An empty `channelId` produced an invalid Realtime channel path, causing the client subscription to silently receive no chunks. Now throws `ValidationFailedException` immediately. +An empty `channelId` now falls back to `conversationId` or a random UUID, preventing all streams from sharing the same channel. Empty strings are treated as unset rather than used literally. diff --git a/packages/bb-agent/README.md b/packages/bb-agent/README.md index 4e4c974a..f46b3ec7 100644 --- a/packages/bb-agent/README.md +++ b/packages/bb-agent/README.md @@ -47,7 +47,7 @@ const agent = new Agent(scope, id, config) | `getPendingInterrupts(conversationId)` | `Promise>` | Get unanswered interrupts (for reload support). | | `getChannel(channelId)` | `Promise` | Get a Realtime channel for subscribing to chunks. | -`stream()` submits the message to AsyncJob and returns immediately — no API Gateway timeout risk. The agent runs asynchronously and publishes chunks to Realtime. +`stream()` submits the message to AsyncJob and returns immediately — no API Gateway timeout risk. The agent runs asynchronously and publishes chunks to Realtime. The channel ID is resolved as `options.channelId || options.conversationId || crypto.randomUUID()` — empty strings are treated as unset and fall through to the next value. **Important: Subscribe before sending.** The agent starts emitting chunks immediately after `stream()` is called. If you subscribe to the channel after calling `stream()`, early chunks may be dropped. Always subscribe first, await `established`, then send: diff --git a/packages/bb-agent/src/agent.ts b/packages/bb-agent/src/agent.ts index 95387de6..6dfebf32 100644 --- a/packages/bb-agent/src/agent.ts +++ b/packages/bb-agent/src/agent.ts @@ -402,9 +402,8 @@ export class AgentBase extends Scope { * Subscribe to chunks via result.channel, or await result.complete() for the final response. */ async stream(message: string, options?: StreamOptions): Promise { - if (options?.channelId !== undefined && !options.channelId) throw blocksAgentError(AgentErrors.ValidationFailed, 'channelId must not be empty when provided'); const conversationId = options?.conversationId; - const channelId = options?.channelId ?? conversationId ?? crypto.randomUUID(); + const channelId = options?.channelId || conversationId || crypto.randomUUID(); if (!options?.userId && !this.config.inferenceOnly) throw blocksAgentError(AgentErrors.PersistenceRequired, 'userId is required when persistence is enabled. Pass it via options.userId.'); const userId = options?.userId ?? 'anonymous'; const context = this.resolveContext(options?.context); diff --git a/packages/bb-agent/src/errors.ts b/packages/bb-agent/src/errors.ts index 9dab8dd3..6d3d7fcf 100644 --- a/packages/bb-agent/src/errors.ts +++ b/packages/bb-agent/src/errors.ts @@ -25,7 +25,6 @@ export const AgentErrors = { BrowserNotSupported: 'BrowserNotSupportedException', StreamFailed: 'StreamFailedException', InterruptRequired: 'InterruptRequiredException', - ValidationFailed: 'ValidationFailedException', } as const; export function blocksAgentError(name: string, message: string): Error { diff --git a/packages/bb-agent/src/index.test.ts b/packages/bb-agent/src/index.test.ts index 49b1dec5..ae5c7e92 100644 --- a/packages/bb-agent/src/index.test.ts +++ b/packages/bb-agent/src/index.test.ts @@ -115,16 +115,21 @@ describe('needsApproval and interrupt mutual exclusivity', () => { }); }); -// ── stream() input validation ──────────────────────────────────────────────── +// ── stream() empty channelId fallback ──────────────────────────────────────── -describe('stream() input validation', () => { - test('rejects empty channelId', async () => { +describe('stream() empty channelId fallback', () => { + test('empty channelId falls back to a generated value', async () => { const scope = new Scope('test-empty-ch'); const agent = new Agent(scope, 'ec', { systemPrompt: 'test', model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } } }); - await assert.rejects( - () => agent.stream('hello', { userId: 'test-user', channelId: '' }), - (err: any) => err.name === 'ValidationFailedException', - ); + const result = await agent.stream('hello', { userId: 'test-user', channelId: '' }); + assert.ok(result.channelId.length > 0, 'empty channelId should fall back to a non-empty value'); + }); + + test('empty conversationId falls back to a generated value', async () => { + const scope = new Scope('test-empty-conv'); + const agent = new Agent(scope, 'ev', { systemPrompt: 'test', model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } } }); + const result = await agent.stream('hello', { userId: 'test-user', conversationId: '' }); + assert.ok(result.channelId.length > 0, 'empty conversationId should fall back to a non-empty channelId'); }); }); diff --git a/packages/bb-agent/src/types.ts b/packages/bb-agent/src/types.ts index 39f632cd..118a9a01 100644 --- a/packages/bb-agent/src/types.ts +++ b/packages/bb-agent/src/types.ts @@ -251,7 +251,7 @@ export interface ToolCallRecord { export interface StreamOptions { conversationId?: string; - /** Channel ID for Realtime delivery. Defaults to conversationId or a random UUID. */ + /** Channel ID for Realtime delivery. Defaults to conversationId or a random UUID. Empty strings are treated as unset. */ channelId?: string; /** User ID for conversation scoping. Defaults to 'anonymous'. */ userId?: string; From 9653e6e132f1545134c63a27dd90195a2a8c2544 Mon Sep 17 00:00:00 2001 From: Paul Jakob Kroker Date: Fri, 19 Jun 2026 18:30:12 +0200 Subject: [PATCH 4/5] chore: regenerate bb-agent API report --- packages/bb-agent/API.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/bb-agent/API.md b/packages/bb-agent/API.md index 74deefc2..8b90feb1 100644 --- a/packages/bb-agent/API.md +++ b/packages/bb-agent/API.md @@ -54,7 +54,6 @@ export const AgentErrors: { readonly BrowserNotSupported: "BrowserNotSupportedException"; readonly StreamFailed: "StreamFailedException"; readonly InterruptRequired: "InterruptRequiredException"; - readonly ValidationFailed: "ValidationFailedException"; }; // @public (undocumented) From f1e1fa995baba0836d233add4fdebbdb60ee4538 Mon Sep 17 00:00:00 2001 From: Paul Jakob Kroker Date: Fri, 19 Jun 2026 18:39:53 +0200 Subject: [PATCH 5/5] test(bb-agent): improve empty channelId/conversationId fallback assertions --- packages/bb-agent/src/index.test.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/bb-agent/src/index.test.ts b/packages/bb-agent/src/index.test.ts index ae5c7e92..40b23168 100644 --- a/packages/bb-agent/src/index.test.ts +++ b/packages/bb-agent/src/index.test.ts @@ -118,18 +118,20 @@ describe('needsApproval and interrupt mutual exclusivity', () => { // ── stream() empty channelId fallback ──────────────────────────────────────── describe('stream() empty channelId fallback', () => { - test('empty channelId falls back to a generated value', async () => { + test('empty channelId is treated as unset', async () => { const scope = new Scope('test-empty-ch'); const agent = new Agent(scope, 'ec', { systemPrompt: 'test', model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } } }); const result = await agent.stream('hello', { userId: 'test-user', channelId: '' }); - assert.ok(result.channelId.length > 0, 'empty channelId should fall back to a non-empty value'); + assert.notStrictEqual(result.channelId, ''); + assert.ok(result.channelId.length > 0); }); - test('empty conversationId falls back to a generated value', async () => { + test('empty conversationId is treated as unset', async () => { const scope = new Scope('test-empty-conv'); const agent = new Agent(scope, 'ev', { systemPrompt: 'test', model: { deployed: { provider: 'canned' }, local: { provider: 'canned' } } }); const result = await agent.stream('hello', { userId: 'test-user', conversationId: '' }); - assert.ok(result.channelId.length > 0, 'empty conversationId should fall back to a non-empty channelId'); + assert.notStrictEqual(result.channelId, ''); + assert.ok(result.channelId.length > 0); }); });