Skip to content

Commit 19a8658

Browse files
TylerBarnesMastra Code (openai/gpt-5.5)
andauthored
fix(signals): add subscription-native tool approval (#17311)
## Summary - Add subscription-native approve/decline routes and client helpers for tool approvals. - Wire React `useChat()` to use the JSON subscription approval path when subscribed. - Restore suspended memory thread/resource in `resumeStream()` so resumed approval chunks register to the active thread subscription. - Document subscription-native approval in signals, client-js, and server route docs. ## Stack navigation Stack position: 3/5. - Previous: #17310 - Next: #17312 ## Test plan - `pnpm --filter @mastra/core test src/agent/__tests__/agent-signals.test.ts src/agent/__tests__/resume-span-tracing.test.ts --bail 1 --reporter=dot` - `pnpm --filter ./packages/server test src/server/handlers/agents.test.ts --bail 1 --reporter=dot` - `pnpm --filter @mastra/client-js test src/resources/agent.test.ts --bail 1 --reporter=dot` - `pnpm --filter @mastra/react test src/agent/hooks.test.ts --bail 1 --reporter=dot` - `pnpm --filter @mastra/core build:lib` - `pnpm --filter @mastra/client-js build:lib` - `pnpm --filter @mastra/react build:js` - `pnpm --filter @mastra/server build:lib` - `pnpm --filter ./packages/playground build` - `pnpm --filter ./packages/server check:permissions` - `pnpm --filter ./packages/server check:core-imports` - `pnpm run prettier:changed` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## ELI5 (Explain Like I'm 5) If a paused AI needs permission to continue, you can now approve or decline it directly in the same chat subscription and the AI will resume speaking in that same conversation; you immediately get a small JSON confirmation that the approval was received. ## Overview Implements subscription-native tool approval/decline so paused tool calls are acknowledged with a JSON response and resumed output is delivered through the existing thread subscription (no separate continuation stream). Changes span server routes and schemas, core agent runtime and APIs, client-js SDK, React hook integration, tests, playground helpers, and documentation. Also ensures suspended-run memory/thread context is preserved on resume so resumed chunks attach to the active subscription. ## Key changes - Server (packages/server) - Add POST endpoints for subscription-native approvals: - POST /agents/:agentId/approve-tool-call-for-thread - POST /agents/:agentId/decline-tool-call-for-thread - Add Zod schemas (toolCallSubscriptionBodySchema, toolCallSubscriptionResponseSchema). - Handlers validate effective thread access, merge requestContext safely, sanitize body, and call the core agent thread-scoped approval helpers. - Thread ownership validation was added in review to prevent cross-thread approvals. - Core (packages/core) - Add getActiveThreadRunId() to look up an active runId for a (threadId, resourceId) scope (treating suspended runs as active). - resumeStream(): restore memory.thread/resource from suspended-run snapshot when resuming (without overwriting caller-provided memory). - Add approveToolCallForThread and declineToolCallForThread helpers which require an active thread-scoped run, default memory keys from thread/resource, call the generic approve/decline flow, and return { accepted: true; runId; toolCallId? }. - Thread-stream runtime: publish run-suspended, keep reservation records for suspended runs, treat suspended as active for queuing and resume, and centralize active-run lookup. - Client SDKs - client-js: add Agent methods approveToolCallForThread / declineToolCallForThread and generated route types / CLI metadata for the new endpoints. - react: useChat now tracks pending tool-approval IDs, exposes isAwaitingToolApproval, treats tool-call-approval and tool-call-suspended chunks as non-running, and routes approve/decline to thread-native APIs when subscribed. - Playground / runtime state - Add getCanSendWhileStreaming helper and use it in MastraRuntimeProvider. - MastraRuntimeProvider updated to include isAwaitingToolApproval gating. - Tests - Core: resumed-run delivery and queuing-behind-suspended-run tests; end-to-end subscription-native approval test covering memory restore. - Server: schema and handler tests for the new routes and authorization. - client-js: tests for new Agent methods calling the new endpoints. - react: extensive useChat tests for subscription approval flows and routing to thread-native approvals. - playground: tests updated to include isAwaitingToolApproval. - Docs - New and updated docs describing subscription-native approve/decline (JSON ack + resumed chunks via thread subscription) and guidance distinguishing for-thread APIs vs legacy continuation-stream APIs. - client-js and server route references updated with examples and route metadata. ## CI / Review notes (actionable) - CI blocker: the shared server-adapter integration tests (express/fastify/hono/koa) fail with 500s for the two new routes because the shared test-helpers do not mock the new agent thread-approval methods; the real implementations run in the synthetic test context and getActiveThreadRunId() can throw when no active run exists. - Immediate test fix: add the thread-approval spies to the central mock setup (server-adapters/_test-utils/src/test-helpers.ts). Example mocks to add: - vi.spyOn(agent, 'approveToolCallForThread' /* or approveToolCallAndSubscribe if that helper is present */).mockResolvedValue({ accepted: true, runId: 'test-run', toolCallId: 'test-tool-call' } as any); - vi.spyOn(agent, 'declineToolCallForThread' /* or declineToolCallAndSubscribe */).mockResolvedValue({ accepted: true, runId: 'test-run', toolCallId: 'test-tool-call' } as any); - Adding these spies alongside existing tool-call mocks in the shared test-helpers will unblock adapter tests. - Security / ownership - Review feedback to validate thread ownership was applied in the subscription route handlers; the author addressed this in follow-up changes. Verify handlers call the same validateThreadOwnership check used by abort-thread routes before delegating. ## Test plan Run the package-scoped tests/build/checks listed in the PR (package filter/test commands included in the PR) and the server permission/core-import checks, then run prettier on changed files. ## Status / Next steps - Add the two mock spies to server-adapters/_test-utils/src/test-helpers.ts to make adapter tests green (CI blocker). - Confirm thread-ownership validation is present and correct in the subscription routes (review indicates it was added). - Once CI passes and ownership validation is confirmed, this is ready to merge. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Mastra Code (openai/gpt-5.5) <noreply@mastra.ai>
1 parent b0771a4 commit 19a8658

23 files changed

Lines changed: 1322 additions & 39 deletions

File tree

.changeset/curly-mice-approve.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@mastra/core': patch
3+
'@mastra/client-js': patch
4+
'@mastra/react': patch
5+
'@mastra/server': patch
6+
'mastra': patch
7+
---
8+
9+
Added subscription-native tool approval APIs so approving or declining a tool call resumes through the active thread subscription instead of requiring a separate continuation stream. New messages are queued while a tool approval is waiting, preventing overlapping runs from duplicating approval requests.
10+
11+
```ts
12+
await agent.sendToolApproval({
13+
resourceId: 'user-123',
14+
threadId: 'thread-123',
15+
toolCallId: 'tool-call-123',
16+
approved: true,
17+
});
18+
```

client-sdks/client-js/src/resources/agent.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,58 @@ describe('Agent signal routes', () => {
8989
});
9090
});
9191

