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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,20 @@ console.log(agent.createdAt); // ISO 8601 timestamp

**Returns** `{ id, name, identifier, createdAt }`

#### `onecli.listAgents(options?)`

List all agents in the project.

```typescript
const agents = await onecli.listAgents();

for (const agent of agents) {
console.log(agent.identifier, agent.isDefault);
}
```

**Returns** `Array<{ id, name, identifier, isDefault, createdAt }>`

#### `onecli.ensureAgent(input, options?)`

Ensure an agent exists. Creates it if missing, returns normally if it already exists.
Expand All @@ -228,6 +242,8 @@ const result = await onecli.ensureAgent({
console.log(result.created); // true if newly created, false if already existed
```

Idempotent even at the agent cap: if the project is at its plan's agent limit but the target identifier already exists, the call still resolves with `created: false` instead of throwing a quota error.

**Returns** `{ name, identifier, created }`

---
Expand Down
64 changes: 64 additions & 0 deletions src/agents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
toOneCLIError,
} from "../errors.js";
import type {
Agent,
CreateAgentInput,
CreateAgentResponse,
EnsureAgentResponse,
Expand Down Expand Up @@ -78,6 +79,55 @@ export class AgentsClient {
}
};

/**
* List all agents in the project.
*/
listAgents = async (options?: RequestOptions): Promise<Agent[]> => {
const url = `${this.baseUrl}/v1/agents`;

try {
const res = await fetch(url, {
method: "GET",
headers: this.buildHeaders(options),
signal: AbortSignal.timeout(this.timeout),
});

if (!res.ok) {
throw new OneCLIRequestError(
`OneCLI returned ${res.status} ${res.statusText}`,
{ url, statusCode: res.status },
);
}

return (await res.json()) as Agent[];
} catch (error) {
if (
error instanceof OneCLIError ||
error instanceof OneCLIRequestError
) {
throw error;
}
throw toOneCLIError(error);
}
};

/**
* Whether an agent with the given identifier already exists in the project.
* Swallows lookup failures and returns `false` so callers can fall back to
* surfacing their original error when existence can't be confirmed.
*/
private agentExists = async (
identifier: string,
options?: RequestOptions,
): Promise<boolean> => {
try {
const agents = await this.listAgents(options);
return agents.some((a) => a.identifier === identifier);
} catch {
return false;
}
};

/**
* Ensure an agent exists. Creates it if missing, returns normally if it already exists.
* Unlike `createAgent`, this method treats a 409 conflict as success.
Expand All @@ -97,6 +147,20 @@ export class AgentsClient {
created: false,
};
}
// At the agent cap the server may evaluate the quota before the
// identifier-uniqueness check and return 403 where it would otherwise
// return 409 for an existing identifier. Re-creating an existing agent is
// a no-op, so confirm existence and treat it as success; only surface the
// quota error when the agent genuinely doesn't exist. See issue #40.
if (error instanceof OneCLIRequestError && error.statusCode === 403) {
if (await this.agentExists(input.identifier, options)) {
return {
name: input.name,
identifier: input.identifier,
created: false,
};
}
}
throw error;
}
};
Expand Down
9 changes: 9 additions & 0 deletions src/agents/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ export interface CreateAgentResponse {
createdAt: string;
}

export interface Agent {
id: string;
name: string;
identifier: string;
/** Whether this is the project's default agent. */
isDefault: boolean;
createdAt: string;
}

export interface EnsureAgentResponse {
name: string;
identifier: string;
Expand Down
8 changes: 8 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
GetContainerConfigOptions,
} from "./container/types.js";
import type {
Agent,
CreateAgentInput,
CreateAgentResponse,
EnsureAgentResponse,
Expand Down Expand Up @@ -92,6 +93,13 @@ export class OneCLI {
return this.containerClient.applyContainerConfig(args, options);
};

/**
* List all agents in the project.
*/
listAgents = (options?: RequestOptions): Promise<Agent[]> => {
return this.agentsClient.listAgents(options);
};

/**
* Create a new agent.
*/
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type {
ApplyContainerConfigOptions,
} from "./container/types.js";
export type {
Agent,
CreateAgentInput,
CreateAgentResponse,
EnsureAgentResponse,
Expand Down
168 changes: 168 additions & 0 deletions test/agents/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ const MOCK_AGENT = {
createdAt: "2025-01-01T00:00:00.000Z",
};

const MOCK_LIST_AGENT = {
id: "clxyz123abc",
name: "My Agent",
identifier: "my-agent",
isDefault: false,
createdAt: "2025-01-01T00:00:00.000Z",
};

describe("AgentsClient", () => {
let fetchSpy: ReturnType<typeof vi.spyOn>;

Expand Down Expand Up @@ -181,6 +189,76 @@ describe("AgentsClient", () => {
});
});

describe("listAgents", () => {
it("sends GET with correct URL and auth header", async () => {
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify([MOCK_LIST_AGENT]), { status: 200 }),
);

const client = new AgentsClient(
"http://localhost:3000",
"oc_mykey",
5000,
);
await client.listAgents();

expect(fetchSpy).toHaveBeenCalledWith(
"http://localhost:3000/v1/agents",
expect.objectContaining({
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: "Bearer oc_mykey",
},
}),
);
});

it("returns parsed array on success", async () => {
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify([MOCK_LIST_AGENT]), { status: 200 }),
);

const client = new AgentsClient(
"http://localhost:3000",
"oc_test",
5000,
);
const agents = await client.listAgents();

expect(agents).toEqual([MOCK_LIST_AGENT]);
});

it("throws OneCLIRequestError on 401", async () => {
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
statusText: "Unauthorized",
}),
);

const client = new AgentsClient("http://localhost:3000", "oc_bad", 5000);

const err = await client.listAgents().catch((e: unknown) => e);
expect(err).toBeInstanceOf(OneCLIRequestError);
expect((err as OneCLIRequestError).statusCode).toBe(401);
});

it("wraps network errors into OneCLIError", async () => {
fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockRejectedValue(new TypeError("fetch failed"));

const client = new AgentsClient(
"http://localhost:3000",
"oc_test",
5000,
);

await expect(client.listAgents()).rejects.toThrow(OneCLIError);
});
});

describe("ensureAgent", () => {
it("returns created: true when agent is newly created", async () => {
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
Expand Down Expand Up @@ -229,6 +307,96 @@ describe("AgentsClient", () => {
});
});

it("returns created: false on 403 when the agent already exists", async () => {
// At the agent cap the server may return 403 (quota) instead of 409 for
// an existing identifier. ensureAgent confirms existence via GET /agents.
fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
new Response(JSON.stringify({ error: "agents limit reached" }), {
status: 403,
statusText: "Forbidden",
}),
)
.mockResolvedValueOnce(
new Response(JSON.stringify([MOCK_LIST_AGENT]), { status: 200 }),
);

const client = new AgentsClient(
"http://localhost:3000",
"oc_test",
5000,
);
const result = await client.ensureAgent({
name: "My Agent",
identifier: "my-agent",
});

expect(result).toEqual({
name: "My Agent",
identifier: "my-agent",
created: false,
});
expect(fetchSpy).toHaveBeenCalledTimes(2);
});

it("re-throws the 403 when the agent does not exist (genuine quota cap)", async () => {
fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
new Response(JSON.stringify({ error: "agents limit reached" }), {
status: 403,
statusText: "Forbidden",
}),
)
.mockResolvedValueOnce(
// Listing exists but does not include the requested identifier.
new Response(
JSON.stringify([{ ...MOCK_LIST_AGENT, identifier: "other" }]),
{ status: 200 },
),
);

const client = new AgentsClient(
"http://localhost:3000",
"oc_test",
5000,
);

const err = await client
.ensureAgent({ name: "My Agent", identifier: "my-agent" })
.catch((e: unknown) => e);
expect(err).toBeInstanceOf(OneCLIRequestError);
expect((err as OneCLIRequestError).statusCode).toBe(403);
});

it("re-throws the original 403 when the existence check itself fails", async () => {
fetchSpy = vi
.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(
new Response(JSON.stringify({ error: "agents limit reached" }), {
status: 403,
statusText: "Forbidden",
}),
)
.mockResolvedValueOnce(
new Response("", { status: 500, statusText: "Internal Server Error" }),
);

const client = new AgentsClient(
"http://localhost:3000",
"oc_test",
5000,
);

const err = await client
.ensureAgent({ name: "My Agent", identifier: "my-agent" })
.catch((e: unknown) => e);
// The original 403 surfaces, not the 500 from the existence check.
expect(err).toBeInstanceOf(OneCLIRequestError);
expect((err as OneCLIRequestError).statusCode).toBe(403);
});

it("throws on non-409 errors", async () => {
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ error: "Unauthorized" }), {
Expand Down
Loading