Skip to content

Commit eeb8ac4

Browse files
authored
feat: add ensureAgent method for idempotent agent creation (#14)
1 parent 376003d commit eeb8ac4

File tree

5 files changed

+128
-2
lines changed

5 files changed

+128
-2
lines changed

src/agents/index.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@ import {
33
OneCLIRequestError,
44
toOneCLIError,
55
} from "../errors.js";
6-
import type { CreateAgentInput, CreateAgentResponse } from "./types.js";
6+
import type {
7+
CreateAgentInput,
8+
CreateAgentResponse,
9+
EnsureAgentResponse,
10+
} from "./types.js";
711

812
export class AgentsClient {
913
private baseUrl: string;
@@ -57,4 +61,26 @@ export class AgentsClient {
5761
throw toOneCLIError(error);
5862
}
5963
};
64+
65+
/**
66+
* Ensure an agent exists. Creates it if missing, returns normally if it already exists.
67+
* Unlike `createAgent`, this method treats a 409 conflict as success.
68+
*/
69+
ensureAgent = async (
70+
input: CreateAgentInput,
71+
): Promise<EnsureAgentResponse> => {
72+
try {
73+
await this.createAgent(input);
74+
return { name: input.name, identifier: input.identifier, created: true };
75+
} catch (error) {
76+
if (error instanceof OneCLIRequestError && error.statusCode === 409) {
77+
return {
78+
name: input.name,
79+
identifier: input.identifier,
80+
created: false,
81+
};
82+
}
83+
throw error;
84+
}
85+
};
6086
}

src/agents/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,10 @@ export interface CreateAgentResponse {
1212
identifier: string;
1313
createdAt: string;
1414
}
15+
16+
export interface EnsureAgentResponse {
17+
name: string;
18+
identifier: string;
19+
/** Whether the agent was newly created. `false` if it already existed. */
20+
created: boolean;
21+
}

src/client.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
import type {
99
CreateAgentInput,
1010
CreateAgentResponse,
11+
EnsureAgentResponse,
1112
} from "./agents/types.js";
1213

1314
const DEFAULT_URL = "https://app.onecli.sh";
@@ -50,4 +51,11 @@ export class OneCLI {
5051
createAgent = (input: CreateAgentInput): Promise<CreateAgentResponse> => {
5152
return this.agentsClient.createAgent(input);
5253
};
54+
55+
/**
56+
* Ensure an agent exists. Creates it if missing, returns normally if it already exists.
57+
*/
58+
ensureAgent = (input: CreateAgentInput): Promise<EnsureAgentResponse> => {
59+
return this.agentsClient.ensureAgent(input);
60+
};
5361
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ export type {
1111
export type {
1212
CreateAgentInput,
1313
CreateAgentResponse,
14+
EnsureAgentResponse,
1415
} from "./agents/types.js";

test/agents/client.test.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,7 +162,7 @@ describe("AgentsClient", () => {
162162
).rejects.toThrow("fetch failed");
163163
});
164164

165-
it("re-throws OneCLIRequestError without wrapping", async () => {
165+
it("re-throws OneCLIRequestError on 500 without wrapping", async () => {
166166
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
167167
new Response("", { status: 500, statusText: "Internal Server Error" }),
168168
);
@@ -180,4 +180,88 @@ describe("AgentsClient", () => {
180180
expect((err as OneCLIRequestError).name).toBe("OneCLIRequestError");
181181
});
182182
});
183+
184+
describe("ensureAgent", () => {
185+
it("returns created: true when agent is newly created", async () => {
186+
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
187+
new Response(JSON.stringify(MOCK_AGENT), { status: 201 }),
188+
);
189+
190+
const client = new AgentsClient(
191+
"http://localhost:3000",
192+
"oc_test",
193+
5000,
194+
);
195+
const result = await client.ensureAgent({
196+
name: "My Agent",
197+
identifier: "my-agent",
198+
});
199+
200+
expect(result).toEqual({
201+
name: "My Agent",
202+
identifier: "my-agent",
203+
created: true,
204+
});
205+
});
206+
207+
it("returns created: false when agent already exists (409)", async () => {
208+
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
209+
new Response(
210+
JSON.stringify({ error: "identifier already exists" }),
211+
{ status: 409, statusText: "Conflict" },
212+
),
213+
);
214+
215+
const client = new AgentsClient(
216+
"http://localhost:3000",
217+
"oc_test",
218+
5000,
219+
);
220+
const result = await client.ensureAgent({
221+
name: "My Agent",
222+
identifier: "my-agent",
223+
});
224+
225+
expect(result).toEqual({
226+
name: "My Agent",
227+
identifier: "my-agent",
228+
created: false,
229+
});
230+
});
231+
232+
it("throws on non-409 errors", async () => {
233+
fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
234+
new Response(JSON.stringify({ error: "Unauthorized" }), {
235+
status: 401,
236+
statusText: "Unauthorized",
237+
}),
238+
);
239+
240+
const client = new AgentsClient(
241+
"http://localhost:3000",
242+
"oc_bad",
243+
5000,
244+
);
245+
246+
await expect(
247+
client.ensureAgent({ name: "Test", identifier: "test" }),
248+
).rejects.toThrow(OneCLIRequestError);
249+
});
250+
251+
it("throws on network errors", async () => {
252+
fetchSpy = vi
253+
.spyOn(globalThis, "fetch")
254+
.mockRejectedValue(new TypeError("fetch failed"));
255+
256+
const client = new AgentsClient(
257+
"http://localhost:3000",
258+
"oc_test",
259+
5000,
260+
);
261+
262+
await expect(
263+
client.ensureAgent({ name: "Test", identifier: "test" }),
264+
).rejects.toThrow(OneCLIError);
265+
});
266+
});
183267
});

0 commit comments

Comments
 (0)