Skip to content

Commit 2d50cfd

Browse files
authored
feat: status tool for cn (#8007)
* feat: status tool * feat: status tool call endpoint * fix: type errors * fix: tests
1 parent 9bd0288 commit 2d50cfd

File tree

7 files changed

+623
-90
lines changed

7 files changed

+623
-90
lines changed

extensions/cli/src/commands/ls.ts

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { render } from "ink";
22
import React from "react";
33

4-
import { getAccessToken, loadAuthConfig } from "../auth/workos.js";
5-
import { env } from "../env.js";
64
import { listSessions, loadSessionById } from "../session.js";
75
import { SessionSelector } from "../ui/SessionSelector.js";
6+
import { ApiRequestError, post } from "../util/apiClient.js";
87
import { logger } from "../util/logger.js";
98

109
import { chat } from "./chat.js";
@@ -27,26 +26,19 @@ function setSessionId(sessionId: string): void {
2726
}
2827

2928
export async function getTunnelForAgent(agentId: string): Promise<string> {
30-
const authConfig = loadAuthConfig();
31-
const accessToken = getAccessToken(authConfig);
32-
33-
const resp = await fetch(
34-
new URL(`agents/${encodeURIComponent(agentId)}/tunnel`, env.apiBase),
35-
{
36-
method: "POST",
37-
headers: {
38-
"Content-Type": "application/json",
39-
Authorization: `Bearer ${accessToken}`,
40-
},
41-
},
42-
);
43-
if (!resp.ok) {
44-
throw new Error(
45-
`Failed to get tunnel for agent ${agentId}: ${await resp.text()}`,
29+
try {
30+
const response = await post<{ url: string }>(
31+
`agents/${encodeURIComponent(agentId)}/tunnel`,
4632
);
33+
return response.data.url;
34+
} catch (error) {
35+
if (error instanceof ApiRequestError) {
36+
throw new Error(
37+
`Failed to get tunnel for agent ${agentId}: ${error.response || error.statusText}`,
38+
);
39+
}
40+
throw error;
4741
}
48-
const data = await resp.json();
49-
return data.url;
5042
}
5143

5244
/**

extensions/cli/src/commands/remote.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ vi.mock("../env.js");
88
vi.mock("../telemetry/telemetryService.js");
99
vi.mock("../ui/index.js");
1010
vi.mock("../util/git.js");
11+
vi.mock("../util/exit.js");
1112

1213
const mockWorkos = vi.mocked(await import("../auth/workos.js"));
1314
const mockEnv = vi.mocked(await import("../env.js"));
1415
const mockGit = vi.mocked(await import("../util/git.js"));
1516
const mockStartRemoteTUIChat = vi.mocked(await import("../ui/index.js"));
17+
const mockExit = vi.mocked(await import("../util/exit.js"));
1618

1719
// Mock fetch globally
1820
const mockFetch = vi.fn();
@@ -62,6 +64,10 @@ describe("remote command", () => {
6264

6365
mockFetch.mockResolvedValue({
6466
ok: true,
67+
headers: {
68+
get: (name: string) =>
69+
name === "content-type" ? "application/json" : null,
70+
},
6571
json: async () => ({
6672
id: "test-agent-id",
6773
url: "ws://test-url.com",
@@ -70,6 +76,9 @@ describe("remote command", () => {
7076
});
7177

7278
mockStartRemoteTUIChat.startRemoteTUIChat.mockResolvedValue({} as any);
79+
80+
// Mock gracefulExit to prevent process.exit during tests
81+
mockExit.gracefulExit.mockResolvedValue(undefined);
7382
});
7483

7584
it("should include idempotency key in request body when provided", async () => {
@@ -151,10 +160,18 @@ describe("remote command", () => {
151160
mockFetch
152161
.mockResolvedValueOnce({
153162
ok: true,
163+
headers: {
164+
get: (name: string) =>
165+
name === "content-type" ? "application/json" : null,
166+
},
154167
json: async () => ({ url: "ws://tunnel-url.com", port: 9090 }),
155168
})
156169
.mockResolvedValue({
157170
ok: true,
171+
headers: {
172+
get: (name: string) =>
173+
name === "content-type" ? "application/json" : null,
174+
},
158175
json: async () => ({
159176
id: "test-agent-id",
160177
url: "ws://test-url.com",
@@ -187,6 +204,10 @@ describe("remote command", () => {
187204

188205
mockFetch.mockResolvedValueOnce({
189206
ok: true,
207+
headers: {
208+
get: (name: string) =>
209+
name === "content-type" ? "application/json" : null,
210+
},
190211
json: async () => tunnelResponse,
191212
});
192213

extensions/cli/src/commands/remote.ts

Lines changed: 30 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import chalk from "chalk";
22

3-
import { getAccessToken, loadAuthConfig } from "../auth/workos.js";
43
import { env } from "../env.js";
54
import { telemetryService } from "../telemetry/telemetryService.js";
65
import { startRemoteTUIChat } from "../ui/index.js";
6+
import {
7+
ApiRequestError,
8+
AuthenticationRequiredError,
9+
post,
10+
} from "../util/apiClient.js";
711
import { gracefulExit } from "../util/exit.js";
812
import { getRepoUrl } from "../util/git.js";
913
import { logger } from "../util/logger.js";
@@ -28,12 +32,6 @@ type AgentCreationResponse = TunnelResponse & {
2832
id: string;
2933
};
3034

31-
class AuthenticationRequiredError extends Error {
32-
constructor() {
33-
super("Not authenticated. Please run 'cn login' first.");
34-
}
35-
}
36-
3735
export async function remote(
3836
prompt: string | undefined,
3937
options: RemoteCommandOptions = {},
@@ -46,19 +44,12 @@ export async function remote(
4644
return;
4745
}
4846

49-
const accessToken = requireAccessToken();
50-
5147
if (options.id) {
52-
await connectExistingAgent(
53-
options.id,
54-
accessToken,
55-
actualPrompt,
56-
options.start,
57-
);
48+
await connectExistingAgent(options.id, actualPrompt, options.start);
5849
return;
5950
}
6051

61-
await createAndConnectRemoteEnvironment(accessToken, actualPrompt, options);
52+
await createAndConnectRemoteEnvironment(actualPrompt, options);
6253
} catch (error) {
6354
await handleRemoteError(error);
6455
}
@@ -99,29 +90,12 @@ async function connectToRemoteUrl(
9990
await launchRemoteTUI(remoteUrl, prompt);
10091
}
10192

102-
function requireAccessToken(): string {
103-
const authConfig = loadAuthConfig();
104-
105-
if (!authConfig) {
106-
throw new AuthenticationRequiredError();
107-
}
108-
109-
const accessToken = getAccessToken(authConfig);
110-
111-
if (!accessToken) {
112-
throw new AuthenticationRequiredError();
113-
}
114-
115-
return accessToken;
116-
}
117-
11893
async function connectExistingAgent(
11994
agentId: string,
120-
accessToken: string,
12195
prompt: string | undefined,
12296
startOnly?: boolean,
12397
) {
124-
const tunnel = await fetchAgentTunnel(agentId, accessToken);
98+
const tunnel = await fetchAgentTunnel(agentId);
12599

126100
if (startOnly) {
127101
printStartJson({
@@ -142,30 +116,24 @@ async function connectExistingAgent(
142116
}
143117

144118
async function createAndConnectRemoteEnvironment(
145-
accessToken: string,
146119
prompt: string | undefined,
147120
options: RemoteCommandOptions,
148121
) {
149122
const requestBody = buildAgentRequestBody(options, prompt);
150123

151-
const response = await fetch(new URL("agents", env.apiBase), {
152-
method: "POST",
153-
headers: {
154-
"Content-Type": "application/json",
155-
Authorization: `Bearer ${accessToken}`,
156-
},
157-
body: JSON.stringify(requestBody),
158-
});
159-
160-
if (!response.ok) {
161-
const errorText = await response.text();
162-
throw new Error(
163-
`Failed to create remote environment: ${response.status} ${errorText}`,
164-
);
124+
let result: AgentCreationResponse;
125+
try {
126+
const response = await post<AgentCreationResponse>("agents", requestBody);
127+
result = response.data;
128+
} catch (error) {
129+
if (error instanceof ApiRequestError) {
130+
throw new Error(
131+
`Failed to create remote environment: ${error.status} ${error.response || error.statusText}`,
132+
);
133+
}
134+
throw error;
165135
}
166136

167-
const result = (await response.json()) as AgentCreationResponse;
168-
169137
if (options.start) {
170138
printStartJson({
171139
status: "success",
@@ -216,26 +184,18 @@ function buildAgentRequestBody(
216184
return body;
217185
}
218186

219-
async function fetchAgentTunnel(agentId: string, accessToken: string) {
220-
const response = await fetch(
221-
new URL(`agents/${agentId}/tunnel`, env.apiBase),
222-
{
223-
method: "POST",
224-
headers: {
225-
"Content-Type": "application/json",
226-
Authorization: `Bearer ${accessToken}`,
227-
},
228-
},
229-
);
230-
231-
if (!response.ok) {
232-
const errorText = await response.text();
233-
throw new Error(
234-
`Failed to create tunnel for agent ${agentId}: ${response.status} ${errorText}`,
235-
);
187+
async function fetchAgentTunnel(agentId: string) {
188+
try {
189+
const response = await post<TunnelResponse>(`agents/${agentId}/tunnel`);
190+
return response.data;
191+
} catch (error) {
192+
if (error instanceof ApiRequestError) {
193+
throw new Error(
194+
`Failed to create tunnel for agent ${agentId}: ${error.status} ${error.response || error.statusText}`,
195+
);
196+
}
197+
throw error;
236198
}
237-
238-
return (await response.json()) as TunnelResponse;
239199
}
240200

241201
async function launchRemoteTUI(remoteUrl: string, prompt: string | undefined) {

extensions/cli/src/tools/index.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { multiEditTool } from "./multiEdit.js";
2323
import { readFileTool } from "./readFile.js";
2424
import { runTerminalCommandTool } from "./runTerminalCommand.js";
2525
import { searchCodeTool } from "./searchCode.js";
26+
import { statusTool } from "./status.js";
2627
import {
2728
type Tool,
2829
type ToolCall,
@@ -70,6 +71,11 @@ function getDynamicTools(): Tool[] {
7071
// Service not ready yet, no dynamic tools
7172
}
7273

74+
// Add beta status tool if --beta-status-tool flag is present
75+
if (process.argv.includes("--beta-status-tool")) {
76+
dynamicTools.push(statusTool);
77+
}
78+
7379
return dynamicTools;
7480
}
7581

extensions/cli/src/tools/status.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import {
2+
ApiRequestError,
3+
AuthenticationRequiredError,
4+
post,
5+
} from "../util/apiClient.js";
6+
import { logger } from "../util/logger.js";
7+
8+
import { Tool } from "./types.js";
9+
10+
/**
11+
* Extract the agent ID from the --id command line flag
12+
*/
13+
function getAgentIdFromArgs(): string | undefined {
14+
const args = process.argv;
15+
const idIndex = args.indexOf("--id");
16+
if (idIndex !== -1 && idIndex + 1 < args.length) {
17+
return args[idIndex + 1];
18+
}
19+
return undefined;
20+
}
21+
22+
export const statusTool: Tool = {
23+
name: "Status",
24+
displayName: "Status",
25+
description: `Set the current status of your task for the user to see
26+
27+
The available statuses are:
28+
- PLANNING: You are creating a plan before beginning implementation
29+
- WORKING: The task is in progress
30+
- DONE: The task is complete
31+
- BLOCKED: You need further information from the user in order to proceed
32+
33+
You should use this tool to notify the user whenever the state of your work changes. By default, the status is assumed to be "PLANNING" prior to you setting a different status.`,
34+
parameters: {
35+
type: "object",
36+
required: ["status"],
37+
properties: {
38+
status: {
39+
type: "string",
40+
description: "The status value to set",
41+
},
42+
},
43+
},
44+
readonly: true,
45+
isBuiltIn: true,
46+
run: async (args: { status: string }): Promise<string> => {
47+
try {
48+
// Get agent ID from --id flag
49+
const agentId = getAgentIdFromArgs();
50+
if (!agentId) {
51+
const errorMessage =
52+
"Agent ID is required. Please use the --id flag with cn serve.";
53+
logger.error(errorMessage);
54+
return `Error: ${errorMessage}`;
55+
}
56+
57+
// Call the API endpoint using shared client
58+
await post(`agents/${agentId}/status`, { status: args.status });
59+
60+
logger.info(`Status: ${args.status}`);
61+
return `Status set: ${args.status}`;
62+
} catch (error) {
63+
if (error instanceof AuthenticationRequiredError) {
64+
logger.error(error.message);
65+
return "Error: Authentication required";
66+
}
67+
68+
if (error instanceof ApiRequestError) {
69+
return `Error setting status: ${error.status} ${error.response || error.statusText}`;
70+
}
71+
72+
const errorMessage =
73+
error instanceof Error ? error.message : String(error);
74+
logger.error(`Error setting status: ${errorMessage}`);
75+
return `Error setting status: ${errorMessage}`;
76+
}
77+
},
78+
};

0 commit comments

Comments
 (0)