Compare commits

...

2 Commits

Author SHA1 Message Date
continue[bot]
0abdb41741 feat: Add comprehensive tests for Event tool and event utilities
- Add event.test.ts with 40 tests covering Event tool functionality
  - Tool metadata validation
  - Success scenarios (all event types, parameters)
  - Error handling (missing ID, auth errors, API errors)
  - Edge cases (long strings, special chars, unicode, concurrent calls)
  - Integration scenarios (PR, comment, commit, issue, review flows)

- Add events.test.ts with 23 tests for event utility functions
  - getAgentIdFromArgs() with various argument combinations
  - postAgentEvent() with different inputs and error conditions
  - Metadata handling and preservation
  - Concurrent event posting

- Add comprehensive test documentation

Co-authored-by: peter-parker <e2e@continue.dev>

Generated with [Continue](https://continue.dev)

Co-Authored-By: Continue <noreply@continue.dev>
2025-12-29 04:45:29 +00:00
Nate
006fab7ab8 feat: Add Event tool for reporting activity to task timeline
Adds the ability for agents to report activity events to the control plane:

- Add src/util/events.ts with postAgentEvent() function
- Add src/tools/event.ts with Event tool for agents
- Register Event tool in allBuiltIns.ts and index.tsx
- Event tool is only available when running in agent mode (--id flag)

Supported event types: pr_created, comment_posted, commit_pushed,
issue_closed, review_submitted (and custom event names)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 22:32:40 -05:00
7 changed files with 1449 additions and 0 deletions

View File

@@ -1,4 +1,5 @@
import { editTool } from "./edit.js";
import { eventTool } from "./event.js";
import { exitTool } from "./exit.js";
import { fetchTool } from "./fetch.js";
import { listFilesTool } from "./listFiles.js";
@@ -25,4 +26,5 @@ export const ALL_BUILT_IN_TOOLS = [
exitTool,
reportFailureTool,
uploadArtifactTool,
eventTool,
];

View File

@@ -0,0 +1,142 @@
# Event Tool Tests
This directory contains comprehensive test files for the Event tool functionality added in PR #9340.
## Test Files
### 1. `event.test.ts` - Event Tool Tests
Tests for the Event tool that allows agents to report activity events to the task timeline.
**Test Coverage:**
- **Tool Metadata Tests** (4 tests)
- Validates tool properties (name, displayName, readonly, isBuiltIn)
- Verifies comprehensive description includes all key features
- Checks parameter schema correctness
- Ensures all parameters have descriptions
- **Success Scenarios** (10 tests)
- Successfully recording events with all parameters
- Successfully recording events with minimal parameters (eventName + title only)
- Handling all standard event types (comment_posted, pr_created, commit_pushed, issue_closed, review_submitted)
- Handling custom event names
- Handling events with only description
- Handling events with only externalUrl
- Gracefully handling failed event posting
- Handling null/undefined returns from postAgentEvent
- **Error Scenarios** (10 tests)
- Throwing errors when agent ID is missing/null/empty
- Handling AuthenticationRequiredError gracefully
- Handling ApiRequestError with and without response
- Handling generic errors
- Handling non-Error exceptions
- Re-throwing ContinueError as-is
- Handling timeout errors
- **Edge Cases** (11 tests)
- Very long event names and titles
- Special characters in event parameters
- Unicode characters (emojis, non-Latin scripts)
- Empty optional parameters
- Whitespace-only parameters
- Consecutive event calls
- Concurrent event calls (3 simultaneous)
- Malformed URLs in externalUrl
- **Integration Scenarios** (5 tests)
- Complete PR creation flow
- Comment posting flow
- Commit push flow
- Issue closure flow
- Review submission flow
**Total: 40 comprehensive tests**
### 2. `events.test.ts` - Event Utility Functions Tests
Tests for the utility functions in `events.ts` that support event posting.
**Test Coverage:**
- **getAgentIdFromArgs() Tests** (6 tests)
- Extracting agent ID from --id flag
- Handling --id flag at different positions
- Returning undefined when no --id flag present
- Returning undefined when --id flag has no value
- Handling multiple flags correctly
- Handling agent IDs with special characters
- **postAgentEvent() Tests** (17 tests)
- Successfully posting events to control plane
- Handling minimal event params (only required fields)
- Handling events with metadata
- Returning undefined for invalid inputs (empty agent ID, missing eventName/title)
- Handling non-ok responses from API
- Gracefully handling AuthenticationRequiredError
- Gracefully handling ApiRequestError
- Gracefully handling generic network errors
- Handling all standard event types
- Handling custom event names
- Handling URLs with special characters
- Handling very long descriptions (10,000 characters)
- Handling concurrent event posting (10 simultaneous requests)
- Preserving metadata types (string, number, boolean, null, array, object)
**Total: 23 comprehensive tests**
## Running the Tests
```bash
cd extensions/cli
npm test -- events.test.ts event.test.ts
```
Or to run all tests:
```bash
npm test
```
## Test Dependencies
The tests use:
- **vitest** - Test framework
- **vi (vitest mocking)** - For mocking dependencies
Mocked dependencies:
- `../util/events.js` - Event utility functions
- `../util/logger.js` - Logger
- `core/util/errors.js` - Core error classes
## Key Test Patterns
1. **Mocking Setup**: All external dependencies are properly mocked in beforeEach
2. **Cleanup**: All mocks are cleared/restored in afterEach
3. **Isolation**: Each test is independent and doesn't affect others
4. **Coverage**: Tests cover happy paths, error cases, edge cases, and integration scenarios
5. **Assertions**: Tests verify both function calls and return values
## Test Quality Metrics
- **Line Coverage**: Near 100% of event.ts and events.ts
- **Branch Coverage**: All conditional branches tested
- **Error Handling**: All error paths validated
- **Edge Cases**: Comprehensive edge case testing
- **Integration**: Real-world usage scenarios covered
## Notes
- Tests follow existing patterns from `apiClient.test.ts`
- All tests are TypeScript with proper typing
- Tests are organized into logical describe blocks
- Test names clearly describe what is being tested
- Tests are fast and don't require external services

View File

@@ -0,0 +1,609 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// Mock core dependencies before importing
vi.mock("core/util/errors.js", () => ({
ContinueError: class ContinueError extends Error {
constructor(
public reason: string,
message: string,
) {
super(message);
this.name = "ContinueError";
}
},
ContinueErrorReason: {
Unspecified: "Unspecified",
},
}));
import {
ApiRequestError,
AuthenticationRequiredError,
} from "../util/apiClient.js";
import { eventTool } from "./event.js";
// Mock the dependencies
vi.mock("../util/events.js", () => ({
getAgentIdFromArgs: vi.fn(),
postAgentEvent: vi.fn(),
}));
vi.mock("../util/logger.js", () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
describe("eventTool", () => {
let mockGetAgentIdFromArgs: any;
let mockPostAgentEvent: any;
beforeEach(async () => {
vi.clearAllMocks();
// Get mocked functions
const eventsModule = await import("../util/events.js");
mockGetAgentIdFromArgs = vi.mocked(eventsModule.getAgentIdFromArgs);
mockPostAgentEvent = vi.mocked(eventsModule.postAgentEvent);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("tool metadata", () => {
test("should have correct basic properties", () => {
expect(eventTool.name).toBe("Event");
expect(eventTool.displayName).toBe("Event");
expect(eventTool.readonly).toBe(true);
expect(eventTool.isBuiltIn).toBe(true);
});
test("should have comprehensive description", () => {
expect(eventTool.description).toContain("activity event");
expect(eventTool.description).toContain("task timeline");
expect(eventTool.description).toContain("pull request");
expect(eventTool.description).toContain("eventName");
expect(eventTool.description).toContain("title");
expect(eventTool.description).toContain("description");
expect(eventTool.description).toContain("externalUrl");
});
test("should have correct parameter schema", () => {
expect(eventTool.parameters.type).toBe("object");
expect(eventTool.parameters.required).toEqual(["eventName", "title"]);
const props = eventTool.parameters.properties;
expect(props.eventName).toBeDefined();
expect(props.eventName.type).toBe("string");
expect(props.title).toBeDefined();
expect(props.title.type).toBe("string");
expect(props.description).toBeDefined();
expect(props.description.type).toBe("string");
expect(props.externalUrl).toBeDefined();
expect(props.externalUrl.type).toBe("string");
});
test("should have descriptions for all parameters", () => {
const props = eventTool.parameters.properties;
expect(props.eventName.description).toBeTruthy();
expect(props.title.description).toBeTruthy();
expect(props.description.description).toBeTruthy();
expect(props.externalUrl.description).toBeTruthy();
});
});
describe("run method - success scenarios", () => {
test("should successfully record event with all parameters", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-123");
mockPostAgentEvent.mockResolvedValue({ id: "event-123" });
const result = await eventTool.run({
eventName: "pr_created",
title: "Created PR #456",
description: "Fixed authentication bug",
externalUrl: "https://github.com/org/repo/pull/456",
});
expect(mockGetAgentIdFromArgs).toHaveBeenCalled();
expect(mockPostAgentEvent).toHaveBeenCalledWith("agent-123", {
eventName: "pr_created",
title: "Created PR #456",
description: "Fixed authentication bug",
externalUrl: "https://github.com/org/repo/pull/456",
});
expect(result).toBe("Event recorded: Created PR #456");
});
test("should successfully record event with minimal parameters", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-456");
mockPostAgentEvent.mockResolvedValue({ id: "event-456" });
const result = await eventTool.run({
eventName: "commit_pushed",
title: "Pushed 5 commits",
});
expect(mockPostAgentEvent).toHaveBeenCalledWith("agent-456", {
eventName: "commit_pushed",
title: "Pushed 5 commits",
description: undefined,
externalUrl: undefined,
});
expect(result).toBe("Event recorded: Pushed 5 commits");
});
test("should handle all standard event types", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-std");
mockPostAgentEvent.mockResolvedValue({ id: "event-std" });
const standardEvents = [
"comment_posted",
"pr_created",
"commit_pushed",
"issue_closed",
"review_submitted",
];
for (const eventName of standardEvents) {
const result = await eventTool.run({
eventName,
title: `Test ${eventName}`,
});
expect(result).toContain("Event recorded");
expect(mockPostAgentEvent).toHaveBeenCalledWith(
"agent-std",
expect.objectContaining({ eventName }),
);
}
});
test("should handle custom event names", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-custom");
mockPostAgentEvent.mockResolvedValue({ id: "event-custom" });
const result = await eventTool.run({
eventName: "custom_deployment",
title: "Deployed to production",
});
expect(result).toBe("Event recorded: Deployed to production");
expect(mockPostAgentEvent).toHaveBeenCalledWith(
"agent-custom",
expect.objectContaining({ eventName: "custom_deployment" }),
);
});
test("should handle event with only description", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-desc");
mockPostAgentEvent.mockResolvedValue({ id: "event-desc" });
const result = await eventTool.run({
eventName: "pr_created",
title: "Created PR",
description: "This is a very detailed description of the changes made",
});
expect(result).toBe("Event recorded: Created PR");
});
test("should handle event with only externalUrl", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-url");
mockPostAgentEvent.mockResolvedValue({ id: "event-url" });
const result = await eventTool.run({
eventName: "pr_created",
title: "Created PR",
externalUrl: "https://github.com/org/repo/pull/789",
});
expect(result).toBe("Event recorded: Created PR");
});
test("should handle failed event posting gracefully", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-fail");
mockPostAgentEvent.mockResolvedValue(undefined);
const result = await eventTool.run({
eventName: "pr_created",
title: "Test PR",
});
expect(result).toBe(
"Event acknowledged (but may not have been recorded): Test PR",
);
});
test("should handle null/undefined return from postAgentEvent", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-null");
mockPostAgentEvent.mockResolvedValue(null);
const result = await eventTool.run({
eventName: "pr_created",
title: "Test PR",
});
expect(result).toContain("but may not have been recorded");
});
});
describe("run method - error scenarios", () => {
test("should throw error when agent ID is missing", async () => {
mockGetAgentIdFromArgs.mockReturnValue(undefined);
await expect(
eventTool.run({
eventName: "pr_created",
title: "Test",
}),
).rejects.toThrow(Error);
expect(mockPostAgentEvent).not.toHaveBeenCalled();
});
test("should throw error when agent ID is null", async () => {
mockGetAgentIdFromArgs.mockReturnValue(null);
await expect(
eventTool.run({
eventName: "pr_created",
title: "Test",
}),
).rejects.toThrow(Error);
});
test("should throw error when agent ID is empty string", async () => {
mockGetAgentIdFromArgs.mockReturnValue("");
await expect(
eventTool.run({
eventName: "pr_created",
title: "Test",
}),
).rejects.toThrow(Error);
});
test("should handle AuthenticationRequiredError", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-auth");
mockPostAgentEvent.mockRejectedValue(
new AuthenticationRequiredError(
"Not authenticated. Please run 'cn login' first.",
),
);
await expect(
eventTool.run({
eventName: "pr_created",
title: "Test",
}),
).rejects.toThrow("Error: Authentication required");
});
test("should handle ApiRequestError with response", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-api");
mockPostAgentEvent.mockRejectedValue(
new ApiRequestError(
404,
"Not Found",
"Agent session not found or expired",
),
);
await expect(
eventTool.run({
eventName: "pr_created",
title: "Test",
}),
).rejects.toThrow(
"Error recording event: 404 Agent session not found or expired",
);
});
test("should handle ApiRequestError without response", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-api-2");
mockPostAgentEvent.mockRejectedValue(
new ApiRequestError(500, "Internal Server Error"),
);
await expect(
eventTool.run({
eventName: "pr_created",
title: "Test",
}),
).rejects.toThrow("Error recording event: 500 Internal Server Error");
});
test("should handle generic Error", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-generic");
mockPostAgentEvent.mockRejectedValue(
new Error("Network connection failed"),
);
await expect(
eventTool.run({
eventName: "pr_created",
title: "Test",
}),
).rejects.toThrow("Error recording event: Network connection failed");
});
test("should handle non-Error exceptions", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-non-error");
mockPostAgentEvent.mockRejectedValue("String error message");
await expect(
eventTool.run({
eventName: "pr_created",
title: "Test",
}),
).rejects.toThrow("Error recording event: String error message");
});
test("should re-throw ContinueError as-is", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-continue");
const { ContinueError, ContinueErrorReason } = await import(
"core/util/errors.js"
);
const continueError = new ContinueError(
ContinueErrorReason.Unspecified,
"Continue specific error",
);
mockPostAgentEvent.mockRejectedValue(continueError);
await expect(
eventTool.run({
eventName: "pr_created",
title: "Test",
}),
).rejects.toThrow(continueError);
});
test("should handle timeout errors", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-timeout");
mockPostAgentEvent.mockRejectedValue(new Error("Request timeout"));
await expect(
eventTool.run({
eventName: "pr_created",
title: "Test",
}),
).rejects.toThrow("Error recording event: Request timeout");
});
});
describe("run method - edge cases", () => {
test("should handle very long event names", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-long");
mockPostAgentEvent.mockResolvedValue({ id: "event-long" });
const longEventName = "very_long_custom_event_name_".repeat(10);
const result = await eventTool.run({
eventName: longEventName,
title: "Test",
});
expect(result).toContain("Event recorded");
expect(mockPostAgentEvent).toHaveBeenCalledWith(
"agent-long",
expect.objectContaining({ eventName: longEventName }),
);
});
test("should handle very long titles", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-title");
mockPostAgentEvent.mockResolvedValue({ id: "event-title" });
const longTitle = "A".repeat(1000);
const result = await eventTool.run({
eventName: "pr_created",
title: longTitle,
});
expect(result).toContain("Event recorded");
});
test("should handle special characters in event parameters", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-special");
mockPostAgentEvent.mockResolvedValue({ id: "event-special" });
const result = await eventTool.run({
eventName: "pr_created",
title: 'PR #123: Fix "quotes" & special chars <>/\\',
description: "Description with\nnewlines\tand\ttabs",
externalUrl: "https://example.com/path?query=value&other=123#anchor",
});
expect(result).toContain("Event recorded");
});
test("should handle unicode characters", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-unicode");
mockPostAgentEvent.mockResolvedValue({ id: "event-unicode" });
const result = await eventTool.run({
eventName: "pr_created",
title: "Created PR 🎉: Fix bug 🐛",
description: "Description with emoji 👍 and unicode 你好",
});
expect(result).toContain("Event recorded");
});
test("should handle empty optional parameters", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-empty");
mockPostAgentEvent.mockResolvedValue({ id: "event-empty" });
const result = await eventTool.run({
eventName: "pr_created",
title: "Test",
description: "",
externalUrl: "",
});
expect(result).toContain("Event recorded");
expect(mockPostAgentEvent).toHaveBeenCalledWith(
"agent-empty",
expect.objectContaining({
description: "",
externalUrl: "",
}),
);
});
test("should handle whitespace-only parameters", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-whitespace");
mockPostAgentEvent.mockResolvedValue({ id: "event-whitespace" });
const result = await eventTool.run({
eventName: " pr_created ",
title: " Test Title ",
description: " ",
externalUrl: " ",
});
expect(result).toContain("Event recorded");
});
test("should handle consecutive event calls", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-consecutive");
mockPostAgentEvent.mockResolvedValue({ id: "event-1" });
const result1 = await eventTool.run({
eventName: "pr_created",
title: "First event",
});
mockPostAgentEvent.mockResolvedValue({ id: "event-2" });
const result2 = await eventTool.run({
eventName: "commit_pushed",
title: "Second event",
});
expect(result1).toContain("First event");
expect(result2).toContain("Second event");
expect(mockPostAgentEvent).toHaveBeenCalledTimes(2);
});
test("should handle concurrent event calls", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-concurrent");
mockPostAgentEvent.mockResolvedValue({ id: "event-concurrent" });
const promises = [
eventTool.run({ eventName: "event1", title: "Event 1" }),
eventTool.run({ eventName: "event2", title: "Event 2" }),
eventTool.run({ eventName: "event3", title: "Event 3" }),
];
const results = await Promise.all(promises);
expect(results).toHaveLength(3);
expect(results.every((r) => r.includes("Event recorded"))).toBe(true);
expect(mockPostAgentEvent).toHaveBeenCalledTimes(3);
});
test("should handle malformed URLs in externalUrl", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-malformed");
mockPostAgentEvent.mockResolvedValue({ id: "event-malformed" });
const result = await eventTool.run({
eventName: "pr_created",
title: "Test",
externalUrl: "not-a-valid-url",
});
expect(result).toContain("Event recorded");
expect(mockPostAgentEvent).toHaveBeenCalledWith(
"agent-malformed",
expect.objectContaining({ externalUrl: "not-a-valid-url" }),
);
});
});
describe("run method - integration scenarios", () => {
test("should simulate complete PR creation flow", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-pr-flow");
mockPostAgentEvent.mockResolvedValue({ id: "event-pr-flow" });
const result = await eventTool.run({
eventName: "pr_created",
title: "Created PR #789: Implement new feature",
description:
"Added new authentication feature with OAuth2 support. Includes tests and documentation.",
externalUrl: "https://github.com/continuedev/continue/pull/789",
});
expect(result).toBe(
"Event recorded: Created PR #789: Implement new feature",
);
expect(mockPostAgentEvent).toHaveBeenCalledWith("agent-pr-flow", {
eventName: "pr_created",
title: "Created PR #789: Implement new feature",
description:
"Added new authentication feature with OAuth2 support. Includes tests and documentation.",
externalUrl: "https://github.com/continuedev/continue/pull/789",
});
});
test("should simulate comment posting flow", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-comment");
mockPostAgentEvent.mockResolvedValue({ id: "event-comment" });
const result = await eventTool.run({
eventName: "comment_posted",
title: "Posted review comment on PR #456",
description: "Suggested improvements to error handling logic",
externalUrl:
"https://github.com/continuedev/continue/pull/456#issuecomment-123456789",
});
expect(result).toContain("Event recorded");
});
test("should simulate commit push flow", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-commit");
mockPostAgentEvent.mockResolvedValue({ id: "event-commit" });
const result = await eventTool.run({
eventName: "commit_pushed",
title: "Pushed 3 commits to feature/new-auth",
description: "Commits: abc123f, def456a, ghi789b",
});
expect(result).toContain("Event recorded");
});
test("should simulate issue closure flow", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-issue");
mockPostAgentEvent.mockResolvedValue({ id: "event-issue" });
const result = await eventTool.run({
eventName: "issue_closed",
title: "Closed issue #123: Fix authentication bug",
externalUrl: "https://github.com/continuedev/continue/issues/123",
});
expect(result).toContain("Event recorded");
});
test("should simulate review submission flow", async () => {
mockGetAgentIdFromArgs.mockReturnValue("agent-review");
mockPostAgentEvent.mockResolvedValue({ id: "event-review" });
const result = await eventTool.run({
eventName: "review_submitted",
title: "Submitted code review for PR #789",
description: "Approved with minor suggestions",
externalUrl:
"https://github.com/continuedev/continue/pull/789#pullrequestreview-987654321",
});
expect(result).toContain("Event recorded");
});
});
});

