From 324a52da1086915f204ecda569e519026e7e082e Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Tue, 4 Nov 2025 15:37:19 -0700 Subject: [PATCH 1/5] feat(api): add agent location and API integration - Introduced LOCATION_AGENT in locations for agent-specific functionality. - Added createAgent import and integrated makeAgentAPI for handling agent data in the API. - Updated types to include AgentAppSDK and related agent context types. --- lib/agent.ts | 25 +++++++++++++++++++++++++ lib/api.ts | 8 ++++++++ lib/locations.ts | 1 + lib/types/agent.types.ts | 12 ++++++++++++ lib/types/api.types.ts | 12 ++++++++++++ lib/types/index.ts | 3 +++ 6 files changed, 61 insertions(+) create mode 100644 lib/agent.ts create mode 100644 lib/types/agent.types.ts diff --git a/lib/agent.ts b/lib/agent.ts new file mode 100644 index 0000000000..66110a3a7b --- /dev/null +++ b/lib/agent.ts @@ -0,0 +1,25 @@ +import { Channel } from './channel' +import { MemoizedSignal } from './signal' +import { AgentAPI, AgentContext, ConnectMessage } from './types' + +export default function createAgent( + channel: Channel, + contextData: ConnectMessage['agent'], +): AgentAPI { + if (!contextData) { + throw new Error('Context data is required') + } + + let context: AgentContext = contextData + + const contextChanged = new MemoizedSignal<[AgentContext]>(context) + + channel.addHandler('contextChanged', (newContext: AgentContext) => { + context = newContext + contextChanged.dispatch(context) + }) + + return { + onContextChange: (handler) => contextChanged.attach(handler), + } +} diff --git a/lib/api.ts b/lib/api.ts index 1810f4589a..e3e88ed683 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -8,6 +8,7 @@ import createDialogs from './dialogs' import createEditor from './editor' import createNavigator from './navigator' import createApp from './app' +import createAgent from './agent' import locations from './locations' import { EntryFieldInfo, @@ -45,6 +46,7 @@ const LOCATION_TO_API_PRODUCERS: { [location: string]: ProducerFunc[] } = { [locations.LOCATION_PAGE]: [makeSharedAPI], [locations.LOCATION_HOME]: [makeSharedAPI], [locations.LOCATION_APP_CONFIG]: [makeSharedAPI, makeAppAPI], + [locations.LOCATION_AGENT]: [makeSharedAPI, makeAgentAPI], } export default function createAPI( @@ -166,3 +168,9 @@ function makeAppAPI(channel: Channel) { app, } } + +function makeAgentAPI(channel: Channel, { agent }: ConnectMessage) { + return { + agent: createAgent(channel, agent), + } +} diff --git a/lib/locations.ts b/lib/locations.ts index 23e604cb42..11650b9dea 100644 --- a/lib/locations.ts +++ b/lib/locations.ts @@ -10,6 +10,7 @@ const locations: Locations = { LOCATION_PAGE: 'page', LOCATION_APP_CONFIG: 'app-config', LOCATION_HOME: 'home', + LOCATION_AGENT: 'agent', } export default locations diff --git a/lib/types/agent.types.ts b/lib/types/agent.types.ts new file mode 100644 index 0000000000..6cfff3cdbb --- /dev/null +++ b/lib/types/agent.types.ts @@ -0,0 +1,12 @@ +export interface AgentContext { + view: string + metadata: { + entryId?: string + contentTypeId?: string + lastFocusedFieldId?: string + } +} + +export interface AgentAPI { + onContextChange: (handler: (context: AgentContext) => void) => VoidFunction +} diff --git a/lib/types/api.types.ts b/lib/types/api.types.ts index 168a0834db..1d7abcc7fa 100644 --- a/lib/types/api.types.ts +++ b/lib/types/api.types.ts @@ -22,6 +22,7 @@ import { NavigatorAPI } from './navigator.types' import { EntryFieldInfo, FieldInfo } from './field.types' import { Adapter, KeyValueMap } from 'contentful-management/types' import { CMAClient } from './cmaClient.types' +import { AgentAPI, AgentContext } from './agent.types' /* User API */ @@ -342,6 +343,14 @@ export type ConfigAppSDK = BaseAppSDK< + InstallationParameters, + never, + never +> & { + agent: AgentAPI +} + export type KnownAppSDK< InstallationParameters extends KeyValueMap = KeyValueMap, InstanceParameters extends KeyValueMap = KeyValueMap, @@ -354,6 +363,7 @@ export type KnownAppSDK< | PageAppSDK | ConfigAppSDK | HomeAppSDK + | AgentAppSDK /** @deprecated consider using {@link BaseAppSDK} */ export type BaseExtensionSDK = BaseAppSDK @@ -392,6 +402,7 @@ export interface Locations { LOCATION_PAGE: 'page' LOCATION_HOME: 'home' LOCATION_APP_CONFIG: 'app-config' + LOCATION_AGENT: 'agent' } export interface ConnectMessage { @@ -421,4 +432,5 @@ export interface ConnectMessage { hostnames: HostnamesAPI release?: Release uiLanguageLocale: string + agent?: AgentContext } diff --git a/lib/types/index.ts b/lib/types/index.ts index 1a38a633ae..1990f25351 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -34,8 +34,11 @@ export type { UserAPI, JSONPatchItem, HostnamesAPI, + AgentAppSDK, } from './api.types' +export type { AgentAPI, AgentContext } from './agent.types' + export type { AppConfigAPI, AppState, From d3c5021253ee50d0eca08374227f9ed65478388b Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Tue, 4 Nov 2025 15:58:30 -0700 Subject: [PATCH 2/5] refactor(agent): streamline context handling and enhance API tests - Updated context handling in createAgent to directly dispatch new context without intermediate variable. - Added new connectMessageWithAgent mock for testing agent-specific functionality. - Expanded unit tests to verify agent API integration, including context handling and error scenarios for missing agent data. --- lib/agent.ts | 7 +- test/mocks/agent.ts | 22 ++++ test/mocks/connectMessage.ts | 7 + test/unit/agent.spec.ts | 243 +++++++++++++++++++++++++++++++++++ test/unit/api.spec.ts | 110 +++++++++++++++- 5 files changed, 382 insertions(+), 7 deletions(-) create mode 100644 test/mocks/agent.ts create mode 100644 test/unit/agent.spec.ts diff --git a/lib/agent.ts b/lib/agent.ts index 66110a3a7b..f02c85fe0a 100644 --- a/lib/agent.ts +++ b/lib/agent.ts @@ -10,13 +10,10 @@ export default function createAgent( throw new Error('Context data is required') } - let context: AgentContext = contextData - - const contextChanged = new MemoizedSignal<[AgentContext]>(context) + const contextChanged = new MemoizedSignal<[AgentContext]>(contextData) channel.addHandler('contextChanged', (newContext: AgentContext) => { - context = newContext - contextChanged.dispatch(context) + contextChanged.dispatch(newContext) }) return { diff --git a/test/mocks/agent.ts b/test/mocks/agent.ts new file mode 100644 index 0000000000..a1b1c0e10b --- /dev/null +++ b/test/mocks/agent.ts @@ -0,0 +1,22 @@ +import { AgentContext } from '../../lib/types' + +export const mockAgentContext: AgentContext = { + view: 'entry-editor', + metadata: { + entryId: 'test-entry-123', + contentTypeId: 'test-content-type-456', + lastFocusedFieldId: 'test-field-789', + }, +} + +export const mockAgentContextMinimal: AgentContext = { + view: 'home', + metadata: {}, +} + +export const mockAgentContextWithPartialMetadata: AgentContext = { + view: 'page', + metadata: { + entryId: 'entry-abc', + }, +} diff --git a/test/mocks/connectMessage.ts b/test/mocks/connectMessage.ts index 572cc3e3a9..116c9db534 100644 --- a/test/mocks/connectMessage.ts +++ b/test/mocks/connectMessage.ts @@ -1,4 +1,5 @@ import { ConnectMessage } from '../../lib/types' +import { mockAgentContext } from './agent' export const baseConnectMessage: ConnectMessage = { id: 'test-app-id', @@ -207,4 +208,10 @@ export const baseConnectMessage: ConnectMessage = { uiLanguageLocale: 'en-US', } as any // Type assertion to work with complex nested types in testing +export const connectMessageWithAgent: ConnectMessage = { + ...baseConnectMessage, + location: 'agent', + agent: mockAgentContext, +} as any + export default baseConnectMessage diff --git a/test/unit/agent.spec.ts b/test/unit/agent.spec.ts new file mode 100644 index 0000000000..067cb1c70f --- /dev/null +++ b/test/unit/agent.spec.ts @@ -0,0 +1,243 @@ +import { describeAttachHandlerMember, sinon, expect } from '../helpers' + +import createAgent from '../../lib/agent' +import { Channel } from '../../lib/channel' +import { AgentContext } from '../../lib/types' +import { + mockAgentContext, + mockAgentContextMinimal, + mockAgentContextWithPartialMetadata, +} from '../mocks/agent' + +describe('createAgent()', () => { + let channelStub: any + + beforeEach(() => { + channelStub = { + addHandler: sinon.stub(), + call: sinon.stub(), + } as unknown as Channel + }) + + describe('construction error', () => { + it('throws an error when contextData is undefined', () => { + expect(() => { + createAgent(channelStub, undefined) + }).to.throw('Context data is required') + }) + + it('throws an error when contextData is null', () => { + expect(() => { + createAgent(channelStub, null as any) + }).to.throw('Context data is required') + }) + }) + + describe('instance with full context', () => { + let agent: ReturnType + + beforeEach(() => { + agent = createAgent(channelStub, mockAgentContext) + }) + + describe('API shape', () => { + it('returns an object with onContextChange method', () => { + expect(agent).to.have.all.keys(['onContextChange']) + expect(agent.onContextChange).to.be.a('function') + }) + }) + + describe('.onContextChange(handler)', () => { + describeAttachHandlerMember('default behaviour', () => { + return agent.onContextChange(() => {}) + }) + + it('calls handler immediately with initial context', () => { + const handler = sinon.stub() + agent.onContextChange(handler) + + expect(handler).to.have.been.calledOnce // eslint-disable-line no-unused-expressions + expect(handler).to.have.been.calledWith(mockAgentContext) + }) + + it('calls handler with context containing all metadata fields', () => { + const handler = sinon.stub() + agent.onContextChange(handler) + + const receivedContext = handler.getCall(0).args[0] + expect(receivedContext.view).to.equal('entry-editor') + expect(receivedContext.metadata.entryId).to.equal('test-entry-123') + expect(receivedContext.metadata.contentTypeId).to.equal('test-content-type-456') + expect(receivedContext.metadata.lastFocusedFieldId).to.equal('test-field-789') + }) + }) + + describe('context change handling', () => { + it('registers contextChanged handler with channel', () => { + expect(channelStub.addHandler).to.have.been.calledOnce // eslint-disable-line no-unused-expressions + expect(channelStub.addHandler).to.have.been.calledWith('contextChanged', sinon.match.func) + }) + + it('calls handler when contextChanged event is received', () => { + const handler = sinon.stub() + agent.onContextChange(handler) + handler.resetHistory() // Reset the initial call + + const newContext: AgentContext = { + view: 'page', + metadata: { + entryId: 'new-entry', + }, + } + + // Simulate channel dispatching contextChanged event + const contextChangedHandler = channelStub.addHandler.getCall(0).args[1] + contextChangedHandler(newContext) + + expect(handler).to.have.been.calledOnce // eslint-disable-line no-unused-expressions + expect(handler).to.have.been.calledWith(newContext) + }) + + it('calls multiple handlers when contextChanged event is received', () => { + const handler1 = sinon.stub() + const handler2 = sinon.stub() + + agent.onContextChange(handler1) + agent.onContextChange(handler2) + + handler1.resetHistory() + handler2.resetHistory() + + const newContext: AgentContext = { + view: 'home', + metadata: {}, + } + + const contextChangedHandler = channelStub.addHandler.getCall(0).args[1] + contextChangedHandler(newContext) + + expect(handler1).to.have.been.calledOnce // eslint-disable-line no-unused-expressions + expect(handler1).to.have.been.calledWith(newContext) + expect(handler2).to.have.been.calledOnce // eslint-disable-line no-unused-expressions + expect(handler2).to.have.been.calledWith(newContext) + }) + + it('does not call detached handler when contextChanged event is received', () => { + const handler = sinon.stub() + const detach = agent.onContextChange(handler) + + handler.resetHistory() + detach() + + const newContext: AgentContext = { + view: 'page', + metadata: {}, + } + + const contextChangedHandler = channelStub.addHandler.getCall(0).args[1] + contextChangedHandler(newContext) + + expect(handler).to.not.have.been.called // eslint-disable-line no-unused-expressions + }) + + it('continues calling remaining handlers after one is detached', () => { + const handler1 = sinon.stub() + const handler2 = sinon.stub() + const handler3 = sinon.stub() + + const detach1 = agent.onContextChange(handler1) + agent.onContextChange(handler2) + agent.onContextChange(handler3) + + handler1.resetHistory() + handler2.resetHistory() + handler3.resetHistory() + + detach1() + + const newContext: AgentContext = { + view: 'dialog', + metadata: { entryId: 'test' }, + } + + const contextChangedHandler = channelStub.addHandler.getCall(0).args[1] + contextChangedHandler(newContext) + + expect(handler1).to.not.have.been.called // eslint-disable-line no-unused-expressions + expect(handler2).to.have.been.calledOnce // eslint-disable-line no-unused-expressions + expect(handler3).to.have.been.calledOnce // eslint-disable-line no-unused-expressions + }) + }) + }) + + describe('instance with minimal context', () => { + let agent: ReturnType + + beforeEach(() => { + agent = createAgent(channelStub, mockAgentContextMinimal) + }) + + it('calls handler immediately with minimal context', () => { + const handler = sinon.stub() + agent.onContextChange(handler) + + expect(handler).to.have.been.calledOnce // eslint-disable-line no-unused-expressions + expect(handler).to.have.been.calledWith(mockAgentContextMinimal) + }) + + it('handles context with empty metadata object', () => { + const handler = sinon.stub() + agent.onContextChange(handler) + + const receivedContext = handler.getCall(0).args[0] + expect(receivedContext.view).to.equal('home') + expect(receivedContext.metadata).to.deep.equal({}) + }) + }) + + describe('instance with partial metadata', () => { + let agent: ReturnType + + beforeEach(() => { + agent = createAgent(channelStub, mockAgentContextWithPartialMetadata) + }) + + it('calls handler with context containing partial metadata', () => { + const handler = sinon.stub() + agent.onContextChange(handler) + + const receivedContext = handler.getCall(0).args[0] + expect(receivedContext.view).to.equal('page') + expect(receivedContext.metadata.entryId).to.equal('entry-abc') + expect(receivedContext.metadata.contentTypeId).to.be.undefined // eslint-disable-line no-unused-expressions + expect(receivedContext.metadata.lastFocusedFieldId).to.be.undefined // eslint-disable-line no-unused-expressions + }) + }) + + describe('sequential context updates', () => { + it('handlers receive each context update in order', () => { + const agent = createAgent(channelStub, mockAgentContext) + const handler = sinon.stub() + agent.onContextChange(handler) + + handler.resetHistory() + + const contexts: AgentContext[] = [ + { view: 'entry-editor', metadata: { entryId: 'entry-1' } }, + { view: 'page', metadata: { entryId: 'entry-2' } }, + { view: 'home', metadata: {} }, + ] + + const contextChangedHandler = channelStub.addHandler.getCall(0).args[1] + + contexts.forEach((context) => { + contextChangedHandler(context) + }) + + expect(handler).to.have.callCount(3) + expect(handler.getCall(0).args[0]).to.deep.equal(contexts[0]) + expect(handler.getCall(1).args[0]).to.deep.equal(contexts[1]) + expect(handler.getCall(2).args[0]).to.deep.equal(contexts[2]) + }) + }) +}) diff --git a/test/unit/api.spec.ts b/test/unit/api.spec.ts index 8c54f4c1f6..7c051aad76 100644 --- a/test/unit/api.spec.ts +++ b/test/unit/api.spec.ts @@ -2,9 +2,10 @@ import { makeDOM, mockMutationObserver, expect, mockResizeObserver } from '../he import createAPI from '../../lib/api' import locations from '../../lib/locations' -import { ConfigAppSDK, ConnectMessage } from '../../lib/types' +import { AgentAppSDK, ConfigAppSDK, ConnectMessage } from '../../lib/types' import { mockRelease, mockReleaseWithoutEntities } from '../mocks/releases' -import { baseConnectMessage } from '../mocks/connectMessage' +import { baseConnectMessage, connectMessageWithAgent } from '../mocks/connectMessage' +import { mockAgentContext, mockAgentContextMinimal } from '../mocks/agent' const sharedExpected = [ 'location', @@ -146,6 +147,25 @@ describe('createAPI()', () => { 'onConfigurationCompleted', ]) }) + + it('returns correct shape of the agent API (agent)', () => { + const expected = ['agent'] + const channel = { addHandler: () => {} } as any + + const dom = makeDOM() + mockMutationObserver(dom, () => {}) + mockResizeObserver(dom, () => {}) + + const api = createAPI( + channel, + connectMessageWithAgent, + dom.window as any as Window, + ) as unknown as AgentAppSDK + + expect(api).to.have.all.keys(sharedExpected.concat(expected)) + expect(api.agent).to.have.all.keys(['onContextChange']) + expect(api.agent.onContextChange).to.be.a('function') + }) }) describe('Release functionality in SDK', () => { @@ -288,3 +308,89 @@ describe('Release functionality in SDK', () => { }) }) }) + +describe('Agent functionality in SDK', () => { + const channel = { addHandler: () => {} } as any + + it('should include agent property when provided in ConnectMessage for agent location', () => { + const dom = makeDOM() + mockMutationObserver(dom, () => {}) + mockResizeObserver(dom, () => {}) + const api = createAPI(channel, connectMessageWithAgent, dom.window as any) as AgentAppSDK + + expect(api.agent).to.not.equal(undefined) + expect(api.agent.onContextChange).to.be.a('function') + }) + + it('should not include agent property when location is not agent', () => { + const connectMessageWithoutAgent = { + ...baseConnectMessage, + location: 'entry-field', + // no agent property + } + + const dom = makeDOM() + mockMutationObserver(dom, () => {}) + mockResizeObserver(dom, () => {}) + const api = createAPI(channel, connectMessageWithoutAgent, dom.window as any) + + expect((api as any).agent).to.equal(undefined) + }) + + it('should provide agent API with context when agent location is used', () => { + const dom = makeDOM() + mockMutationObserver(dom, () => {}) + mockResizeObserver(dom, () => {}) + const api = createAPI(channel, connectMessageWithAgent, dom.window as any) as AgentAppSDK + + expect(api.agent).to.not.equal(undefined) + expect(api.agent.onContextChange).to.be.a('function') + + // Test that the handler gets called with initial context + let receivedContext: any + api.agent.onContextChange((context) => { + receivedContext = context + }) + + expect(receivedContext).to.deep.equal(mockAgentContext) + }) + + it('should handle agent location with minimal metadata', () => { + const connectMessage = { + ...baseConnectMessage, + location: 'agent', + agent: mockAgentContextMinimal, + } + + const dom = makeDOM() + mockMutationObserver(dom, () => {}) + mockResizeObserver(dom, () => {}) + const api = createAPI(channel, connectMessage, dom.window as any) as AgentAppSDK + + expect(api.agent).to.not.equal(undefined) + + let receivedContext: any + api.agent.onContextChange((context) => { + receivedContext = context + }) + + expect(receivedContext.view).to.equal('home') + expect(receivedContext.metadata).to.deep.equal({}) + }) + + it('should throw error when agent context is not provided for agent location', () => { + const connectMessage = { + ...baseConnectMessage, + location: 'agent', + // no agent property - this should cause an error + } + + const dom = makeDOM() + mockMutationObserver(dom, () => {}) + mockResizeObserver(dom, () => {}) + + expect(() => { + createAPI(channel, connectMessage, dom.window as any) + }).to.throw('Context data is required') + }) +}) From aa0cd85aaaa8a108235ea1db6a3eb89a3255b5b3 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Fri, 7 Nov 2025 11:24:05 -0700 Subject: [PATCH 3/5] feat(agent): add toolbar action handling to agent API - Introduced a new Signal for toolbar actions in createAgent. - Updated the AgentAPI to include onToolbarAction for handling toolbar events. - Defined ToolbarAction type to standardize action structure. --- lib/agent.ts | 10 +++++++++- lib/types/agent.types.ts | 8 ++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/agent.ts b/lib/agent.ts index f02c85fe0a..edfe0c27f5 100644 --- a/lib/agent.ts +++ b/lib/agent.ts @@ -1,6 +1,7 @@ import { Channel } from './channel' -import { MemoizedSignal } from './signal' +import { MemoizedSignal, Signal } from './signal' import { AgentAPI, AgentContext, ConnectMessage } from './types' +import { ToolbarAction } from './types/agent.types' export default function createAgent( channel: Channel, @@ -12,11 +13,18 @@ export default function createAgent( const contextChanged = new MemoizedSignal<[AgentContext]>(contextData) + const toolbarActionSignal = new Signal<[ToolbarAction]>() + channel.addHandler('contextChanged', (newContext: AgentContext) => { contextChanged.dispatch(newContext) }) + channel.addHandler('toolbarAction', (action: ToolbarAction) => { + toolbarActionSignal.dispatch(action) + }) + return { onContextChange: (handler) => contextChanged.attach(handler), + onToolbarAction: (handler) => toolbarActionSignal.attach(handler), } } diff --git a/lib/types/agent.types.ts b/lib/types/agent.types.ts index 6cfff3cdbb..294f6c44ef 100644 --- a/lib/types/agent.types.ts +++ b/lib/types/agent.types.ts @@ -7,6 +7,14 @@ export interface AgentContext { } } +export type ToolbarActionName = 'chat.history' | 'chat.back' | 'chat.close' + +export interface ToolbarAction { + name: ToolbarActionName + action: 'click' +} + export interface AgentAPI { onContextChange: (handler: (context: AgentContext) => void) => VoidFunction + onToolbarAction: (handler: (action: ToolbarAction) => void) => VoidFunction } From 953cfcd5b19aee6ebf9d7f89225c92ea001998ca Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Fri, 7 Nov 2025 11:47:11 -0700 Subject: [PATCH 4/5] test(agent): enhance unit tests for toolbar action handling - Updated agent tests to verify the presence of onToolbarAction method. - Expanded context change handling tests to include toolbarAction event registration. - Added new tests for toolbar action handling, including multiple handlers and action type verification. --- test/unit/agent.spec.ts | 103 ++++++++++++++++++++++++++++++++++++++-- test/unit/api.spec.ts | 3 +- 2 files changed, 100 insertions(+), 6 deletions(-) diff --git a/test/unit/agent.spec.ts b/test/unit/agent.spec.ts index 067cb1c70f..bda6fa138f 100644 --- a/test/unit/agent.spec.ts +++ b/test/unit/agent.spec.ts @@ -41,9 +41,10 @@ describe('createAgent()', () => { }) describe('API shape', () => { - it('returns an object with onContextChange method', () => { - expect(agent).to.have.all.keys(['onContextChange']) + it('returns an object with onContextChange and onToolbarAction methods', () => { + expect(agent).to.have.all.keys(['onContextChange', 'onToolbarAction']) expect(agent.onContextChange).to.be.a('function') + expect(agent.onToolbarAction).to.be.a('function') }) }) @@ -73,9 +74,16 @@ describe('createAgent()', () => { }) describe('context change handling', () => { - it('registers contextChanged handler with channel', () => { - expect(channelStub.addHandler).to.have.been.calledOnce // eslint-disable-line no-unused-expressions - expect(channelStub.addHandler).to.have.been.calledWith('contextChanged', sinon.match.func) + it('registers contextChanged and toolbarAction handlers with channel', () => { + expect(channelStub.addHandler).to.have.been.calledTwice // eslint-disable-line no-unused-expressions + expect(channelStub.addHandler.firstCall).to.have.been.calledWith( + 'contextChanged', + sinon.match.func, + ) + expect(channelStub.addHandler.secondCall).to.have.been.calledWith( + 'toolbarAction', + sinon.match.func, + ) }) it('calls handler when contextChanged event is received', () => { @@ -240,4 +248,89 @@ describe('createAgent()', () => { expect(handler.getCall(2).args[0]).to.deep.equal(contexts[2]) }) }) + + describe('toolbar action handling', () => { + let agent: ReturnType + + beforeEach(() => { + agent = createAgent(channelStub, mockAgentContext) + }) + + describe('.onToolbarAction(handler)', () => { + describeAttachHandlerMember('default behaviour', () => { + return agent.onToolbarAction(() => {}) + }) + + it('does not call handler immediately on attach', () => { + const handler = sinon.stub() + + agent.onToolbarAction(handler) + + expect(handler).to.not.have.been.called // eslint-disable-line no-unused-expressions + }) + + it('calls handler when toolbarAction event is received', () => { + const handler = sinon.stub() + agent.onToolbarAction(handler) + + const action = { name: 'chat.history' as const, action: 'click' as const } + + // Simulate channel dispatching toolbarAction event + const toolbarActionHandler = channelStub.addHandler.secondCall.args[1] + toolbarActionHandler(action) + + expect(handler).to.have.been.calledOnce // eslint-disable-line no-unused-expressions + expect(handler).to.have.been.calledWith(action) + }) + + it('calls multiple handlers when toolbarAction event is received', () => { + const handler1 = sinon.stub() + const handler2 = sinon.stub() + + agent.onToolbarAction(handler1) + agent.onToolbarAction(handler2) + + const action = { name: 'chat.close' as const, action: 'click' as const } + + const toolbarActionHandler = channelStub.addHandler.secondCall.args[1] + toolbarActionHandler(action) + + expect(handler1).to.have.been.calledOnce // eslint-disable-line no-unused-expressions + expect(handler1).to.have.been.calledWith(action) + expect(handler2).to.have.been.calledOnce // eslint-disable-line no-unused-expressions + expect(handler2).to.have.been.calledWith(action) + }) + + it('does not call detached handler when toolbarAction event is received', () => { + const handler = sinon.stub() + const detach = agent.onToolbarAction(handler) + + detach() + + const action = { name: 'chat.back' as const, action: 'click' as const } + + const toolbarActionHandler = channelStub.addHandler.secondCall.args[1] + toolbarActionHandler(action) + + expect(handler).to.not.have.been.called // eslint-disable-line no-unused-expressions + }) + + it('handles different toolbar action types', () => { + const handler = sinon.stub() + agent.onToolbarAction(handler) + + const toolbarActionHandler = channelStub.addHandler.secondCall.args[1] + + // Test all action types + toolbarActionHandler({ name: 'chat.history' as const, action: 'click' as const }) + toolbarActionHandler({ name: 'chat.back' as const, action: 'click' as const }) + toolbarActionHandler({ name: 'chat.close' as const, action: 'click' as const }) + + expect(handler).to.have.callCount(3) + expect(handler.getCall(0).args[0]).to.deep.equal({ name: 'chat.history', action: 'click' }) + expect(handler.getCall(1).args[0]).to.deep.equal({ name: 'chat.back', action: 'click' }) + expect(handler.getCall(2).args[0]).to.deep.equal({ name: 'chat.close', action: 'click' }) + }) + }) + }) }) diff --git a/test/unit/api.spec.ts b/test/unit/api.spec.ts index 7c051aad76..67a73806c0 100644 --- a/test/unit/api.spec.ts +++ b/test/unit/api.spec.ts @@ -163,8 +163,9 @@ describe('createAPI()', () => { ) as unknown as AgentAppSDK expect(api).to.have.all.keys(sharedExpected.concat(expected)) - expect(api.agent).to.have.all.keys(['onContextChange']) + expect(api.agent).to.have.all.keys(['onContextChange', 'onToolbarAction']) expect(api.agent.onContextChange).to.be.a('function') + expect(api.agent.onToolbarAction).to.be.a('function') }) }) From e7d799dabd705b2ffaf931f2f19d5937d0ce2d84 Mon Sep 17 00:00:00 2001 From: Josh Lewis Date: Fri, 7 Nov 2025 13:36:50 -0700 Subject: [PATCH 5/5] refactor(agent): consolidate type imports in agent module --- lib/agent.ts | 3 +-- lib/types/index.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/agent.ts b/lib/agent.ts index edfe0c27f5..f0636e8866 100644 --- a/lib/agent.ts +++ b/lib/agent.ts @@ -1,7 +1,6 @@ import { Channel } from './channel' import { MemoizedSignal, Signal } from './signal' -import { AgentAPI, AgentContext, ConnectMessage } from './types' -import { ToolbarAction } from './types/agent.types' +import { AgentAPI, AgentContext, ConnectMessage, ToolbarAction } from './types' export default function createAgent( channel: Channel, diff --git a/lib/types/index.ts b/lib/types/index.ts index 1990f25351..bc21a56e81 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -37,7 +37,7 @@ export type { AgentAppSDK, } from './api.types' -export type { AgentAPI, AgentContext } from './agent.types' +export type { AgentAPI, AgentContext, ToolbarAction, ToolbarActionName } from './agent.types' export type { AppConfigAPI,