Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/bright-readers-hunt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
'@mastra/claude': minor
'@mastra/cursor': minor
'@mastra/openai': minor
---

Added structured output support for Claude and OpenAI SDK agents using their provider-native structured output APIs. Cursor SDK agent calls now fail clearly when structuredOutput is requested because the Cursor TypeScript SDK does not expose a schema-constrained output API. SDK agents now implement provider-native resume through Mastra's existing resumeGenerate/resumeStream methods by accepting provider-specific resumeData with a message payload. Cursor SDK agent options now use the same clear source split as OpenAI: pass either a pre-created agent or SDK options for wrapper-created agents.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Example:

```ts
await claudeAgent.resumeGenerate({
message: 'Continue the task.',
sessionId: 'claude-session-id',
});

await openAIAgent.resumeStream({
message: 'Continue the task.',
previousResponseId: 'resp_123',
});

const result = await openAIAgent.generate('Return the answer as JSON.', {
structuredOutput: {
schema: z.object({ answer: z.string() }),
},
});
// result.object has shape { answer: string }
```

Claude and OpenAI SDK agents support `structuredOutput` through their native SDK APIs. `CursorSDKAgent` throws a clear error when `structuredOutput` is requested because the Cursor TypeScript SDK does not expose schema-constrained output.
145 changes: 136 additions & 9 deletions agent-sdks/claude/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import { isAgentCompatible } from '@mastra/core/agent';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import { ClaudeSDKAgent } from './index';
import type { ClaudeSDKAgentResumeData } from './index';

const queryMock = vi.hoisted(() => vi.fn());
const TEST_CLAUDE_MODEL = 'claude-sonnet-4-6';