92+
it('approves tool calls through the subscription-native route', async () => {
93+
const agent = new Agent(mockClientOptions, 'test-agent');
94+
const mockRequest = vi.fn().mockResolvedValue({ accepted: true, runId: 'run-123', toolCallId: 'tool-call-123' });
95+
agent['request'] = mockRequest as (typeof agent)['request'];
96+
97+
const result = await agent.sendToolApproval({
98+
resourceId: 'resource-123',
99+
threadId: 'thread-123',
100+
toolCallId: 'tool-call-123',
101+
approved: true,
102+
requestContext: { userId: 'user-123' },
103+
});
104+
105+
expect(result).toEqual({ accepted: true, runId: 'run-123', toolCallId: 'tool-call-123' });
106+
expect(mockRequest).toHaveBeenCalledWith('/agents/test-agent/send-tool-approval', {
107+
method: 'POST',
108+
body: {
109+
resourceId: 'resource-123',
110+
threadId: 'thread-123',
111+
toolCallId: 'tool-call-123',
112+
approved: true,
113+
requestContext: { userId: 'user-123' },
114+
},
115+
});
116+
});
117+
118+
it('declines tool calls through the subscription-native route', async () => {
119+
const agent = new Agent(mockClientOptions, 'test-agent');
120+
const mockRequest = vi.fn().mockResolvedValue({ accepted: true, runId: 'run-123', toolCallId: 'tool-call-123' });
121+
agent['request'] = mockRequest as (typeof agent)['request'];
122+
123+
const result = await agent.sendToolApproval({
124+
resourceId: 'resource-123',
125+
threadId: 'thread-123',
126+
toolCallId: 'tool-call-123',
127+
approved: false,
128+
requestContext: { userId: 'user-123' },
129+
});
130+
131+
expect(result).toEqual({ accepted: true, runId: 'run-123', toolCallId: 'tool-call-123' });
132+
expect(mockRequest).toHaveBeenCalledWith('/agents/test-agent/send-tool-approval', {
133+
method: 'POST',
134+
body: {
135+
resourceId: 'resource-123',
136+
threadId: 'thread-123',
137+
toolCallId: 'tool-call-123',
138+
approved: false,
139+
requestContext: { userId: 'user-123' },
140+
},
141+
});
142+
});
143+
92144
it('sends thread-targeted signals with active and idle behavior unchanged', async () => {
93145
const agent = new Agent(mockClientOptions, 'test-agent');
94146
const mockRequest = vi.fn().mockResolvedValue({ accepted: true, runId: 'run-123' });

client-sdks/client-js/src/resources/agent.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2717,6 +2717,23 @@ export class Agent extends BaseResource {
27172717
return streamResponse;
27182718
}
27192719

2720+
async sendToolApproval(params: {
2721+
resourceId: string;
2722+
threadId: string;
2723+
toolCallId: string;
2724+
approved: boolean;
2725+
requestContext?: RequestContext | Record<string, any>;
2726+
}): Promise<{ accepted: true; runId: string; toolCallId?: string }> {
2727+
const { requestContext, ...rest } = params;
2728+
return this.request<{ accepted: true; runId: string; toolCallId?: string }>(
2729+
`/agents/${this.agentId}/send-tool-approval`,
2730+
{
2731+
method: 'POST',
2732+
body: { ...rest, requestContext: parseClientRequestContext(requestContext) },
2733+
},
2734+
);
2735+
}
2736+
27202737
async declineToolCall(params: {
27212738
runId: string;
27222739
toolCallId: string;

client-sdks/client-js/src/route-types.generated.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6942,6 +6942,54 @@ export interface PostAgentsAgentIdApproveToolCall_RouteContract {
69426942
responseType: 'stream';
69436943
}
69446944

6945+
// ============================================================================
6946+
// Route: POST /agents/:agentId/send-tool-approval
6947+
// ============================================================================
6948+
export type PostAgentsAgentIdSendToolApproval_PathParams = {
6949+
/** Unique identifier for the agent */
6950+
agentId: string;
6951+
};
6952+
6953+
export type PostAgentsAgentIdSendToolApproval_Body = {
6954+
resourceId: string;
6955+
threadId: string;
6956+
requestContext?:
6957+
| {
6958+
[key: string]: any;
6959+
}
6960+
| undefined;
6961+
toolCallId: string;
6962+
approved: boolean;
6963+
format?: string | undefined;
6964+
};
6965+
6966+
export type PostAgentsAgentIdSendToolApproval_Response = {
6967+
accepted: true;
6968+
runId: string;
6969+
toolCallId?: string | undefined;
6970+
};
6971+
6972+
export type PostAgentsAgentIdSendToolApproval_Request = Simplify<
6973+
(PostAgentsAgentIdSendToolApproval_PathParams extends never
6974+
? {}
6975+
: { params: PostAgentsAgentIdSendToolApproval_PathParams }) &
6976+
(never extends never ? {} : {} extends never ? { query?: never } : { query: never }) &
6977+
(PostAgentsAgentIdSendToolApproval_Body extends never
6978+
? {}
6979+
: {} extends PostAgentsAgentIdSendToolApproval_Body
6980+
? { body?: PostAgentsAgentIdSendToolApproval_Body }
6981+
: { body: PostAgentsAgentIdSendToolApproval_Body })
6982+
>;
6983+
6984+
export interface PostAgentsAgentIdSendToolApproval_RouteContract {
6985+
pathParams: PostAgentsAgentIdSendToolApproval_PathParams;
6986+
queryParams: never;
6987+
body: PostAgentsAgentIdSendToolApproval_Body;
6988+
request: PostAgentsAgentIdSendToolApproval_Request;
6989+
response: PostAgentsAgentIdSendToolApproval_Response;
6990+
responseType: 'json';
6991+
}
6992+
69456993
// ============================================================================
69466994
// Route: POST /agents/:agentId/decline-tool-call
69476995
// ============================================================================
@@ -84754,6 +84802,7 @@ export interface RouteTypes {
8475484802
'POST /agents/:agentId/threads/subscribe': PostAgentsAgentIdThreadsSubscribe_RouteContract;
8475584803
'POST /agents/:agentId/tools/:toolId/execute': PostAgentsAgentIdToolsToolIdExecute_RouteContract;
8475684804
'POST /agents/:agentId/approve-tool-call': PostAgentsAgentIdApproveToolCall_RouteContract;
84805+
'POST /agents/:agentId/send-tool-approval': PostAgentsAgentIdSendToolApproval_RouteContract;
8475784806
'POST /agents/:agentId/decline-tool-call': PostAgentsAgentIdDeclineToolCall_RouteContract;
8475884807
'POST /agents/:agentId/resume-stream': PostAgentsAgentIdResumeStream_RouteContract;
8475984808
'POST /agents/:agentId/approve-tool-call-generate': PostAgentsAgentIdApproveToolCallGenerate_RouteContract;
@@ -85221,6 +85270,9 @@ export interface Client {
8522185270
'/agents/:agentId/send-message': {
8522285271
POST: PostAgentsAgentIdSendMessage_RouteContract;
8522385272
};
85273+
'/agents/:agentId/send-tool-approval': {
85274+
POST: PostAgentsAgentIdSendToolApproval_RouteContract;
85275+
};
8522485276
'/agents/:agentId/signals': {
8522585277
POST: PostAgentsAgentIdSignals_RouteContract;
8522685278
};

0 commit comments

Comments
 (0)