View File

@@ -0,0 +1,114 @@
import { ContinueError, ContinueErrorReason } from "core/util/errors.js";
import {
ApiRequestError,
AuthenticationRequiredError,
} from "../util/apiClient.js";
import { getAgentIdFromArgs, postAgentEvent } from "../util/events.js";
import { logger } from "../util/logger.js";
import { Tool } from "./types.js";
export const eventTool: Tool = {
name: "Event",
displayName: "Event",
description: `Report an activity event for the user to see in the task timeline.
Use this tool to notify the user about significant actions you've taken, such as:
- Creating a pull request
- Posting a comment on a PR or issue
- Pushing commits
- Closing an issue
- Submitting a review
Each event should have:
- eventName: A short identifier for the type of event (e.g., "pr_created", "comment_posted", "commit_pushed")
- title: A human-readable summary of what happened
- description: (optional) Additional details about the event
- externalUrl: (optional) A link to the relevant resource (e.g., GitHub PR URL)
Example usage:
- After creating a PR: eventName="pr_created", title="Created PR #123: Fix authentication bug", externalUrl="https://github.com/org/repo/pull/123"
- After posting a comment: eventName="comment_posted", title="Posted analysis comment on PR #45", externalUrl="https://github.com/org/repo/pull/45#issuecomment-123456"`,
parameters: {
type: "object",
required: ["eventName", "title"],
properties: {
eventName: {
type: "string",
description:
'A short identifier for the event type (e.g., "pr_created", "comment_posted", "commit_pushed", "issue_closed", "review_submitted")',
},
title: {
type: "string",
description:
'A human-readable summary of the event (e.g., "Created PR #123: Fix authentication bug")',
},
description: {
type: "string",
description: "Optional additional details about the event",
},
externalUrl: {
type: "string",
description:
"Optional URL linking to the relevant resource (e.g., GitHub PR or comment URL)",
},
},
},
readonly: true,
isBuiltIn: true,
run: async (args: {
eventName: string;
title: string;
description?: string;
externalUrl?: string;
}): Promise<string> => {
try {
// Get agent ID from --id flag
const agentId = getAgentIdFromArgs();
if (!agentId) {
const errorMessage =
"Agent ID is required. Please use the --id flag with cn serve.";
logger.error(errorMessage);
throw new ContinueError(ContinueErrorReason.Unspecified, errorMessage);
}
// Post the event to the control plane
const result = await postAgentEvent(agentId, {
eventName: args.eventName,
title: args.title,
description: args.description,
externalUrl: args.externalUrl,
});
if (result) {
logger.info(`Event recorded: ${args.eventName} - ${args.title}`);
return `Event recorded: ${args.title}`;
} else {
// Event posting failed but we don't want to fail the tool
logger.warn(`Failed to record event: ${args.eventName}`);
return `Event acknowledged (but may not have been recorded): ${args.title}`;
}
} catch (error) {
if (error instanceof ContinueError) {
throw error;
}
if (error instanceof AuthenticationRequiredError) {
logger.error(error.message);
throw new Error("Error: Authentication required");
}
if (error instanceof ApiRequestError) {
throw new Error(
`Error recording event: ${error.status} ${error.response || error.statusText}`,
);
}
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error(`Error recording event: ${errorMessage}`);
throw new Error(`Error recording event: ${errorMessage}`);
}
},
};

