Skip to content

Commit 4b8f783

Browse files
feat: Integrate CI workflow for tck mandatory tests (#164)
# Description This PR introduces a new CI pipeline that will run automatically on every push and pull request on the main branch. The created pipeline is responsible for running the tck mandatory tests against the the newly created sample agent in the tck forlder. Thank you for opening a Pull Request! Before submitting your PR, there are a few things you can do to make sure it goes smoothly: - [x] Follow the [`CONTRIBUTING` Guide](https://github.com/google-a2a/a2a-js/blob/main/CONTRIBUTING.md). - [x] Make your Pull Request title in the <https://www.conventionalcommits.org/> specification. - Important Prefixes for [release-please](https://github.com/googleapis/release-please): - `fix:` which represents bug fixes, and correlates to a [SemVer](https://semver.org/) patch. - `feat:` represents a new feature, and correlates to a SemVer minor. - `feat!:`, or `fix!:`, `refactor!:`, etc., which represent a breaking change (indicated by the `!`) and will result in a SemVer major. - [x] Ensure the tests and linter pass - [x] Appropriate docs were updated (if necessary) Fixes #159 🦕
1 parent 3eec0bd commit 4b8f783

File tree

4 files changed

+343
-0
lines changed

4 files changed

+343
-0
lines changed

.github/workflows/run-tck.yaml

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
name: Run TCK
2+
3+
on:
4+
push:
5+
branches: [ "main" ]
6+
pull_request:
7+
branches: [ "main" ]
8+
9+
10+
env:
11+
# TODO once we have the TCK for 0.4.0 we will need to look at the branch to decide which TCK version to run.
12+
# Tag of the TCK
13+
TCK_VERSION: 0.3.0.beta3
14+
# Tells uv to not need a venv, and instead use system
15+
UV_SYSTEM_PYTHON: 1
16+
# SUT_JSONRPC_URL to use for the TCK and the server agent
17+
SUT_JSONRPC_URL: http://localhost:41241
18+
# Slow system on CI
19+
TCK_STREAMING_TIMEOUT: 5.0
20+
21+
# Only run the latest job
22+
concurrency:
23+
group: '${{ github.workflow }} @ ${{ github.head_ref || github.ref }}'
24+
cancel-in-progress: true
25+
26+
jobs:
27+
tck-test:
28+
runs-on: ubuntu-latest
29+
steps:
30+
- name: Checkout a2a-js
31+
uses: actions/checkout@v4
32+
- name: Set up Node
33+
uses: actions/setup-node@v4
34+
with:
35+
node-version: 18
36+
registry-url: 'https://registry.npmjs.org'
37+
- run: |
38+
npm ci
39+
npm run build
40+
- name: Checkout a2a-tck
41+
uses: actions/checkout@v4
42+
with:
43+
repository: a2aproject/a2a-tck
44+
path: tck/a2a-tck
45+
ref: ${{ env.TCK_VERSION }}
46+
- name: Set up Python
47+
uses: actions/setup-python@v5
48+
with:
49+
python-version-file: "tck/a2a-tck/pyproject.toml"
50+
- name: Install uv and Python dependencies
51+
run: |
52+
pip install uv
53+
uv pip install -e .
54+
working-directory: tck/a2a-tck
55+
- name: Start SUT
56+
run: |
57+
cd tck
58+
npm run tck:sut-agent &
59+
- name: Wait for SUT to start
60+
run: |
61+
URL="${{ env.SUT_JSONRPC_URL }}/.well-known/agent-card.json"
62+
EXPECTED_STATUS=200
63+
TIMEOUT=120
64+
RETRY_INTERVAL=2
65+
START_TIME=$(date +%s)
66+
67+
while true; do
68+
# Calculate elapsed time
69+
CURRENT_TIME=$(date +%s)
70+
ELAPSED_TIME=$((CURRENT_TIME - START_TIME))
71+
72+
# Check for timeout
73+
if [ "$ELAPSED_TIME" -ge "$TIMEOUT" ]; then
74+
echo "❌ Timeout: Server did not respond with status $EXPECTED_STATUS within $TIMEOUT seconds."
75+
exit 1
76+
fi
77+
78+
# Get HTTP status code. || true is to reporting a failure to connect as an error
79+
HTTP_STATUS=$(curl --output /dev/null --silent --write-out "%{http_code}" "$URL") || true
80+
echo "STATUS: ${HTTP_STATUS}"
81+
82+
# Check if we got the correct status code
83+
if [ "$HTTP_STATUS" -eq "$EXPECTED_STATUS" ]; then
84+
echo "✅ Server is up! Received status $HTTP_STATUS after $ELAPSED_TIME seconds."
85+
break;
86+
fi
87+
88+
# Wait before retrying
89+
echo "⏳ Server not ready (status: $HTTP_STATUS). Retrying in $RETRY_INTERVAL seconds..."
90+
sleep "$RETRY_INTERVAL"
91+
done
92+
93+
- name: Run TCK
94+
id: run-tck
95+
timeout-minutes: 5
96+
run: |
97+
./run_tck.py --sut-url ${{ env.SUT_JSONRPC_URL }} --category mandatory
98+
working-directory: tck/a2a-tck
99+
- name: Stop SUT
100+
if: always()
101+
run: |
102+
# Find and kill the SUT process
103+
pkill -f "npm run tck:sut-agent" || true
104+
sleep 2

tck/agent/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# SUT Agent
2+
3+
This agent is a sample to emulate the task flow in a streaming scenario, and it will be the SUT tested against the tck tests in the CI. To run:
4+
5+
```bash
6+
npm run tck:sut-agent
7+
```
8+
9+
The agent will start on `http://localhost:41241`.

tck/agent/index.ts

Lines changed: 221 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import express from "express";
2+
import { v4 as uuidv4 } from 'uuid'; // For generating unique IDs
3+
4+
import {
5+
AgentCard,
6+
Task,
7+
TaskStatusUpdateEvent,
8+
Message
9+
} from "../../src/index.js";
10+
import {
11+
InMemoryTaskStore,
12+
TaskStore,
13+
AgentExecutor,
14+
RequestContext,
15+
ExecutionEventBus,
16+
DefaultRequestHandler
17+
} from "../../src/server/index.js";
18+
import { A2AExpressApp } from "../../src/server/express/index.js";
19+
20+
/**
21+
* SUTAgentExecutor implements the agent's core logic.
22+
*/
23+
class SUTAgentExecutor implements AgentExecutor {
24+
private runningTask: Set<string> = new Set();
25+
private lastContextId?: string;
26+
27+
public cancelTask = async (
28+
taskId: string,
29+
eventBus: ExecutionEventBus,
30+
): Promise<void> => {
31+
this.runningTask.delete(taskId);
32+
const cancelledUpdate: TaskStatusUpdateEvent = {
33+
kind: 'status-update',
34+
taskId: taskId,
35+
contextId: this.lastContextId,
36+
status: {
37+
state: 'canceled',
38+
timestamp: new Date().toISOString(),
39+
},
40+
final: true, // Cancellation is a final state
41+
};
42+
eventBus.publish(cancelledUpdate);
43+
};
44+
45+
async execute(
46+
requestContext: RequestContext,
47+
eventBus: ExecutionEventBus
48+
): Promise<void> {
49+
const userMessage = requestContext.userMessage;
50+
const existingTask = requestContext.task;
51+
52+
// Determine IDs for the task and context
53+
const taskId = requestContext.taskId;
54+
const contextId = requestContext.contextId;
55+
56+
this.lastContextId = contextId;
57+
this.runningTask.add(taskId);
58+
59+
console.log(
60+
`[SUTAgentExecutor] Processing message ${userMessage.messageId} for task ${taskId} (context: ${contextId})`
61+
);
62+
63+
// 1. Publish initial Task event if it's a new task
64+
if (!existingTask) {
65+
const initialTask: Task = {
66+
kind: 'task',
67+
id: taskId,
68+
contextId: contextId,
69+
status: {
70+
state: 'submitted',
71+
timestamp: new Date().toISOString(),
72+
},
73+
history: [userMessage], // Start history with the current user message
74+
metadata: userMessage.metadata, // Carry over metadata from message if any
75+
};
76+
eventBus.publish(initialTask);
77+
}
78+
79+
// 2. Publish "working" status update
80+
const workingStatusUpdate: TaskStatusUpdateEvent = {
81+
kind: 'status-update',
82+
taskId: taskId,
83+
contextId: contextId,
84+
status: {
85+
state: 'working',
86+
message: {
87+
kind: 'message',
88+
role: 'agent',
89+
messageId: uuidv4(),
90+
parts: [{ kind: 'text', text: 'Processing your question' }],
91+
taskId: taskId,
92+
contextId: contextId,
93+
},
94+
timestamp: new Date().toISOString(),
95+
},
96+
final: false,
97+
};
98+
eventBus.publish(workingStatusUpdate);
99+
100+
// 3. Publish final task status update
101+
const agentReplyText = this.parseInputMessage(userMessage);
102+
await new Promise(resolve => setTimeout(resolve, 3000)); // Simulate processing delay
103+
if (!this.runningTask.has(taskId)) {
104+
console.log(
105+
`[SUTAgentExecutor] Task ${taskId} was cancelled before processing could complete.`
106+
);
107+
return;
108+
}
109+
console.info(`[SUTAgentExecutor] Prompt response: ${agentReplyText}`);
110+
111+
const agentMessage: Message = {
112+
kind: 'message',
113+
role: 'agent',
114+
messageId: uuidv4(),
115+
parts: [{ kind: 'text', text: agentReplyText }],
116+
taskId: taskId,
117+
contextId: contextId,
118+
};
119+
120+
const finalUpdate: TaskStatusUpdateEvent = {
121+
kind: 'status-update',
122+
taskId: taskId,
123+
contextId: contextId,
124+
status: {
125+
state: 'input-required',
126+
message: agentMessage,
127+
timestamp: new Date().toISOString(),
128+
},
129+
final: true,
130+
};
131+
eventBus.publish(finalUpdate);
132+
}
133+
134+
parseInputMessage(message: Message): string {
135+
/** Process the user query and return a response. */
136+
const textPart = message.parts.find(part => part.kind === 'text');
137+
const query = textPart ? textPart.text.trim() : '';
138+
139+
if (!query) {
140+
return "Hello! Please provide a message for me to respond to.";
141+
}
142+
143+
// Simple responses based on input
144+
const queryLower = query.toLowerCase();
145+
if (queryLower.includes("hello") || queryLower.includes("hi")) {
146+
return "Hello World! How are you?";
147+
} else if (queryLower.includes("how are you")) {
148+
return "I'm doing great! Thanks for asking. How can I help you today?";
149+
} else {
150+
return `Hello World! You said: '${query}'. Please, send me a new message.`;
151+
}
152+
}
153+
}
154+
155+
// --- Server Setup ---
156+
157+
const SUTAgentCard: AgentCard = {
158+
name: 'SUT Agent',
159+
description: 'A sample agent to be used as SUT against tck tests.',
160+
// Adjust the base URL and port as needed. /a2a is the default base in A2AExpressApp
161+
url: 'http://localhost:41241/',
162+
provider: {
163+
organization: 'A2A Samples',
164+
url: 'https://example.com/a2a-samples' // Added provider URL
165+
},
166+
version: '1.0.0', // Incremented version
167+
protocolVersion: '0.3.0',
168+
capabilities: {
169+
streaming: true, // The new framework supports streaming
170+
pushNotifications: false, // Assuming not implemented for this agent yet
171+
stateTransitionHistory: true, // Agent uses history
172+
},
173+
defaultInputModes: ['text'],
174+
defaultOutputModes: ['text', 'task-status'], // task-status is a common output mode
175+
skills: [
176+
{
177+
id: 'sut_agent',
178+
name: 'SUT Agent',
179+
description: 'Simulate the general flow of a streaming agent.',
180+
tags: ['sut'],
181+
examples: ["hi", "hello world", "how are you", "goodbye"],
182+
inputModes: ['text'], // Explicitly defining for skill
183+
outputModes: ['text', 'task-status'] // Explicitly defining for skill
184+
},
185+
],
186+
supportsAuthenticatedExtendedCard: false,
187+
preferredTransport: 'JSONRPC',
188+
additionalInterfaces: [{url: 'http://localhost:41241', transport: 'JSONRPC'}],
189+
};
190+
191+
async function main() {
192+
// 1. Create TaskStore
193+
const taskStore: TaskStore = new InMemoryTaskStore();
194+
195+
// 2. Create AgentExecutor
196+
const agentExecutor: AgentExecutor = new SUTAgentExecutor();
197+
198+
// 3. Create DefaultRequestHandler
199+
const requestHandler = new DefaultRequestHandler(
200+
SUTAgentCard,
201+
taskStore,
202+
agentExecutor
203+
);
204+
205+
// 4. Create and setup A2AExpressApp
206+
const appBuilder = new A2AExpressApp(requestHandler);
207+
const expressApp = appBuilder.setupRoutes(express());
208+
209+
// 5. Start the server
210+
const PORT = process.env.PORT || 41241;
211+
expressApp.listen(PORT, (err) => {
212+
if (err) {
213+
throw err;
214+
}
215+
console.log(`[SUTAgent] Server using new framework started on http://localhost:${PORT}`);
216+
console.log(`[SUTAgent] Agent Card: http://localhost:${PORT}/.well-known/agent-card.json`);
217+
console.log('[SUTAgent] Press Ctrl+C to stop the server');
218+
});
219+
}
220+
221+
main().catch(console.error);

tck/package.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"name": "tck",
3+
"version": "1.0.0",
4+
"description": "SUT agent for tck tests CI",
5+
"scripts": {
6+
"tck:sut-agent": "tsx agent/index.ts"
7+
8+
}
9+
}

0 commit comments

Comments
 (0)