vi.mock('@anthropic-ai/claude-agent-sdk', () => ({
query: queryMock,
Expand All @@ -31,7 +33,7 @@ function createStreamEvent(text: string): SDKMessage {
} as SDKMessage;
}

function createResultMessage(result: string): SDKMessage {
function createResultMessage(result: string, structuredOutput?: unknown): SDKMessage {
return {
type: 'result',
subtype: 'success',
Expand All @@ -49,7 +51,7 @@ function createResultMessage(result: string): SDKMessage {
cache_creation_input_tokens: 3,
},
modelUsage: {
__GATEWAY_ANTHROPIC_MODEL_SONNET__: {
[TEST_CLAUDE_MODEL]: {
inputTokens: 10,
outputTokens: 4,
cacheReadInputTokens: 2,
Expand All @@ -61,6 +63,7 @@ function createResultMessage(result: string): SDKMessage {
},
},
permission_denials: [],
structured_output: structuredOutput,
uuid: 'message-uuid',
session_id: 'session-id',
} as SDKMessage;
Expand Down Expand Up @@ -91,7 +94,7 @@ function createAssistantMessage(id: string, usage: Record<string, number>): SDKM
type: 'message',
role: 'assistant',
content: [],
model: '__GATEWAY_ANTHROPIC_MODEL_SONNET__',
model: TEST_CLAUDE_MODEL,
stop_reason: 'end_turn',
stop_sequence: null,
usage,
Expand All @@ -112,7 +115,7 @@ describe('ClaudeSDKAgent', () => {
name: 'Claude Agent',
description: 'Use Claude Agent as a Mastra agent.',
sdkOptions: {
model: '__GATEWAY_ANTHROPIC_MODEL_SONNET__',
model: TEST_CLAUDE_MODEL,
},
});

Expand All @@ -131,7 +134,7 @@ describe('ClaudeSDKAgent', () => {
description: 'Claude',
sdkOptions: {
cwd: '/tmp/project',
model: '__GATEWAY_ANTHROPIC_MODEL_SONNET__',
model: TEST_CLAUDE_MODEL,
maxTurns: 1,
permissionMode: 'acceptEdits',
tools: ['Read', 'Bash'],
Expand All @@ -151,6 +154,9 @@ describe('ClaudeSDKAgent', () => {
runId: 'mastra-run',
abortSignal: abortController.signal,
maxSteps: 1,
sdkOptions: {
resume: 'session-id',
},
});

expect(result.text).toBe('generated text');
Expand All @@ -165,7 +171,7 @@ describe('ClaudeSDKAgent', () => {
expect(result.providerMetadata).toMatchObject({
claude: {
totalCostUsd: 0.0123,
model: '__GATEWAY_ANTHROPIC_MODEL_SONNET__',
model: TEST_CLAUDE_MODEL,
cwd: '/tmp/project',
permissionMode: 'acceptEdits',
maxTurns: 1,
Expand All @@ -177,7 +183,7 @@ describe('ClaudeSDKAgent', () => {
prompt: 'Generate prompt',
options: expect.objectContaining({
cwd: '/tmp/project',
model: '__GATEWAY_ANTHROPIC_MODEL_SONNET__',
model: TEST_CLAUDE_MODEL,
maxTurns: 1,
permissionMode: 'acceptEdits',
tools: ['Read', 'Bash'],
Expand All @@ -190,6 +196,7 @@ describe('ClaudeSDKAgent', () => {
CLAUDE_AGENT_SDK_CLIENT_APP: 'mastra-test',
},
pathToClaudeCodeExecutable: '/usr/local/bin/claude',
resume: 'session-id',
abortController: expect.any(AbortController),
}),
});
Expand All @@ -211,7 +218,7 @@ describe('ClaudeSDKAgent', () => {
id: 'claude-agent',
description: 'Claude',
sdkOptions: {
model: '__GATEWAY_ANTHROPIC_MODEL_SONNET__',
model: TEST_CLAUDE_MODEL,
},
});

Expand Down Expand Up @@ -242,7 +249,7 @@ describe('ClaudeSDKAgent', () => {
id: 'claude-agent',
description: 'Claude',
sdkOptions: {
model: '__GATEWAY_ANTHROPIC_MODEL_SONNET__',
model: TEST_CLAUDE_MODEL,
},
});

Expand All @@ -266,4 +273,124 @@ describe('ClaudeSDKAgent', () => {
'finish',
]);
});

it('uses Claude native structured output and exposes the validated object', async () => {
queryMock.mockImplementationOnce(() => createQuery([createResultMessage('', { answer: 'yes' })]));
const agent = new ClaudeSDKAgent({
id: 'claude-agent',
description: 'Claude',
sdkOptions: {
model: TEST_CLAUDE_MODEL,
},
});

const result = await agent.generate<{ answer: string }>('Return a JSON answer', {
structuredOutput: {
schema: {
type: 'object',
properties: {
answer: { type: 'string' },
},
required: ['answer'],
},
},
});

expect(result.object).toEqual({ answer: 'yes' });
expect(queryMock).toHaveBeenCalledWith({
prompt: 'Return a JSON answer',
options: expect.objectContaining({
model: TEST_CLAUDE_MODEL,
outputFormat: {
type: 'json_schema',
schema: expect.objectContaining({
type: 'object',
properties: expect.objectContaining({
answer: expect.objectContaining({ type: 'string' }),
}),
}),
},
}),
});
});

it('resumeGenerate maps resumeData to Claude session resume options', async () => {
queryMock.mockImplementationOnce(() => createQuery([createResultMessage('continued text')]));
const agent = new ClaudeSDKAgent({
id: 'claude-agent',
description: 'Claude',
sdkOptions: {
model: TEST_CLAUDE_MODEL,
},
});

const result = await agent.resumeGenerate(
{
message: 'Continue prompt',
sessionId: 'session-id',
forkSession: true,
resumeSessionAt: 'assistant-message-id',
},
{ runId: 'resume-run' },
);

expect(result.text).toBe('continued text');
expect(result.runId).toBe('resume-run');
expect(queryMock).toHaveBeenCalledWith({
prompt: 'Continue prompt',
options: expect.objectContaining({
model: TEST_CLAUDE_MODEL,
resume: 'session-id',
forkSession: true,
resumeSessionAt: 'assistant-message-id',
}),
});
});

it('resumeStream maps resumeData to Claude latest-session continuation', async () => {
queryMock.mockImplementationOnce(() =>
createQuery([createStreamEvent('continued '), createStreamEvent('text'), createResultMessage('continued text')]),
);
const agent = new ClaudeSDKAgent({
id: 'claude-agent',
description: 'Claude',
sdkOptions: {
model: TEST_CLAUDE_MODEL,
},
});

const stream = await agent.resumeStream({ message: 'Continue prompt', continue: true }, { runId: 'resume-run' });
for await (const _chunk of stream.fullStream) {
// drain
}

expect(await stream.text).toBe('continued text');
expect(queryMock).toHaveBeenCalledWith({
prompt: 'Continue prompt',
options: expect.objectContaining({
model: TEST_CLAUDE_MODEL,
continue: true,
}),
});
});

it('rejects mixed Claude resumeData shapes', async () => {
const agent = new ClaudeSDKAgent({
id: 'claude-agent',
description: 'Claude',
sdkOptions: {
model: TEST_CLAUDE_MODEL,
},
});

await expect(
agent.resumeGenerate({
message: 'Continue prompt',
sessionId: 123,
continue: true,
} as unknown as ClaudeSDKAgentResumeData),
).rejects.toThrow('either sessionId or continue: true, not both');

expect(queryMock).not.toHaveBeenCalled();
});
});
Loading
Loading