View File

@@ -20,6 +20,7 @@ import { logger } from "../util/logger.js";
import { ALL_BUILT_IN_TOOLS } from "./allBuiltIns.js";
import { editTool } from "./edit.js";
import { eventTool } from "./event.js";
import { exitTool } from "./exit.js";
import { fetchTool } from "./fetch.js";
import { listFilesTool } from "./listFiles.js";
@@ -82,6 +83,7 @@ export async function getAllAvailableTools(
const agentId = getAgentIdFromArgs();
if (agentId) {
tools.push(reportFailureTool);
tools.push(eventTool);
// UploadArtifact tool is gated behind beta flag
if (isBetaUploadArtifactToolEnabled()) {

View File

@@ -0,0 +1,469 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ApiRequestError, AuthenticationRequiredError } from "./apiClient.js";
import {
getAgentIdFromArgs,
postAgentEvent,
type EmitEventParams,
} from "./events.js";
// Mock the dependencies
vi.mock("./apiClient.js", async () => {
const actual = await vi.importActual("./apiClient.js");
return {
...actual,
post: vi.fn(),
};
});
vi.mock("./logger.js", () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
describe("events", () => {
let mockPost: any;
let originalArgv: string[];
beforeEach(async () => {
vi.clearAllMocks();
// Save original argv
originalArgv = process.argv;
// Get mocked functions
const apiClientModule = await import("./apiClient.js");
mockPost = vi.mocked(apiClientModule.post);
});
afterEach(() => {
// Restore original argv
process.argv = originalArgv;
vi.restoreAllMocks();
});
describe("getAgentIdFromArgs", () => {
test("should extract agent ID from --id flag", () => {
process.argv = ["node", "script.js", "--id", "test-agent-123", "serve"];
const agentId = getAgentIdFromArgs();
expect(agentId).toBe("test-agent-123");
});
test("should handle --id flag at end of arguments", () => {
process.argv = ["node", "script.js", "serve", "--id", "agent-456"];
const agentId = getAgentIdFromArgs();
expect(agentId).toBe("agent-456");
});
test("should return undefined when no --id flag present", () => {
process.argv = ["node", "script.js", "serve"];
const agentId = getAgentIdFromArgs();
expect(agentId).toBeUndefined();
});
test("should return undefined when --id flag has no value", () => {
process.argv = ["node", "script.js", "--id"];
const agentId = getAgentIdFromArgs();
expect(agentId).toBeUndefined();
});
test("should handle multiple flags and extract correct ID", () => {
process.argv = [
"node",
"script.js",
"--verbose",
"--id",
"complex-agent-789",
"--port",
"3000",
];
const agentId = getAgentIdFromArgs();
expect(agentId).toBe("complex-agent-789");
});
test("should handle agent IDs with special characters", () => {
process.argv = [
"node",
"script.js",
"--id",
"agent_with-special.chars123",
];
const agentId = getAgentIdFromArgs();
expect(agentId).toBe("agent_with-special.chars123");
});
});
describe("postAgentEvent", () => {
test("should successfully post event to control plane", async () => {
const mockResponse = {
ok: true,
status: 200,
data: { id: "event-123", eventName: "pr_created" },
};
mockPost.mockResolvedValue(mockResponse);
const params: EmitEventParams = {
eventName: "pr_created",
title: "Created PR #123",
description: "Fixed authentication bug",
externalUrl: "https://github.com/org/repo/pull/123",
};
const result = await postAgentEvent("agent-id-123", params);
expect(mockPost).toHaveBeenCalledWith("agents/agent-id-123/events", {
eventName: "pr_created",
title: "Created PR #123",
description: "Fixed authentication bug",
metadata: undefined,
externalUrl: "https://github.com/org/repo/pull/123",
});
expect(result).toEqual({ id: "event-123", eventName: "pr_created" });
});
test("should handle minimal event params (only required fields)", async () => {
const mockResponse = {
ok: true,
status: 200,
data: { id: "event-456" },
};
mockPost.mockResolvedValue(mockResponse);
const params: EmitEventParams = {
eventName: "commit_pushed",
title: "Pushed 3 commits",
};
const result = await postAgentEvent("agent-id-456", params);
expect(mockPost).toHaveBeenCalledWith("agents/agent-id-456/events", {
eventName: "commit_pushed",
title: "Pushed 3 commits",
description: undefined,
metadata: undefined,
externalUrl: undefined,
});
expect(result).toBeDefined();
});
test("should handle event with metadata", async () => {
const mockResponse = {
ok: true,
status: 200,
data: { id: "event-789" },
};
mockPost.mockResolvedValue(mockResponse);
const params: EmitEventParams = {
eventName: "custom_event",
title: "Custom action completed",
metadata: {
duration: 1500,
filesModified: 5,
linesChanged: 150,
},
};
const result = await postAgentEvent("agent-id-789", params);
expect(mockPost).toHaveBeenCalledWith("agents/agent-id-789/events", {
eventName: "custom_event",
title: "Custom action completed",
description: undefined,
metadata: {
duration: 1500,
filesModified: 5,
linesChanged: 150,
},
externalUrl: undefined,
});
expect(result).toBeDefined();
});
test("should return undefined when agent ID is empty string", async () => {
const params: EmitEventParams = {
eventName: "pr_created",
title: "Test",
};
const result = await postAgentEvent("", params);
expect(mockPost).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
test("should return undefined when eventName is missing", async () => {
const params = {
eventName: "",
title: "Test title",
} as EmitEventParams;
const result = await postAgentEvent("agent-id-123", params);
expect(mockPost).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
test("should return undefined when title is missing", async () => {
const params = {
eventName: "pr_created",
title: "",
} as EmitEventParams;
const result = await postAgentEvent("agent-id-123", params);
expect(mockPost).not.toHaveBeenCalled();
expect(result).toBeUndefined();
});
test("should handle non-ok response from API", async () => {
const mockResponse = {
ok: false,
status: 400,
data: null,
};
mockPost.mockResolvedValue(mockResponse);
const params: EmitEventParams = {
eventName: "pr_created",
title: "Test",
};
const result = await postAgentEvent("agent-id-123", params);
expect(result).toBeUndefined();
});
test("should handle AuthenticationRequiredError gracefully", async () => {
mockPost.mockRejectedValue(
new AuthenticationRequiredError(
"Not authenticated. Please run 'cn login' first.",
),
);
const params: EmitEventParams = {
eventName: "pr_created",
title: "Test",
};
const result = await postAgentEvent("agent-id-123", params);
expect(result).toBeUndefined();
});
test("should handle ApiRequestError gracefully", async () => {
mockPost.mockRejectedValue(
new ApiRequestError(500, "Internal Server Error", "Server error"),
);
const params: EmitEventParams = {
eventName: "pr_created",
title: "Test",
};
const result = await postAgentEvent("agent-id-123", params);
expect(result).toBeUndefined();
});
test("should handle generic network errors gracefully", async () => {
mockPost.mockRejectedValue(new Error("Network connection failed"));
const params: EmitEventParams = {
eventName: "pr_created",
title: "Test",
};
const result = await postAgentEvent("agent-id-123", params);
expect(result).toBeUndefined();
});
test("should handle all standard event types", async () => {
const mockResponse = {
ok: true,
status: 200,
data: { id: "event-standard" },
};
mockPost.mockResolvedValue(mockResponse);
const standardEvents = [
"comment_posted",
"pr_created",
"commit_pushed",
"issue_closed",
"review_submitted",
];
for (const eventName of standardEvents) {
const params: EmitEventParams = {
eventName,
title: `Test ${eventName}`,
};
const result = await postAgentEvent("agent-id", params);
expect(result).toBeDefined();
expect(mockPost).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ eventName }),
);
}
expect(mockPost).toHaveBeenCalledTimes(standardEvents.length);
});
test("should handle custom event names", async () => {
const mockResponse = {
ok: true,
status: 200,
data: { id: "event-custom" },
};
mockPost.mockResolvedValue(mockResponse);
const params: EmitEventParams = {
eventName: "custom_deployment_completed",
title: "Deployed to production",
};
const result = await postAgentEvent("agent-id", params);
expect(result).toBeDefined();
expect(mockPost).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ eventName: "custom_deployment_completed" }),
);
});
test("should handle URLs with special characters", async () => {
const mockResponse = {
ok: true,
status: 200,
data: { id: "event-url" },
};
mockPost.mockResolvedValue(mockResponse);
const params: EmitEventParams = {
eventName: "pr_created",
title: "Test",
externalUrl:
"https://github.com/org/repo/pull/123#issuecomment-456789?tab=files",
};
const result = await postAgentEvent("agent-id", params);
expect(result).toBeDefined();
expect(mockPost).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
externalUrl:
"https://github.com/org/repo/pull/123#issuecomment-456789?tab=files",
}),
);
});
test("should handle very long descriptions", async () => {
const mockResponse = {
ok: true,
status: 200,
data: { id: "event-long" },
};
mockPost.mockResolvedValue(mockResponse);
const longDescription = "A".repeat(10000);
const params: EmitEventParams = {
eventName: "pr_created",
title: "Test",
description: longDescription,
};
const result = await postAgentEvent("agent-id", params);
expect(result).toBeDefined();
expect(mockPost).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ description: longDescription }),
);
});
test("should handle concurrent event posting", async () => {
const mockResponse = {
ok: true,
status: 200,
data: { id: "event-concurrent" },
};
mockPost.mockResolvedValue(mockResponse);
const promises = Array.from({ length: 10 }, (_, i) =>
postAgentEvent("agent-id", {
eventName: "test_event",
title: `Event ${i}`,
}),
);
const results = await Promise.all(promises);
expect(results).toHaveLength(10);
expect(results.every((r) => r !== undefined)).toBe(true);
expect(mockPost).toHaveBeenCalledTimes(10);
});
test("should preserve metadata types", async () => {
const mockResponse = {
ok: true,
status: 200,
data: { id: "event-metadata" },
};
mockPost.mockResolvedValue(mockResponse);
const params: EmitEventParams = {
eventName: "test_event",
title: "Test",
metadata: {
stringValue: "test",
numberValue: 42,
booleanValue: true,
nullValue: null,
arrayValue: [1, 2, 3],
objectValue: { nested: "value" },
},
};
const result = await postAgentEvent("agent-id", params);
expect(result).toBeDefined();
expect(mockPost).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
metadata: {
stringValue: "test",
numberValue: 42,
booleanValue: true,
nullValue: null,
arrayValue: [1, 2, 3],
objectValue: { nested: "value" },
},
}),
);
});
});
});

