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..995073d0 --- /dev/null +++ b/.changeset/fix-bb-agent-empty-channel-id.md @@ -0,0 +1,7 @@ +--- +"@aws-blocks/bb-agent": patch +--- + +fix(bb-agent): treat empty channelId as unset in stream() + +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 211c81d6..6dfebf32 100644 --- a/packages/bb-agent/src/agent.ts +++ b/packages/bb-agent/src/agent.ts @@ -403,7 +403,7 @@ export class AgentBase extends Scope { */ async stream(message: string, options?: StreamOptions): Promise { 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/index.test.ts b/packages/bb-agent/src/index.test.ts index 4c053a58..40b23168 100644 --- a/packages/bb-agent/src/index.test.ts +++ b/packages/bb-agent/src/index.test.ts @@ -115,6 +115,26 @@ describe('needsApproval and interrupt mutual exclusivity', () => { }); }); +// ── stream() empty channelId fallback ──────────────────────────────────────── + +describe('stream() empty channelId fallback', () => { + 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.notStrictEqual(result.channelId, ''); + assert.ok(result.channelId.length > 0); + }); + + 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.notStrictEqual(result.channelId, ''); + assert.ok(result.channelId.length > 0); + }); +}); + // ── tool factory enforcement (compile-time) ────────────────────────────────── describe('tool factory enforcement', () => { 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;