Skip to content

Commit 4f793e2

Browse files
authored
feat(framework): self-hosted brain-controlled typing indicator fixes NV-8128 (#11671)
1 parent e0d3185 commit 4f793e2

9 files changed

Lines changed: 282 additions & 2 deletions

File tree

apps/api/src/app/agents/conversation-runtime/reply/agent-reply.controller.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export class AgentReplyController {
3636
resolve: body.resolve,
3737
signals: body.signals as Signal[],
3838
addReactions: body.addReactions,
39+
typing: body.typing,
3940
})
4041
);
4142
}

apps/api/src/app/agents/conversation-runtime/reply/handle-agent-reply/handle-agent-reply.command.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ export class HandleAgentReplyCommand extends EnvironmentWithUserCommand {
4848
@IsObject()
4949
plan?: { model: PlanModel; phase: PlanPhase; messageId?: string };
5050

51+
@IsOptional()
52+
typing?: { status?: string } | 'stop';
53+
5154
@IsOptional()
5255
slackNative?: SlackNativeDelivery;
5356
}

apps/api/src/app/agents/conversation-runtime/reply/handle-agent-reply/handle-agent-reply.usecase.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,11 @@ export class HandleAgentReply {
5757
!command.resolve &&
5858
!command.signals?.length &&
5959
!command.addReactions?.length &&
60-
!command.plan
60+
!command.plan &&
61+
!command.typing
6162
) {
6263
throw new BadRequestException(
63-
'At least one of reply, edit, resolve, signals, addReactions, or plan must be provided'
64+
'At least one of reply, edit, resolve, signals, addReactions, plan, or typing must be provided'
6465
);
6566
}
6667