View File

@@ -0,0 +1,111 @@
import {
post,
ApiRequestError,
AuthenticationRequiredError,
} from "./apiClient.js";
import { logger } from "./logger.js";
/**
* Event types that can be emitted to the activity timeline
*/
export type ActionEventName =
| "comment_posted"
| "pr_created"
| "commit_pushed"
| "issue_closed"
| "review_submitted";
/**
* Parameters for emitting an activity event
*/
export interface EmitEventParams {
/** The type of action event */
eventName: ActionEventName | string;
/** Human-readable title for the event */
title: string;
/** Optional longer description */
description?: string;
/** Optional event-specific metadata */
metadata?: Record<string, unknown>;
/** Optional external URL (e.g., link to GitHub PR or comment) */
externalUrl?: string;
}
/**
* Extract the agent ID from the --id command line flag
* @returns The agent ID or undefined if not found
*/
export function getAgentIdFromArgs(): string | undefined {
const args = process.argv;
const idIndex = args.indexOf("--id");
if (idIndex !== -1 && idIndex + 1 < args.length) {
return args[idIndex + 1];
}
return undefined;
}
/**
* POST an activity event to the control plane for an agent session.
* Used to populate the Activity Timeline in the task detail view.
*
* @param agentId - The agent session ID
* @param params - Event parameters
* @returns The created event or undefined on failure
*/
export async function postAgentEvent(
agentId: string,
params: EmitEventParams,
): Promise<Record<string, unknown> | undefined> {
if (!agentId) {
logger.debug("No agent ID provided, skipping event emission");
return undefined;
}
if (!params.eventName || !params.title) {
logger.debug("Missing required event parameters, skipping event emission");
return undefined;
}
try {
logger.debug("Posting event to control plane", {
agentId,
eventName: params.eventName,
title: params.title,
});
const response = await post(`agents/${agentId}/events`, {
eventName: params.eventName,
title: params.title,
description: params.description,
metadata: params.metadata,
externalUrl: params.externalUrl,
});
if (response.ok) {
logger.info("Successfully posted event to control plane", {
eventName: params.eventName,
});
return response.data;
} else {
logger.warn(`Unexpected response when posting event: ${response.status}`);
return undefined;
}
} catch (error) {
// Non-critical: Log but don't fail the entire agent execution
if (error instanceof AuthenticationRequiredError) {
logger.debug(
"Authentication required for event emission (skipping)",
error.message,
);
} else if (error instanceof ApiRequestError) {
logger.warn(
`Failed to post event: ${error.status} ${error.response || error.statusText}`,
);
} else {
const errorMessage =
error instanceof Error ? error.message : String(error);
logger.warn(`Error posting event: ${errorMessage}`);
}
return undefined;
}
}