0d3cb498a3
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Has been cancelled
Test and Publish Multi-arch Docker Image / test (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Has been cancelled
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Has been cancelled
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Has been cancelled
Validate Renovate Config / Validate Renovate Configuration (push) Has been cancelled
1324 lines
38 KiB
TypeScript
1324 lines
38 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
vi.mock('../../../src/util/fetch/index', async () => {
|
|
const actual = await vi.importActual<typeof import('../../../src/util/fetch/index')>(
|
|
'../../../src/util/fetch/index',
|
|
);
|
|
return {
|
|
...actual,
|
|
fetchWithProxy: vi.fn(),
|
|
fetchWithTimeout: vi.fn(),
|
|
};
|
|
});
|
|
|
|
vi.mock('../../../src/util/time', async () => {
|
|
const actual =
|
|
await vi.importActual<typeof import('../../../src/util/time')>('../../../src/util/time');
|
|
return {
|
|
...actual,
|
|
sleep: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
});
|
|
|
|
import { loadApiProvider } from '../../../src/providers';
|
|
import { A2AProvider } from '../../../src/providers/a2a';
|
|
import { fetchWithProxy, fetchWithTimeout } from '../../../src/util/fetch/index';
|
|
import { sleep } from '../../../src/util/time';
|
|
|
|
function jsonResponse(body: unknown, init?: ResponseInit): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
headers: { 'Content-Type': 'application/a2a+json' },
|
|
status: 200,
|
|
statusText: 'OK',
|
|
...init,
|
|
});
|
|
}
|
|
|
|
function sseResponse(events: unknown[]): Response {
|
|
const encoder = new TextEncoder();
|
|
return new Response(
|
|
new ReadableStream({
|
|
start(controller) {
|
|
for (const event of events) {
|
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
|
}
|
|
controller.close();
|
|
},
|
|
}),
|
|
{
|
|
headers: { 'Content-Type': 'text/event-stream' },
|
|
status: 200,
|
|
statusText: 'OK',
|
|
},
|
|
);
|
|
}
|
|
|
|
function provider(config: Record<string, unknown> = {}) {
|
|
return new A2AProvider('a2a:https://agent.example.com/a2a/v1', { config });
|
|
}
|
|
|
|
describe('A2AProvider', () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.mocked(sleep).mockResolvedValue(undefined);
|
|
});
|
|
|
|
it('loads from the provider registry with a bare id and shorthand url', async () => {
|
|
await expect(loadApiProvider('a2a')).resolves.toBeInstanceOf(A2AProvider);
|
|
const loaded = await loadApiProvider('a2a:https://agent.example.com/a2a/v1');
|
|
expect(loaded).toBeInstanceOf(A2AProvider);
|
|
expect(loaded.config.url).toBe('https://agent.example.com/a2a/v1');
|
|
});
|
|
|
|
it('uses the shorthand url when config.url is blank', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
message: {
|
|
role: 'ROLE_AGENT',
|
|
parts: [{ text: 'blank url response' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await provider({ url: '' }).callApi('hi');
|
|
|
|
expect(result.output).toBe('blank url response');
|
|
expect(fetchWithTimeout).toHaveBeenCalledWith(
|
|
'https://agent.example.com/a2a/v1/message:send',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
|
|
it('sends a non-streaming message and extracts the direct message text', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
message: {
|
|
role: 'ROLE_AGENT',
|
|
parts: [{ text: 'hello from a2a' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await provider({
|
|
headers: { Authorization: 'Bearer {{token}}' },
|
|
message: { role: 'ROLE_USER', parts: [{ text: 'Question: {{prompt}}' }] },
|
|
}).callApi('hi', {
|
|
prompt: { raw: '{{prompt}}', label: 'prompt' },
|
|
vars: { token: 'secret-token', sessionId: 'session-1' },
|
|
testCaseId: 'case-1',
|
|
});
|
|
|
|
expect(result.output).toBe('hello from a2a');
|
|
expect(fetchWithTimeout).toHaveBeenCalledWith(
|
|
'https://agent.example.com/a2a/v1/message:send',
|
|
expect.objectContaining({
|
|
body: expect.stringContaining('Question: hi'),
|
|
headers: expect.objectContaining({
|
|
Authorization: 'Bearer secret-token',
|
|
'A2A-Version': '1.0',
|
|
'Content-Type': 'application/a2a+json',
|
|
}),
|
|
method: 'POST',
|
|
}),
|
|
expect.any(Number),
|
|
);
|
|
const requestBody = JSON.parse(vi.mocked(fetchWithTimeout).mock.calls[0]?.[1]?.body as string);
|
|
expect(requestBody.message.contextId).toBe('session-1');
|
|
expect(requestBody.message.messageId).toMatch(/^promptfoo-/);
|
|
});
|
|
|
|
it('sends standards-compliant default A2A messages', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
message: {
|
|
role: 'ROLE_AGENT',
|
|
parts: [{ text: 'ok' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await provider().callApi('hello');
|
|
|
|
expect(result.output).toBe('ok');
|
|
const requestBody = JSON.parse(vi.mocked(fetchWithTimeout).mock.calls[0]?.[1]?.body as string);
|
|
expect(requestBody.message).toMatchObject({
|
|
role: 'ROLE_USER',
|
|
parts: [{ text: 'hello' }],
|
|
});
|
|
});
|
|
|
|
it('returns sessionId from direct message contextId', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
message: {
|
|
contextId: 'a2a-context-1',
|
|
role: 'ROLE_AGENT',
|
|
parts: [{ text: 'context response' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await provider().callApi('hello');
|
|
|
|
expect(result.output).toBe('context response');
|
|
expect(result.sessionId).toBe('a2a-context-1');
|
|
expect(result.metadata?.a2a).toMatchObject({ contextId: 'a2a-context-1' });
|
|
});
|
|
|
|
it('adds image strategy media to default A2A messages', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
message: {
|
|
role: 'ROLE_AGENT',
|
|
parts: [{ text: 'image ok' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await provider().callApi('Please answer the question in the image.', {
|
|
prompt: { raw: '{{prompt}}', label: 'prompt' },
|
|
test: {
|
|
metadata: {
|
|
imageInjectVar: 'image',
|
|
originalText: 'Hidden prompt from the generated image',
|
|
strategyId: 'image',
|
|
},
|
|
},
|
|
vars: {
|
|
image: 'base64-image',
|
|
question: 'Please answer the question in the image.',
|
|
},
|
|
});
|
|
|
|
expect(result.output).toBe('image ok');
|
|
const requestBody = JSON.parse(vi.mocked(fetchWithTimeout).mock.calls[0]?.[1]?.body as string);
|
|
expect(requestBody.message).toMatchObject({
|
|
role: 'ROLE_USER',
|
|
parts: [
|
|
{ text: 'Please answer the question in the image.' },
|
|
{
|
|
filename: 'promptfoo-image.png',
|
|
mediaType: 'image/png',
|
|
raw: 'base64-image',
|
|
},
|
|
],
|
|
});
|
|
expect(JSON.stringify(requestBody.message)).not.toContain(
|
|
'Hidden prompt from the generated image',
|
|
);
|
|
});
|
|
|
|
it('adds audio strategy media without echoing base64 as text', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
message: {
|
|
role: 'ROLE_AGENT',
|
|
parts: [{ text: 'audio ok' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await provider().callApi('base64-audio', {
|
|
prompt: { raw: '{{prompt}}', label: 'prompt' },
|
|
test: {
|
|
metadata: {
|
|
audioInjectVar: 'audio',
|
|
strategyId: 'audio',
|
|
},
|
|
},
|
|
vars: {
|
|
audio: 'base64-audio',
|
|
},
|
|
});
|
|
|
|
expect(result.output).toBe('audio ok');
|
|
const requestBody = JSON.parse(vi.mocked(fetchWithTimeout).mock.calls[0]?.[1]?.body as string);
|
|
expect(requestBody.message).toMatchObject({
|
|
role: 'ROLE_USER',
|
|
parts: [
|
|
{
|
|
filename: 'promptfoo-audio.mp3',
|
|
mediaType: 'audio/mpeg',
|
|
raw: 'base64-audio',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('uses legacy A2A file parts for multimodal default messages on 0.3 agent cards', async () => {
|
|
vi.mocked(fetchWithTimeout)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
preferredTransport: 'HTTP+JSON',
|
|
protocolVersion: '0.3.0',
|
|
url: 'https://agent.example.com/a2a/main',
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
message: {
|
|
role: 'agent',
|
|
parts: [{ text: 'legacy image ok' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await new A2AProvider('a2a', {
|
|
config: {
|
|
agentCardUrl: 'https://agent.example.com/.well-known/agent-card.json',
|
|
},
|
|
}).callApi('Please answer the question in the image.', {
|
|
prompt: { raw: '{{prompt}}', label: 'prompt' },
|
|
test: {
|
|
metadata: {
|
|
strategyId: 'image',
|
|
},
|
|
},
|
|
vars: {
|
|
image: 'data:image/jpeg;base64,base64-image',
|
|
question: 'Please answer the question in the image.',
|
|
},
|
|
});
|
|
|
|
expect(result.output).toBe('legacy image ok');
|
|
const requestBody = JSON.parse(vi.mocked(fetchWithTimeout).mock.calls[1]?.[1]?.body as string);
|
|
expect(requestBody.message).toMatchObject({
|
|
role: 'user',
|
|
parts: [
|
|
{ kind: 'text', text: 'Please answer the question in the image.' },
|
|
{
|
|
file: {
|
|
fileWithBytes: 'base64-image',
|
|
mimeType: 'image/jpeg',
|
|
name: 'promptfoo-image.png',
|
|
},
|
|
kind: 'file',
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it('does not add strategy media when message config is explicit', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
message: {
|
|
role: 'ROLE_AGENT',
|
|
parts: [{ text: 'custom ok' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await provider({
|
|
message: {
|
|
parts: [{ text: 'Custom {{question}}' }],
|
|
role: 'ROLE_USER',
|
|
},
|
|
}).callApi('Please answer the question in the image.', {
|
|
prompt: { raw: '{{prompt}}', label: 'prompt' },
|
|
test: {
|
|
metadata: {
|
|
strategyId: 'image',
|
|
},
|
|
},
|
|
vars: {
|
|
image: 'base64-image',
|
|
question: 'question text',
|
|
},
|
|
});
|
|
|
|
expect(result.output).toBe('custom ok');
|
|
const requestBody = JSON.parse(vi.mocked(fetchWithTimeout).mock.calls[0]?.[1]?.body as string);
|
|
expect(requestBody.message.parts).toEqual([{ text: 'Custom question text' }]);
|
|
});
|
|
|
|
it('discovers an HTTP+JSON interface from the agent card', async () => {
|
|
vi.mocked(fetchWithTimeout)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
capabilities: { streaming: false },
|
|
supportedInterfaces: [
|
|
{
|
|
protocolBinding: 'JSONRPC',
|
|
url: 'https://agent.example.com/jsonrpc',
|
|
},
|
|
{
|
|
protocolBinding: 'HTTP+JSON',
|
|
protocolVersion: '1.0',
|
|
tenant: 'tenant-a',
|
|
url: 'https://agent.example.com/a2a/http',
|
|
},
|
|
],
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
message: {
|
|
role: 'ROLE_AGENT',
|
|
parts: [{ text: 'card response' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await new A2AProvider('a2a', {
|
|
config: {
|
|
agentCardUrl: 'https://agent.example.com/.well-known/agent-card.json',
|
|
},
|
|
}).callApi('hello');
|
|
|
|
expect(result.output).toBe('card response');
|
|
expect(fetchWithTimeout).toHaveBeenNthCalledWith(
|
|
2,
|
|
'https://agent.example.com/a2a/http/message:send',
|
|
expect.objectContaining({
|
|
body: expect.stringContaining('"tenant":"tenant-a"'),
|
|
headers: expect.objectContaining({ 'A2A-Version': '1.0' }),
|
|
}),
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
|
|
it('discovers HTTP+JSON from legacy additionalInterfaces agent cards', async () => {
|
|
vi.mocked(fetchWithTimeout)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
preferredTransport: 'JSONRPC',
|
|
url: 'https://agent.example.com/jsonrpc',
|
|
additionalInterfaces: [
|
|
{
|
|
transport: 'HTTP+JSON',
|
|
protocolVersion: '1.0',
|
|
tenant: 'tenant-a',
|
|
url: 'https://agent.example.com/a2a/http',
|
|
},
|
|
],
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
message: {
|
|
role: 'ROLE_AGENT',
|
|
parts: [{ text: 'legacy card response' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await new A2AProvider('a2a', {
|
|
config: {
|
|
agentCardUrl: 'https://agent.example.com/.well-known/agent-card.json',
|
|
},
|
|
}).callApi('hello');
|
|
|
|
expect(result.output).toBe('legacy card response');
|
|
expect(fetchWithTimeout).toHaveBeenNthCalledWith(
|
|
2,
|
|
'https://agent.example.com/a2a/http/message:send',
|
|
expect.objectContaining({
|
|
body: expect.stringContaining('"tenant":"tenant-a"'),
|
|
}),
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
|
|
it('fails fast when an agent card only advertises JSON-RPC', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
preferredTransport: 'JSONRPC',
|
|
url: 'https://agent.example.com/jsonrpc',
|
|
}),
|
|
);
|
|
|
|
const result = await new A2AProvider('a2a', {
|
|
config: {
|
|
agentCardUrl: 'https://agent.example.com/.well-known/agent-card.json',
|
|
},
|
|
}).callApi('hello');
|
|
|
|
expect(result.error).toContain('does not advertise a supported HTTP+JSON interface');
|
|
expect(fetchWithTimeout).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('does not treat a legacy agent card URL without preferredTransport as HTTP+JSON', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
url: 'https://agent.example.com/jsonrpc',
|
|
}),
|
|
);
|
|
|
|
const result = await new A2AProvider('a2a', {
|
|
config: {
|
|
agentCardUrl: 'https://agent.example.com/.well-known/agent-card.json',
|
|
},
|
|
}).callApi('hello');
|
|
|
|
expect(result.error).toContain('does not advertise a supported HTTP+JSON interface');
|
|
expect(fetchWithTimeout).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('uses the Agent Card protocol version when the main URL is HTTP+JSON', async () => {
|
|
vi.mocked(fetchWithTimeout)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
preferredTransport: 'HTTP+JSON',
|
|
protocolVersion: '0.3.0',
|
|
url: 'https://agent.example.com/a2a/main',
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
message: {
|
|
role: 'agent',
|
|
parts: [{ text: 'main url response' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await new A2AProvider('a2a', {
|
|
config: {
|
|
agentCardUrl: 'https://agent.example.com/.well-known/agent-card.json',
|
|
},
|
|
}).callApi('hello');
|
|
|
|
expect(result.output).toBe('main url response');
|
|
expect(fetchWithTimeout).toHaveBeenNthCalledWith(
|
|
2,
|
|
'https://agent.example.com/a2a/main/message:send',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({ 'A2A-Version': '0.3.0' }),
|
|
}),
|
|
expect.any(Number),
|
|
);
|
|
const requestBody = JSON.parse(vi.mocked(fetchWithTimeout).mock.calls[1]?.[1]?.body as string);
|
|
expect(requestBody.message).toMatchObject({
|
|
role: 'user',
|
|
parts: [{ kind: 'text', text: 'hello' }],
|
|
});
|
|
});
|
|
|
|
it('does not inherit discovered tenant when config.url overrides Agent Card interface URL', async () => {
|
|
vi.mocked(fetchWithTimeout)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
supportedInterfaces: [
|
|
{
|
|
protocolBinding: 'HTTP+JSON',
|
|
tenant: 'tenant-a',
|
|
url: 'https://agent.example.com/a2a/tenant-a',
|
|
},
|
|
],
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
message: {
|
|
role: 'agent',
|
|
parts: [{ text: 'explicit url response' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await new A2AProvider('a2a', {
|
|
config: {
|
|
agentCardUrl: 'https://agent.example.com/.well-known/agent-card.json',
|
|
url: 'https://agent.example.com/a2a/explicit',
|
|
},
|
|
}).callApi('hello');
|
|
|
|
expect(result.output).toBe('explicit url response');
|
|
const requestBody = JSON.parse(vi.mocked(fetchWithTimeout).mock.calls[1]?.[1]?.body as string);
|
|
expect(requestBody).not.toHaveProperty('tenant');
|
|
expect(fetchWithTimeout).toHaveBeenNthCalledWith(
|
|
2,
|
|
'https://agent.example.com/a2a/explicit/message:send',
|
|
expect.any(Object),
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
|
|
it('applies bearer auth to agent card and operation requests', async () => {
|
|
vi.mocked(fetchWithTimeout)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
supportedInterfaces: [
|
|
{
|
|
protocolBinding: 'HTTP+JSON',
|
|
url: 'https://agent.example.com/a2a/http',
|
|
},
|
|
],
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
message: {
|
|
role: 'ROLE_AGENT',
|
|
parts: [{ text: 'authenticated response' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await new A2AProvider('a2a', {
|
|
config: {
|
|
agentCardUrl: 'https://agent.example.com/.well-known/agent-card.json',
|
|
auth: { type: 'bearer', token: '{{A2A_TOKEN}}' },
|
|
},
|
|
}).callApi('hello', {
|
|
prompt: { raw: '{{prompt}}', label: 'prompt' },
|
|
vars: { A2A_TOKEN: 'secret-token' },
|
|
});
|
|
|
|
expect(result.output).toBe('authenticated response');
|
|
expect(fetchWithTimeout).toHaveBeenNthCalledWith(
|
|
1,
|
|
'https://agent.example.com/.well-known/agent-card.json',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({ Authorization: 'Bearer secret-token' }),
|
|
method: 'GET',
|
|
}),
|
|
expect.any(Number),
|
|
);
|
|
expect(fetchWithTimeout).toHaveBeenNthCalledWith(
|
|
2,
|
|
'https://agent.example.com/a2a/http/message:send',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({ Authorization: 'Bearer secret-token' }),
|
|
method: 'POST',
|
|
}),
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
|
|
it('supports basic auth headers', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
message: {
|
|
role: 'ROLE_AGENT',
|
|
parts: [{ text: 'basic auth response' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await provider({
|
|
auth: { type: 'basic', username: 'user', password: 'pass' },
|
|
}).callApi('hi');
|
|
|
|
expect(result.output).toBe('basic auth response');
|
|
expect(fetchWithTimeout).toHaveBeenCalledWith(
|
|
'https://agent.example.com/a2a/v1/message:send',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({
|
|
Authorization: `Basic ${Buffer.from('user:pass').toString('base64')}`,
|
|
}),
|
|
}),
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
|
|
it('supports api key auth in query parameters', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
message: {
|
|
role: 'ROLE_AGENT',
|
|
parts: [{ text: 'query auth response' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await provider({
|
|
auth: {
|
|
keyName: 'api_key',
|
|
placement: 'query',
|
|
type: 'api_key',
|
|
value: '{{A2A_API_KEY}}',
|
|
},
|
|
}).callApi('hi', {
|
|
prompt: { raw: '{{prompt}}', label: 'prompt' },
|
|
vars: { A2A_API_KEY: 'query-secret' },
|
|
});
|
|
|
|
expect(result.output).toBe('query auth response');
|
|
expect(fetchWithTimeout).toHaveBeenCalledWith(
|
|
'https://agent.example.com/a2a/v1/message:send?api_key=query-secret',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
|
|
it('preserves endpoint query strings when appending operation paths', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
message: {
|
|
role: 'agent',
|
|
parts: [{ text: 'query response' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await new A2AProvider('a2a:https://agent.example.com/a2a/v1?tenant=a', {
|
|
config: {},
|
|
}).callApi('hi');
|
|
|
|
expect(result.output).toBe('query response');
|
|
expect(fetchWithTimeout).toHaveBeenCalledWith(
|
|
'https://agent.example.com/a2a/v1/message:send?tenant=a',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
|
|
it('fetches OAuth client credentials tokens for operation requests', async () => {
|
|
vi.mocked(fetchWithProxy).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
access_token: 'oauth-token',
|
|
expires_in: 3600,
|
|
}),
|
|
);
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
message: {
|
|
role: 'ROLE_AGENT',
|
|
parts: [{ text: 'oauth response' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await provider({
|
|
auth: {
|
|
clientId: '{{clientId}}',
|
|
clientSecret: '{{clientSecret}}',
|
|
grantType: 'client_credentials',
|
|
scopes: ['a2a.send'],
|
|
tokenUrl: 'https://auth.example.com/oauth/token',
|
|
type: 'oauth',
|
|
},
|
|
}).callApi('hi', {
|
|
prompt: { raw: '{{prompt}}', label: 'prompt' },
|
|
vars: { clientId: 'client-1', clientSecret: 'client-secret' },
|
|
});
|
|
|
|
expect(result.output).toBe('oauth response');
|
|
expect(fetchWithProxy).toHaveBeenCalledWith(
|
|
'https://auth.example.com/oauth/token',
|
|
expect.objectContaining({
|
|
body: expect.stringContaining('client_id=client-1'),
|
|
headers: expect.objectContaining({
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
}),
|
|
method: 'POST',
|
|
}),
|
|
);
|
|
expect(fetchWithProxy).toHaveBeenCalledWith(
|
|
'https://auth.example.com/oauth/token',
|
|
expect.objectContaining({
|
|
body: expect.stringContaining('scope=a2a.send'),
|
|
}),
|
|
);
|
|
expect(fetchWithTimeout).toHaveBeenCalledWith(
|
|
'https://agent.example.com/a2a/v1/message:send',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({ Authorization: 'Bearer oauth-token' }),
|
|
}),
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
|
|
it('renders templated OAuth scope strings before splitting them', async () => {
|
|
vi.mocked(fetchWithProxy).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
access_token: 'oauth-token',
|
|
expires_in: 3600,
|
|
}),
|
|
);
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
message: {
|
|
role: 'ROLE_AGENT',
|
|
parts: [{ text: 'oauth scoped response' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await provider({
|
|
auth: {
|
|
clientId: 'client-1',
|
|
clientSecret: 'client-secret',
|
|
grantType: 'client_credentials',
|
|
scopes: '{{ env.A2A_SCOPES }}',
|
|
tokenUrl: 'https://auth.example.com/oauth/template-token',
|
|
type: 'oauth',
|
|
},
|
|
}).callApi('hi', {
|
|
prompt: { raw: '{{prompt}}', label: 'prompt' },
|
|
vars: { env: { A2A_SCOPES: 'a2a.send a2a.read' } },
|
|
});
|
|
|
|
expect(result.output).toBe('oauth scoped response');
|
|
expect(fetchWithProxy).toHaveBeenCalledWith(
|
|
'https://auth.example.com/oauth/template-token',
|
|
expect.objectContaining({
|
|
body: expect.stringContaining('scope=a2a.send+a2a.read'),
|
|
method: 'POST',
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('extracts output from a completed task artifact', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
task: {
|
|
id: 'task-1',
|
|
status: { state: 'TASK_STATE_COMPLETED' },
|
|
artifacts: [{ parts: [{ text: 'artifact text' }] }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await provider().callApi('hi');
|
|
|
|
expect(result.output).toBe('artifact text');
|
|
expect(result.metadata?.a2a).toMatchObject({
|
|
mode: 'send',
|
|
taskId: 'task-1',
|
|
taskState: 'TASK_STATE_COMPLETED',
|
|
});
|
|
});
|
|
|
|
it('prefers completed task artifacts over status messages', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
task: {
|
|
id: 'task-1',
|
|
status: {
|
|
state: 'TASK_STATE_COMPLETED',
|
|
message: { parts: [{ text: 'Completed lifecycle status' }] },
|
|
},
|
|
artifacts: [{ parts: [{ text: 'Final artifact text' }] }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await provider().callApi('hi');
|
|
|
|
expect(result.output).toBe('Final artifact text');
|
|
});
|
|
|
|
it('falls back to completed task status text when artifacts are absent', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
task: {
|
|
id: 'task-1',
|
|
status: {
|
|
state: 'TASK_STATE_COMPLETED',
|
|
message: { parts: [{ text: 'Completed fallback text' }] },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await provider().callApi('hi');
|
|
|
|
expect(result.output).toBe('Completed fallback text');
|
|
});
|
|
|
|
it('accepts bare task responses from message:send', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
id: 'task-1',
|
|
status: { state: 'TASK_STATE_COMPLETED' },
|
|
artifacts: [{ parts: [{ text: 'bare task text' }] }],
|
|
}),
|
|
);
|
|
|
|
const result = await provider().callApi('hi');
|
|
|
|
expect(result.output).toBe('bare task text');
|
|
});
|
|
|
|
it('polls non-terminal tasks until completion', async () => {
|
|
vi.mocked(fetchWithTimeout)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
task: {
|
|
id: 'task-1',
|
|
status: { state: 'TASK_STATE_WORKING' },
|
|
},
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
id: 'task-1',
|
|
contextId: 'a2a-task-context',
|
|
status: { state: 'TASK_STATE_COMPLETED' },
|
|
artifacts: [{ parts: [{ text: 'done later' }] }],
|
|
}),
|
|
);
|
|
|
|
const result = await provider({ polling: { intervalMs: 0, timeoutMs: 1000 } }).callApi('hi');
|
|
|
|
expect(result.output).toBe('done later');
|
|
expect(result.sessionId).toBe('a2a-task-context');
|
|
expect(fetchWithTimeout).toHaveBeenNthCalledWith(
|
|
2,
|
|
'https://agent.example.com/a2a/v1/tasks/task-1',
|
|
expect.objectContaining({ method: 'GET' }),
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
|
|
it('uses the final polled task as raw output for async send responses', async () => {
|
|
vi.mocked(fetchWithTimeout)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
task: {
|
|
id: 'task-1',
|
|
status: { state: 'TASK_STATE_WORKING' },
|
|
},
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
id: 'task-1',
|
|
status: { state: 'TASK_STATE_COMPLETED' },
|
|
metadata: { structured: true },
|
|
}),
|
|
);
|
|
|
|
const result = await provider({ polling: { intervalMs: 0, timeoutMs: 1000 } }).callApi('hi');
|
|
|
|
expect(result.output).toContain('"structured":true');
|
|
expect(result.raw).toMatchObject({
|
|
id: 'task-1',
|
|
metadata: { structured: true },
|
|
status: { state: 'TASK_STATE_COMPLETED' },
|
|
});
|
|
});
|
|
|
|
it('includes tenant query parameter when polling tasks', async () => {
|
|
vi.mocked(fetchWithTimeout)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
task: {
|
|
id: 'task-1',
|
|
status: { state: 'TASK_STATE_WORKING' },
|
|
},
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
id: 'task-1',
|
|
status: { state: 'TASK_STATE_COMPLETED' },
|
|
artifacts: [{ parts: [{ text: 'tenant done' }] }],
|
|
}),
|
|
);
|
|
|
|
const result = await provider({
|
|
polling: { intervalMs: 0, timeoutMs: 1000 },
|
|
tenant: 'tenant-a',
|
|
}).callApi('hi');
|
|
|
|
expect(result.output).toBe('tenant done');
|
|
expect(fetchWithTimeout).toHaveBeenNthCalledWith(
|
|
2,
|
|
'https://agent.example.com/a2a/v1/tasks/task-1?tenant=tenant-a',
|
|
expect.objectContaining({ method: 'GET' }),
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
|
|
it('recognizes standard completed task state spelling', async () => {
|
|
vi.mocked(fetchWithTimeout)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
task: {
|
|
id: 'task-1',
|
|
status: { state: 'working' },
|
|
},
|
|
}),
|
|
)
|
|
.mockResolvedValueOnce(
|
|
jsonResponse({
|
|
id: 'task-1',
|
|
status: { state: 'completed' },
|
|
artifacts: [{ parts: [{ text: 'standard done' }] }],
|
|
}),
|
|
);
|
|
|
|
const result = await provider({ polling: { intervalMs: 0, timeoutMs: 1000 } }).callApi('hi');
|
|
|
|
expect(result.output).toBe('standard done');
|
|
expect(sleep).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
['TASK_STATE_FAILED', 'failed'],
|
|
['TASK_STATE_CANCELED', 'canceled'],
|
|
['TASK_STATE_CANCELLED', 'canceled'],
|
|
['TASK_STATE_REJECTED', 'rejected'],
|
|
['TASK_STATE_INPUT_REQUIRED', 'requires additional input'],
|
|
['TASK_STATE_AUTH_REQUIRED', 'requires additional authentication'],
|
|
])('returns an error for %s tasks', async (state, expected) => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
task: {
|
|
id: 'task-1',
|
|
status: {
|
|
state,
|
|
message: { parts: [{ text: 'state detail' }] },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await provider().callApi('hi');
|
|
|
|
expect(result.error).toContain(expected);
|
|
expect(result.error).toContain('state detail');
|
|
});
|
|
|
|
it.each([
|
|
['failed', 'failed'],
|
|
['canceled', 'canceled'],
|
|
['rejected', 'rejected'],
|
|
['input-required', 'requires additional input'],
|
|
['auth-required', 'requires additional authentication'],
|
|
])('returns an error for standard %s tasks', async (state, expected) => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
task: {
|
|
id: 'task-1',
|
|
status: {
|
|
state,
|
|
message: { parts: [{ text: 'state detail' }] },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await provider().callApi('hi');
|
|
|
|
expect(result.error).toContain(expected);
|
|
expect(result.error).toContain('state detail');
|
|
});
|
|
|
|
it('filters user messages from history fallback output', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
task: {
|
|
id: 'task-1',
|
|
status: { state: 'completed' },
|
|
history: [
|
|
{ role: 'ROLE_USER', parts: [{ text: 'user prompt' }] },
|
|
{ role: 'ROLE_AGENT', parts: [{ text: 'agent answer' }] },
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await provider().callApi('hi');
|
|
|
|
expect(result.output).toBe('agent answer');
|
|
});
|
|
|
|
it('falls back to raw JSON when history only contains user messages', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
task: {
|
|
id: 'task-1',
|
|
status: { state: 'completed' },
|
|
history: [{ role: 'ROLE_USER', parts: [{ text: 'user prompt' }] }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await provider().callApi('hi');
|
|
|
|
expect(result.output).toContain('"history"');
|
|
expect(result.output).toContain('"user prompt"');
|
|
});
|
|
|
|
it('consumes a message-only SSE stream', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
sseResponse([
|
|
{
|
|
message: {
|
|
role: 'ROLE_AGENT',
|
|
parts: [{ text: 'streamed text' }],
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
|
|
const result = await provider({ mode: 'stream' }).callApi('hi');
|
|
|
|
expect(result.output).toBe('streamed text');
|
|
expect(fetchWithTimeout).toHaveBeenCalledWith(
|
|
'https://agent.example.com/a2a/v1/message:stream',
|
|
expect.objectContaining({
|
|
headers: expect.objectContaining({ Accept: 'text/event-stream' }),
|
|
}),
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
|
|
it('accumulates task lifecycle SSE events', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
sseResponse([
|
|
{
|
|
task: {
|
|
id: 'task-1',
|
|
status: { state: 'TASK_STATE_WORKING' },
|
|
},
|
|
},
|
|
{
|
|
artifactUpdate: {
|
|
taskId: 'task-1',
|
|
artifact: { parts: [{ text: 'partial artifact' }] },
|
|
},
|
|
},
|
|
{
|
|
statusUpdate: {
|
|
contextId: 'a2a-stream-context',
|
|
taskId: 'task-1',
|
|
status: { state: 'TASK_STATE_COMPLETED' },
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
|
|
const result = await provider({ mode: 'stream' }).callApi('hi');
|
|
|
|
expect(result.output).toBe('partial artifact');
|
|
expect(result.sessionId).toBe('a2a-stream-context');
|
|
expect(result.metadata?.a2a).toMatchObject({
|
|
contextId: 'a2a-stream-context',
|
|
mode: 'stream',
|
|
taskId: 'task-1',
|
|
taskState: 'TASK_STATE_COMPLETED',
|
|
});
|
|
});
|
|
|
|
it('merges appended streaming artifact chunks for the same artifact', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
sseResponse([
|
|
{
|
|
task: {
|
|
id: 'task-1',
|
|
status: { state: 'TASK_STATE_WORKING' },
|
|
},
|
|
},
|
|
{
|
|
artifactUpdate: {
|
|
append: false,
|
|
taskId: 'task-1',
|
|
artifact: { artifactId: 'artifact-1', parts: [{ text: 'hel' }] },
|
|
},
|
|
},
|
|
{
|
|
artifactUpdate: {
|
|
append: true,
|
|
taskId: 'task-1',
|
|
artifact: { artifactId: 'artifact-1', parts: [{ text: 'lo' }] },
|
|
},
|
|
},
|
|
{
|
|
statusUpdate: {
|
|
taskId: 'task-1',
|
|
status: { state: 'TASK_STATE_COMPLETED' },
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
|
|
const result = await provider({ mode: 'stream' }).callApi('hi');
|
|
|
|
expect(result.output).toBe('hello');
|
|
expect(result.raw).toHaveLength(4);
|
|
});
|
|
|
|
it('preserves streamed artifacts when a terminal task frame omits artifacts', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
sseResponse([
|
|
{
|
|
task: {
|
|
id: 'task-1',
|
|
status: { state: 'TASK_STATE_WORKING' },
|
|
},
|
|
},
|
|
{
|
|
artifactUpdate: {
|
|
taskId: 'task-1',
|
|
artifact: { artifactId: 'artifact-1', parts: [{ text: 'streamed artifact result' }] },
|
|
},
|
|
},
|
|
{
|
|
task: {
|
|
id: 'task-1',
|
|
status: { state: 'TASK_STATE_COMPLETED' },
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
|
|
const result = await provider({ mode: 'stream' }).callApi('hi');
|
|
|
|
expect(result.output).toBe('streamed artifact result');
|
|
expect(result.metadata?.a2a).toMatchObject({
|
|
taskId: 'task-1',
|
|
taskState: 'TASK_STATE_COMPLETED',
|
|
});
|
|
});
|
|
|
|
it('replaces existing streaming artifacts when append is false', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
sseResponse([
|
|
{
|
|
task: {
|
|
id: 'task-1',
|
|
status: { state: 'TASK_STATE_WORKING' },
|
|
},
|
|
},
|
|
{
|
|
artifactUpdate: {
|
|
append: false,
|
|
taskId: 'task-1',
|
|
artifact: { artifactId: 'artifact-1', parts: [{ text: 'stale draft' }] },
|
|
},
|
|
},
|
|
{
|
|
artifactUpdate: {
|
|
append: false,
|
|
taskId: 'task-1',
|
|
artifact: { artifactId: 'artifact-1', parts: [{ text: 'final answer' }] },
|
|
},
|
|
},
|
|
{
|
|
statusUpdate: {
|
|
taskId: 'task-1',
|
|
status: { state: 'TASK_STATE_COMPLETED' },
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
|
|
const result = await provider({ mode: 'stream' }).callApi('hi');
|
|
|
|
expect(result.output).toBe('final answer');
|
|
expect(result.output).not.toContain('stale draft');
|
|
});
|
|
|
|
it('returns a provider error for malformed SSE events', async () => {
|
|
const encoder = new TextEncoder();
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
new Response(
|
|
new ReadableStream({
|
|
start(controller) {
|
|
controller.enqueue(encoder.encode('data: {not-json}\n\n'));
|
|
controller.close();
|
|
},
|
|
}),
|
|
{ status: 200, statusText: 'OK' },
|
|
),
|
|
);
|
|
|
|
const result = await provider({ mode: 'stream' }).callApi('hi');
|
|
|
|
expect(result.error).toContain('A2A Provider error');
|
|
});
|
|
|
|
it('returns a provider error for SSE protocol error events instead of prior partial output', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
sseResponse([
|
|
{
|
|
message: {
|
|
role: 'ROLE_AGENT',
|
|
parts: [{ text: 'partial output' }],
|
|
},
|
|
},
|
|
{
|
|
error: {
|
|
code: -32000,
|
|
message: 'stream failed',
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
|
|
const result = await provider({ mode: 'stream' }).callApi('hi');
|
|
|
|
expect(result.output).toBeUndefined();
|
|
expect(result.error).toContain('A2A stream error: stream failed (code: -32000)');
|
|
});
|
|
|
|
it('forwards abort signals to requests', async () => {
|
|
const controller = new AbortController();
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
message: {
|
|
role: 'ROLE_AGENT',
|
|
parts: [{ text: 'ok' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
await provider().callApi('hi', undefined, { abortSignal: controller.signal });
|
|
|
|
expect(fetchWithTimeout).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.objectContaining({ signal: controller.signal }),
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
|
|
it('applies a custom transformResponse', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
message: {
|
|
role: 'ROLE_AGENT',
|
|
parts: [{ text: 'original' }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await provider({
|
|
transformResponse: '(result, text, context) => ({ output: `${context.mode}:${text}` })',
|
|
}).callApi('hi');
|
|
|
|
expect(result.output).toBe('send:original');
|
|
});
|
|
|
|
it('supports HTTP-style json variable in string transformResponse', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
task: {
|
|
id: 'task-1',
|
|
status: { state: 'TASK_STATE_COMPLETED' },
|
|
artifacts: [{ parts: [{ text: 'artifact text' }] }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await provider({
|
|
transformResponse: '({ output: `${json.task.id}:${text}` })',
|
|
}).callApi('hi');
|
|
|
|
expect(result.output).toBe('task-1:artifact text');
|
|
});
|
|
|
|
it('keeps result as an alias in string transformResponse', async () => {
|
|
vi.mocked(fetchWithTimeout).mockResolvedValueOnce(
|
|
jsonResponse({
|
|
task: {
|
|
id: 'task-1',
|
|
status: { state: 'TASK_STATE_COMPLETED' },
|
|
artifacts: [{ parts: [{ text: 'artifact text' }] }],
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await provider({
|
|
transformResponse: '({ output: result.task.id })',
|
|
}).callApi('hi');
|
|
|
|
expect(result.output).toBe('task-1');
|
|
});
|
|
});
|