@@ -76,6 +77,10 @@ export class HandleAgentReply {
7677
const channel = this.conversationService.getPrimaryChannel(conversation);
7778
const agentName = await this.resolveValidatedAgentNameForDelivery(command, conversation);
7879

80+
if (command.typing) {
81+
await this.deliverTyping(command, conversation, channel, command.typing);
82+
}
83+
7984
if (command.edit) {
8085
return this.deliverEdit(command, conversation, channel, command.edit, agentName);
8186
}
@@ -149,6 +154,7 @@ export class HandleAgentReply {
149154
if (triggerSignalCount > 0) actions.push('trigger_signals');
150155
if (metadataSignalCount > 0) actions.push('metadata_signals');
151156
if (reactionCount > 0) actions.push('add_reactions');
157+
if (command.typing) actions.push('typing');
152158

153159
trackAgentReplyProcessed(this.analyticsService, {
154160
userId: command.userId,
@@ -302,6 +308,26 @@ export class HandleAgentReply {
302308
);
303309
}
304310

311+
private async deliverTyping(
312+
command: HandleAgentReplyCommand,
313+
conversation: ConversationEntity,
314+
channel: ConversationChannel,
315+
typing: NonNullable<HandleAgentReplyCommand['typing']>
316+
): Promise<void> {
317+
const status = typing === 'stop' ? '' : (typing.status ?? 'Thinking...');
318+
319+
try {
320+
await this.outboundGateway.startTypingInConversation(
321+
conversation._agentId,
322+
command.integrationIdentifier,
323+
channel.platformThreadId,
324+
status
325+
);
326+
} catch (err) {
327+
this.logger.warn(err, `[agent:${command.agentIdentifier}] Failed to set typing status`);
328+
}
329+
}
330+
305331
private async executeSignals(
306332
command: HandleAgentReplyCommand,
307333
conversation: ConversationEntity,

apps/api/src/app/agents/e2e/agent-reply.e2e.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ describe('Agent Reply - /agents/:agentId/reply #novu-v2', () => {
3838
.resolves({ messageId: 'platform-msg-1', platformThreadId: 'platform-thread-1' });
3939
sinon.stub(outboundGateway, 'reactToMessage').resolves();
4040
sinon.stub(outboundGateway, 'removeReaction').resolves();
41+
sinon.stub(outboundGateway, 'startTypingInConversation').resolves();
4142
});
4243

4344
function postReply(body: Record<string, unknown>) {
@@ -325,6 +326,84 @@ describe('Agent Reply - /agents/:agentId/reply #novu-v2', () => {
325326
});
326327
});
327328

329+
describe('Typing', () => {
330+
it('should set a typing status from a typing-only request', async () => {
331+
const conversationId = await seedConversation(ctx);
332+
const outboundGateway = testServer.getService(OutboundGateway);
333+
334+
const res = await postReply({
335+
conversationId,
336+
integrationIdentifier: ctx.integrationIdentifier,
337+
typing: { status: 'Searching the docs…' },
338+
});
339+
340+
expect(res.status).to.equal(200);
341+
expect(res.body.data).to.be.null;
342+
343+
const stub = outboundGateway.startTypingInConversation as sinon.SinonStub;
344+
expect(stub.callCount).to.equal(1);
345+
expect(stub.getCall(0).args[3]).to.equal('Searching the docs…');
346+
});
347+
348+
it('should default the status text when typing has no status', async () => {
349+
const conversationId = await seedConversation(ctx);
350+
const outboundGateway = testServer.getService(OutboundGateway);
351+
352+
const res = await postReply({
353+
conversationId,
354+
integrationIdentifier: ctx.integrationIdentifier,
355+
typing: {},
356+
});
357+
358+
expect(res.status).to.equal(200);
359+
360+
const stub = outboundGateway.startTypingInConversation as sinon.SinonStub;
361+
expect(stub.getCall(0).args[3]).to.equal('Thinking...');
362+
});
363+
364+
it('should clear the status with an empty string for typing "stop"', async () => {
365+
const conversationId = await seedConversation(ctx);
366+
const outboundGateway = testServer.getService(OutboundGateway);
367+
368+
const res = await postReply({
369+
conversationId,
370+
integrationIdentifier: ctx.integrationIdentifier,
371+
typing: 'stop',
372+
});
373+
374+
expect(res.status).to.equal(200);
375+
376+
const stub = outboundGateway.startTypingInConversation as sinon.SinonStub;
377+
expect(stub.getCall(0).args[3]).to.equal('');
378+
});
379+
380+
it('should not fail the turn when the typing call throws', async () => {
381+
const conversationId = await seedConversation(ctx);
382+
const outboundGateway = testServer.getService(OutboundGateway);
383+
(outboundGateway.startTypingInConversation as sinon.SinonStub).rejects(new Error('platform down'));
384+
385+
const res = await postReply({
386+
conversationId,
387+
integrationIdentifier: ctx.integrationIdentifier,
388+
typing: { status: 'Working…' },
389+
});
390+
391+
expect(res.status).to.equal(200);
392+
});
393+
394+
it('should reject an invalid typing op', async () => {
395+
const conversationId = await seedConversation(ctx);
396+
397+
const res = await postReply({
398+
conversationId,
399+
integrationIdentifier: ctx.integrationIdentifier,
400+
typing: 'go',
401+
});
402+
403+
expect(res.status).to.equal(422);
404+
});
405+
});
406+
328407
describe('Inactive agent', () => {
329408
it('should return 422 when agent is inactive', async () => {
330409
const conversationId = await seedConversation(ctx);

apps/api/src/app/agents/shared/dtos/agent-reply-payload.dto.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,26 @@ export class IsValidReplyContent implements ValidatorConstraintInterface {
149149
}
150150
}
151151

152+
@ValidatorConstraint({ name: 'isValidTypingOp', async: false })
153+
export class IsValidTypingOp implements ValidatorConstraintInterface {
154+
validate(value: unknown): boolean {
155+
if (value === undefined) return true;
156+
if (value === 'stop') return true;
157+
158+
if (typeof value === 'object' && value !== null) {
159+
const status = (value as { status?: unknown }).status;
160+
161+
return status === undefined || typeof status === 'string';
162+
}
163+
164+
return false;
165+
}
166+
167+
defaultMessage(): string {
168+
return "typing must be 'stop' or an object with an optional string status.";
169+
}
170+
}
171+
152172
export class ReplyContentDto {
153173
@ApiPropertyOptional()
154174
@IsOptional()
@@ -283,4 +303,13 @@ export class AgentReplyPayloadDto {
283303
@ValidateNested({ each: true })
284304
@Type(() => AddReactionPayloadDto)
285305
addReactions?: AddReactionPayloadDto[];
306+
307+
@ApiPropertyOptional({
308+
description:
309+
'Per-turn typing/status control. `{ status?: string }` sets the status text ' +
310+
'(omit for the default "Thinking…"); `"stop"` clears it. Best-effort per platform.',
311+
})
312+
@IsOptional()
313+
@Validate(IsValidTypingOp)
314+
typing?: { status?: string } | 'stop';
286315
}

packages/framework/src/resources/agent/agent.context.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import type {
1818
SentMessageInfo,
1919
Signal,
2020
TriggerRecipientsPayload,
21+
TypingControl,
22+
TypingOp,
2123
} from './agent.types';
2224
import { AgentEventEnum } from './agent.types';
2325

@@ -258,6 +260,7 @@ export class AgentContextImpl {
258260
readonly history: AgentHistoryEntry[];
259261
readonly platform: string;
260262
readonly platformContext: AgentPlatformContext;
263+
readonly typing: TypingControl;
261264

262265
readonly metadata: {
263266
get(key: string): unknown;
@@ -317,6 +320,17 @@ export class AgentContextImpl {
317320
return { ...self._metadataState } as Readonly<Record<string, unknown>>;
318321
},
319322
};
323+
324+
const postTyping = (op: TypingOp): Promise<void> =>
325+
this._post({
326+
conversationId: this._conversationId,
327+
integrationIdentifier: this._integrationIdentifier,
328+
typing: op,
329+
}).then(() => undefined);
330+
331+
const typing = ((status?: string) => postTyping(status === undefined ? {} : { status })) as TypingControl;
332+
typing.stop = () => postTyping('stop');
333+
this.typing = typing;
320334
}
321335

322336
async reply(content: MessageContent, options?: { files?: FileRef[] }): Promise<ReplyHandle> {

packages/framework/src/resources/agent/agent.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1868,4 +1868,101 @@ describe('agent dispatch via NovuRequestHandler', () => {
18681868
expect(JSON.parse(replyCalls[0][1].body).reply.markdown).toBe('Thinking…');
18691869
expect(JSON.parse(replyCalls[1][1].body).reply.markdown).toBe('Final answer');
18701870
});
1871+
1872+
it('should post a typing status op for ctx.typing(text)', async () => {
1873+
const testBot = agent('test-bot', {
1874+
onMessage: async (_message, ctx) => {
1875+
await ctx.typing('Searching the docs…');
1876+
},
1877+
});
1878+
1879+
const handler = new NovuRequestHandler({
1880+
frameworkName: 'test',
1881+
agents: [testBot],
1882+
client,
1883+
handler: () => ({
1884+
body: () => createMockBridgeRequest(),
1885+
headers: () => null,
1886+
method: () => 'POST',
1887+
url: () => new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onMessage`),
1888+
transformResponse: (res: any) => res,
1889+
}),
1890+
});
1891+
1892+
await handler.createHandler()();
1893+
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
1894+
1895+
const replyCall = fetchMock.mock.calls.find(
1896+
(call: any[]) => call[0] === 'https://api.novu.co/v1/agents/test-bot/reply'
1897+
);
1898+
const body = JSON.parse(replyCall![1].body);
1899+
1900+
expect(body.typing).toEqual({ status: 'Searching the docs…' });
1901+
expect(body.reply).toBeUndefined();
1902+
expect(body.conversationId).toBe('conv-456');
1903+
expect(body.integrationIdentifier).toBe('slack-main');
1904+
});
1905+
1906+
it('should post an empty status op for ctx.typing() with no text', async () => {
1907+
const testBot = agent('test-bot', {
1908+
onMessage: async (_message, ctx) => {
1909+
await ctx.typing();
1910+
},
1911+
});
1912+
1913+
const handler = new NovuRequestHandler({
1914+
frameworkName: 'test',
1915+
agents: [testBot],
1916+
client,
1917+
handler: () => ({
1918+
body: () => createMockBridgeRequest(),
1919+
headers: () => null,
1920+
method: () => 'POST',
1921+
url: () => new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onMessage`),
1922+
transformResponse: (res: any) => res,
1923+
}),
1924+
});
1925+
1926+
await handler.createHandler()();
1927+
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
1928+
1929+
const replyCall = fetchMock.mock.calls.find(
1930+
(call: any[]) => call[0] === 'https://api.novu.co/v1/agents/test-bot/reply'
1931+
);
1932+
const body = JSON.parse(replyCall![1].body);
1933+
1934+
expect(body.typing).toEqual({});
1935+
});
1936+
1937+
it('should post a stop op for ctx.typing.stop()', async () => {
1938+
const testBot = agent('test-bot', {
1939+
onMessage: async (_message, ctx) => {
1940+
await ctx.typing.stop();
1941+
},
1942+
});
1943+
1944+
const handler = new NovuRequestHandler({
1945+
frameworkName: 'test',
1946+
agents: [testBot],
1947+
client,
1948+
handler: () => ({
1949+
body: () => createMockBridgeRequest(),
1950+
headers: () => null,
1951+
method: () => 'POST',
1952+
url: () => new URL(`http://localhost?action=${PostActionEnum.AGENT_EVENT}&agentId=test-bot&event=onMessage`),
1953+
transformResponse: (res: any) => res,
1954+
}),
1955+
});
1956+
1957+
await handler.createHandler()();
1958+
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
1959+
1960+
const replyCall = fetchMock.mock.calls.find(
1961+
(call: any[]) => call[0] === 'https://api.novu.co/v1/agents/test-bot/reply'
1962+
);
1963+
const body = JSON.parse(replyCall![1].body);
1964+
1965+
expect(body.typing).toBe('stop');
1966+
expect(body.reply).toBeUndefined();
1967+
});
18711968
});

packages/framework/src/resources/agent/agent.types.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,20 @@ export interface AgentContextBase {
302302
* await ctx.reply('Done!');
303303
*/
304304
addReaction(messageId: string, emojiName: Emoji): void;
305+
/**
306+
* Control the typing / "Thinking…" status for the current turn.
307+
* Posts immediately (like `reply()`), updating the indicator Novu already shows on inbound.
308+
*
309+
* @example
310+
* await ctx.typing('Searching the docs…'); // set / replace the status text
311+
* await ctx.typing(); // reset to the default "Thinking…"
312+
* await ctx.typing.stop(); // clear it for this turn
313+
*
314+
* Behaviour is best-effort per platform: custom text shows on Slack-like platforms,
315+
* a generic typing bubble on others, and is a no-op where there is no typing channel
316+
* (e.g. email). A normal turn that ends with `ctx.reply()` clears the status automatically.
317+
*/
318+
typing: TypingControl;
305319
}
306320

307321
/** Context passed to the `onMessage` handler. */
@@ -434,6 +448,20 @@ export interface AddReactionPayload {
434448
emojiName: Emoji;
435449
}
436450

451+
/**
452+
* Per-turn typing/status control op sent on the reply contract.
453+
* - `{ status?: string }` — set/replace the status; omit `status` for the default "Thinking…".
454+
* - `'stop'` — clear the status for this turn.
455+
*/
456+
export type TypingOp = { status?: string } | 'stop';
457+
458+
/**
459+
* `ctx.typing` surface: a callable that sets/updates the status, plus `.stop()` to clear it.
460+
*/
461+
export type TypingControl = ((status?: string) => Promise<void>) & {
462+
stop: () => Promise<void>;
463+
};
464+
437465
export interface AgentReplyPayload {
438466
conversationId: string;
439467
integrationIdentifier: string;
@@ -442,6 +470,7 @@ export interface AgentReplyPayload {
442470
resolve?: { summary?: string };
443471
signals?: Signal[];
444472
addReactions?: AddReactionPayload[];
473+
typing?: TypingOp;
445474
}
446475

447476
/** Shape returned by /agents/:id/reply when a reply or edit was delivered. */

0 commit comments

Comments
 (0)