chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import type { AgentCard, Artifact, Message, Part, Task } from '@a2a-js/sdk'
|
||||
import { Role, TaskState } from '@a2a-js/sdk'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/core/security/input-validation.server', () => ({
|
||||
validateUrlWithDNS: vi.fn(),
|
||||
secureFetchWithPinnedIP: vi.fn(),
|
||||
}))
|
||||
|
||||
import {
|
||||
agentCardOutput,
|
||||
buildUserMessage,
|
||||
extractText,
|
||||
isTaskResult,
|
||||
messageOutput,
|
||||
taskErrored,
|
||||
taskOutput,
|
||||
} from '@/lib/a2a/client'
|
||||
|
||||
function textPart(value: string): Part {
|
||||
return { content: { $case: 'text', value }, metadata: undefined, filename: '', mediaType: '' }
|
||||
}
|
||||
|
||||
function message(role: Role, parts: Part[]): Message {
|
||||
return {
|
||||
messageId: 'm-1',
|
||||
contextId: 'ctx-1',
|
||||
taskId: 'task-1',
|
||||
role,
|
||||
parts,
|
||||
metadata: undefined,
|
||||
extensions: [],
|
||||
referenceTaskIds: [],
|
||||
}
|
||||
}
|
||||
|
||||
function artifact(name: string, description: string, parts: Part[]): Artifact {
|
||||
return {
|
||||
artifactId: 'a-1',
|
||||
name,
|
||||
description,
|
||||
parts,
|
||||
metadata: undefined,
|
||||
extensions: [],
|
||||
} as unknown as Artifact
|
||||
}
|
||||
|
||||
function task(opts: {
|
||||
state?: TaskState
|
||||
statusMessage?: Message
|
||||
hasStatus?: boolean
|
||||
history?: Message[]
|
||||
artifacts?: Artifact[]
|
||||
}): Task {
|
||||
const hasStatus = opts.hasStatus ?? (opts.state !== undefined || opts.statusMessage !== undefined)
|
||||
return {
|
||||
id: 'task-1',
|
||||
contextId: 'ctx-1',
|
||||
status: hasStatus
|
||||
? {
|
||||
state: opts.state ?? TaskState.TASK_STATE_UNSPECIFIED,
|
||||
message: opts.statusMessage,
|
||||
timestamp: undefined,
|
||||
}
|
||||
: undefined,
|
||||
artifacts: opts.artifacts ?? [],
|
||||
history: opts.history ?? [],
|
||||
metadata: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
describe('buildUserMessage', () => {
|
||||
it('builds a text-only user message', () => {
|
||||
const m = buildUserMessage({ text: 'hello' })
|
||||
expect(m.role).toBe(Role.ROLE_USER)
|
||||
expect(m.parts).toHaveLength(1)
|
||||
expect(m.parts[0].content).toEqual({ $case: 'text', value: 'hello' })
|
||||
expect(m.taskId).toBe('')
|
||||
expect(m.contextId).toBe('')
|
||||
})
|
||||
|
||||
it('passes through taskId and contextId', () => {
|
||||
const m = buildUserMessage({ text: 'hi', taskId: 't', contextId: 'c' })
|
||||
expect(m.taskId).toBe('t')
|
||||
expect(m.contextId).toBe('c')
|
||||
})
|
||||
|
||||
it('appends a data part for structured data', () => {
|
||||
const data = { foo: 'bar' }
|
||||
const m = buildUserMessage({ text: 'hi', data })
|
||||
expect(m.parts).toHaveLength(2)
|
||||
expect(m.parts[1].content).toEqual({ $case: 'data', value: data })
|
||||
})
|
||||
|
||||
it('builds a raw file part from resolved bytes', () => {
|
||||
const bytes = new TextEncoder().encode('hello')
|
||||
const m = buildUserMessage({
|
||||
text: 'hi',
|
||||
files: [{ bytes, name: 'f.txt', mediaType: 'text/plain' }],
|
||||
})
|
||||
const content = m.parts[1].content
|
||||
expect(content?.$case).toBe('raw')
|
||||
if (content?.$case === 'raw') expect(Buffer.from(content.value).toString()).toBe('hello')
|
||||
expect(m.parts[1].filename).toBe('f.txt')
|
||||
expect(m.parts[1].mediaType).toBe('text/plain')
|
||||
})
|
||||
})
|
||||
|
||||
describe('extractText', () => {
|
||||
it('joins text parts and ignores non-text parts', () => {
|
||||
const m = message(Role.ROLE_AGENT, [
|
||||
textPart('a'),
|
||||
{ content: { $case: 'data', value: {} }, metadata: undefined, filename: '', mediaType: '' },
|
||||
textPart('b'),
|
||||
])
|
||||
expect(extractText(m)).toBe('a\nb')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isTaskResult', () => {
|
||||
it('treats objects with a status key as tasks', () => {
|
||||
expect(isTaskResult(task({ state: TaskState.TASK_STATE_COMPLETED }))).toBe(true)
|
||||
})
|
||||
|
||||
it('treats messages as non-tasks', () => {
|
||||
expect(isTaskResult(message(Role.ROLE_AGENT, [textPart('hi')]))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('taskErrored', () => {
|
||||
it.each([
|
||||
[TaskState.TASK_STATE_FAILED, true],
|
||||
[TaskState.TASK_STATE_REJECTED, true],
|
||||
[TaskState.TASK_STATE_COMPLETED, false],
|
||||
[TaskState.TASK_STATE_INPUT_REQUIRED, false],
|
||||
[TaskState.TASK_STATE_AUTH_REQUIRED, false],
|
||||
[TaskState.TASK_STATE_CANCELED, false],
|
||||
[TaskState.TASK_STATE_WORKING, false],
|
||||
])('state %s -> errored %s', (state, expected) => {
|
||||
expect(taskErrored(task({ state }))).toBe(expected)
|
||||
})
|
||||
|
||||
it('is false when status is missing', () => {
|
||||
expect(taskErrored(task({ hasStatus: false }))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('taskOutput', () => {
|
||||
it('maps state to a friendly label', () => {
|
||||
expect(taskOutput(task({ state: TaskState.TASK_STATE_COMPLETED })).state).toBe('completed')
|
||||
expect(taskOutput(task({ state: TaskState.TASK_STATE_INPUT_REQUIRED })).state).toBe(
|
||||
'input-required'
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the latest agent message from history for content', () => {
|
||||
const out = taskOutput(
|
||||
task({
|
||||
state: TaskState.TASK_STATE_COMPLETED,
|
||||
history: [
|
||||
message(Role.ROLE_USER, [textPart('q')]),
|
||||
message(Role.ROLE_AGENT, [textPart('first')]),
|
||||
message(Role.ROLE_AGENT, [textPart('latest')]),
|
||||
],
|
||||
})
|
||||
)
|
||||
expect(out.content).toBe('latest')
|
||||
})
|
||||
|
||||
it('falls back to the status message when history has no agent text', () => {
|
||||
const out = taskOutput(
|
||||
task({
|
||||
state: TaskState.TASK_STATE_INPUT_REQUIRED,
|
||||
statusMessage: message(Role.ROLE_AGENT, [textPart('need more info')]),
|
||||
})
|
||||
)
|
||||
expect(out.content).toBe('need more info')
|
||||
})
|
||||
|
||||
it('maps artifacts to flattened output', () => {
|
||||
const out = taskOutput(
|
||||
task({
|
||||
state: TaskState.TASK_STATE_COMPLETED,
|
||||
artifacts: [artifact('report', 'the report', [textPart('body')])],
|
||||
})
|
||||
)
|
||||
expect(out.artifacts).toEqual([{ name: 'report', description: 'the report', content: 'body' }])
|
||||
})
|
||||
|
||||
it('carries task and context ids', () => {
|
||||
const out = taskOutput(task({ state: TaskState.TASK_STATE_COMPLETED }))
|
||||
expect(out.taskId).toBe('task-1')
|
||||
expect(out.contextId).toBe('ctx-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('messageOutput', () => {
|
||||
it('maps a direct message to a completed output', () => {
|
||||
const out = messageOutput(message(Role.ROLE_AGENT, [textPart('done')]))
|
||||
expect(out).toEqual({
|
||||
content: 'done',
|
||||
taskId: 'task-1',
|
||||
contextId: 'ctx-1',
|
||||
state: 'completed',
|
||||
artifacts: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentCardOutput', () => {
|
||||
function makeCard(fields: {
|
||||
supportedInterfaces?: Array<{ url: string; protocolVersion: string }>
|
||||
skills?: Array<{ id: string; name: string; description: string }>
|
||||
}): AgentCard {
|
||||
return {
|
||||
name: 'Agent',
|
||||
description: 'desc',
|
||||
version: '1.2.3',
|
||||
supportedInterfaces: fields.supportedInterfaces ?? [],
|
||||
capabilities: undefined,
|
||||
skills: fields.skills ?? [],
|
||||
defaultInputModes: [],
|
||||
defaultOutputModes: [],
|
||||
} as unknown as AgentCard
|
||||
}
|
||||
|
||||
it('falls back to the agent url when there are no interfaces', () => {
|
||||
const out = agentCardOutput(makeCard({}), 'https://fallback.example/a2a')
|
||||
expect(out.url).toBe('https://fallback.example/a2a')
|
||||
expect(out.protocolVersion).toBe('')
|
||||
expect(out.capabilities).toEqual({
|
||||
streaming: false,
|
||||
pushNotifications: false,
|
||||
extendedAgentCard: false,
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers the first supported interface', () => {
|
||||
const out = agentCardOutput(
|
||||
makeCard({
|
||||
supportedInterfaces: [{ url: 'https://agent.example/rpc', protocolVersion: '1.0' }],
|
||||
}),
|
||||
'https://fallback.example/a2a'
|
||||
)
|
||||
expect(out.url).toBe('https://agent.example/rpc')
|
||||
expect(out.protocolVersion).toBe('1.0')
|
||||
})
|
||||
|
||||
it('maps skills to id, name, and description', () => {
|
||||
const out = agentCardOutput(
|
||||
makeCard({ skills: [{ id: 's1', name: 'Search', description: 'searches' }] }),
|
||||
'https://fallback.example/a2a'
|
||||
)
|
||||
expect(out.skills).toEqual([{ id: 's1', name: 'Search', description: 'searches' }])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user