mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
feat: add /remote-control command to start Remote Control for a session (#1197)
Adds a `/remote-control [name]` command (alias `/rc`) so a session hosted over ACP can be continued from claude.ai/code or the Claude mobile app. Addresses #459. The CLI's own `/remote-control` is terminal-bound and isn't advertised to SDK hosts, and `remoteControlAtStartup` only applies to interactive sessions. The SDK does expose a runtime `enableRemoteControl(enabled, name?)` on the query object, which returns the session URL. See anthropics/claude-agent-sdk-typescript#460. - The adapter handles the command itself in `prompt()`, calls `enableRemoteControl(true, name)`, and replies with the session URL as an agent message. The turn ends without going to the model. - Failures (for example Remote Control disabled by policy) are reported to the user in the same way. - The command is added to `available_commands_update` only when the query exposes the method. - `enableRemoteControl` is not in the published `Query` types yet, so it is typed locally the same way as `mcpAuthenticate`. ## Testing - `npm run check`, `npm run build` and `npm run test:run` pass, including new tests in `src/tests/remote-control.test.ts`. - Ran the built agent through `examples/simple-client.ts` against a real Claude Max session: the command was advertised, `/remote-control` returned a working `claude.ai/code` URL, and prompts sent from the Claude iOS app ran in the session and were answered. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Noah Eisenbruch <noaheisenbruch@gmail.com> Co-authored-by: Andrey Bragin <andrey.bragin@jetbrains.com>
This commit is contained in:
committed by
GitHub
parent
f5967fd6b4
commit
db9f50f021
163
src/acp-agent.ts
163
src/acp-agent.ts
@@ -558,6 +558,53 @@ function supportsMcpOAuth(query: Query): query is McpOAuthQuery {
|
||||
return typeof (query as Partial<McpOAuthQuery>).mcpAuthenticate === "function";
|
||||
}
|
||||
|
||||
/** Runtime Remote Control control exposed by the pinned Agent SDK. Like
|
||||
* `mcpAuthenticate`, it is not in the public `Query` declaration yet. The CLI's
|
||||
* own `/remote-control` is terminal-bound and not offered to SDK hosts, so the
|
||||
* adapter handles the command itself and answers with the session URL. */
|
||||
type RemoteControlQuery = Query & {
|
||||
enableRemoteControl(
|
||||
enabled: boolean,
|
||||
name?: string,
|
||||
): Promise<{ session_url?: string; connect_url?: string }>;
|
||||
};
|
||||
|
||||
function supportsRemoteControl(query: Query): query is RemoteControlQuery {
|
||||
return typeof (query as Partial<RemoteControlQuery>).enableRemoteControl === "function";
|
||||
}
|
||||
|
||||
const REMOTE_CONTROL_COMMAND_NAME = "remote-control";
|
||||
|
||||
type RemoteControlCommand = { action: "connect"; name?: string } | { action: "disconnect" };
|
||||
|
||||
/** Parses `/remote-control [name]` and `/remote-control off` from a single-text
|
||||
* prompt. Connecting is idempotent, so the command never needs to know whether
|
||||
* Remote Control is already on. */
|
||||
function parseRemoteControlCommand(prompt: PromptRequest["prompt"]): RemoteControlCommand | null {
|
||||
const [first] = prompt;
|
||||
if (prompt.length !== 1 || first?.type !== "text") return null;
|
||||
const match = /^\/remote-control(?:\s+([\s\S]*))?$/.exec(first.text.trim());
|
||||
if (!match) return null;
|
||||
const argument = match[1]?.trim();
|
||||
if (argument?.toLowerCase() === "off") return { action: "disconnect" };
|
||||
return argument ? { action: "connect", name: argument } : { action: "connect" };
|
||||
}
|
||||
|
||||
/** Whether Claude Code offers a slash command of this name to an ACP client.
|
||||
* This is the rule `getAvailableSlashCommands` applies when it lists the
|
||||
* commands, so the adapter stands in for the command exactly when the client
|
||||
* sees the adapter's entry: Claude Code's terminal-bound commands are not
|
||||
* offered, and a user's own command or skill of the name is never shadowed. */
|
||||
function claudeCodeOffersCommand(
|
||||
commands: readonly SlashCommand[],
|
||||
terminalCommands: readonly string[] | undefined,
|
||||
name: string,
|
||||
): boolean {
|
||||
return commands.some(
|
||||
(command) => command.name === name && !terminalCommands?.includes(command.name),
|
||||
);
|
||||
}
|
||||
|
||||
/** Wait for a polling interval, resolving false when the session is aborted. */
|
||||
function waitUnlessAborted(milliseconds: number, signal: AbortSignal): Promise<boolean> {
|
||||
if (signal.aborted) return Promise.resolve(false);
|
||||
@@ -1024,6 +1071,9 @@ export type Session = {
|
||||
* terminal (e.g. /doctor, /color). ACP clients aren't that terminal, so
|
||||
* these are filtered out of `available_commands_update` payloads. */
|
||||
terminalSlashCommands?: string[];
|
||||
/** The `/remote-control` command that is running, from its request to its
|
||||
* reply. Prompts wait for it, and a second command is refused meanwhile. */
|
||||
remoteControlCommand?: Promise<void>;
|
||||
/** The resolved SKILL.md paths of the skill commands, keyed by the cwd and
|
||||
* the command name. A `commands_changed` clears it, because the skill
|
||||
* files can have changed then. */
|
||||
@@ -3754,6 +3804,57 @@ export class ClaudeAcpAgent {
|
||||
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
||||
}
|
||||
|
||||
const remoteControlCommand = parseRemoteControlCommand(params.prompt);
|
||||
if (
|
||||
remoteControlCommand &&
|
||||
supportsRemoteControl(session.query) &&
|
||||
!claudeCodeOffersCommand(
|
||||
await session.query.supportedCommands(),
|
||||
session.terminalSlashCommands,
|
||||
REMOTE_CONTROL_COMMAND_NAME,
|
||||
)
|
||||
) {
|
||||
// The reply bypasses the turn queue, so it would land inside the output of
|
||||
// a turn that is still running, and v2 would report no `running` state
|
||||
// around it. Refuse instead of interleaving. `foregroundWork` also covers
|
||||
// a cycle that Claude Code started on its own, such as its follow-up
|
||||
// after a background subagent finished, which is not in `turnQueue`.
|
||||
if (
|
||||
this.foregroundWork.has(params.sessionId) ||
|
||||
(session.turnQueue ?? []).some((turn) => !turn.settled)
|
||||
) {
|
||||
throw RequestError.invalidRequest(
|
||||
undefined,
|
||||
"Remote Control can't be changed while Claude is working. Try again when the turn ends.",
|
||||
);
|
||||
}
|
||||
if (session.remoteControlCommand) {
|
||||
throw RequestError.invalidRequest(
|
||||
undefined,
|
||||
"Remote Control is already being changed. Try again in a moment.",
|
||||
);
|
||||
}
|
||||
// Answered by the adapter without a model turn; v2 clients treat an
|
||||
// `ended` without a prior `inserted` as a refused prompt.
|
||||
events.inserted(randomUUID());
|
||||
// The request goes over the network. Until it and its reply are done,
|
||||
// `remoteControlCommand` holds back the prompts that follow, which would
|
||||
// otherwise start a turn that the reply lands in.
|
||||
const command = this.runRemoteControlCommand(
|
||||
params.sessionId,
|
||||
session.query,
|
||||
remoteControlCommand,
|
||||
);
|
||||
session.remoteControlCommand = command;
|
||||
try {
|
||||
await command;
|
||||
} finally {
|
||||
session.remoteControlCommand = undefined;
|
||||
}
|
||||
events.ended({ stopReason: "end_turn" });
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionGuard = this.runClaudeSubscriptionGuard(params.sessionId, session);
|
||||
if (subscriptionGuard) await subscriptionGuard;
|
||||
|
||||
@@ -3794,6 +3895,12 @@ export class ClaudeAcpAgent {
|
||||
session.promptSuggestionExpected = false;
|
||||
}
|
||||
|
||||
// A `/remote-control` command still in flight answers into the session's
|
||||
// output; wait for its reply before this prompt's turn can begin. Nothing
|
||||
// below awaits before the turn is queued, so no command can start between.
|
||||
// A failure of the command's reply is reported to the command's own prompt.
|
||||
if (session.remoteControlCommand) await session.remoteControlCommand.catch(() => {});
|
||||
|
||||
// A `session/close` during the awaits above tore the session down, and
|
||||
// its consumer is gone, so the prompt would never be inserted. End it as
|
||||
// the close ends a queued turn: `cancelled` on v1, -32800 on v2. A stream
|
||||
@@ -3846,6 +3953,38 @@ export class ClaudeAcpAgent {
|
||||
);
|
||||
}
|
||||
|
||||
/** Connects Remote Control (again, if it is already on) or disconnects it. */
|
||||
private async runRemoteControlCommand(
|
||||
sessionId: string,
|
||||
query: RemoteControlQuery,
|
||||
command: RemoteControlCommand,
|
||||
): Promise<void> {
|
||||
let text: string;
|
||||
if (command.action === "disconnect") {
|
||||
try {
|
||||
await query.enableRemoteControl(false);
|
||||
text = "Remote Control is off. Run /remote-control to reconnect.";
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to disable Remote Control: ${error}`);
|
||||
text = `Could not stop Remote Control: ${error instanceof Error ? error.message : error}`;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const result = await query.enableRemoteControl(true, command.name);
|
||||
text = result.session_url
|
||||
? `Remote Control is active. Continue this session from claude.ai/code or the Claude mobile app:\n\n${result.session_url}\n\nRun /remote-control off to disconnect.`
|
||||
: "Remote Control is active, but Claude Code did not return a session URL.";
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to enable Remote Control: ${error}`);
|
||||
text = `Could not start Remote Control: ${error instanceof Error ? error.message : error}`;
|
||||
}
|
||||
}
|
||||
await this.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } },
|
||||
});
|
||||
}
|
||||
|
||||
/** `--hide-claude-auth` applies only to the CLI's own login. A provider
|
||||
* override (`providers/set` or gateway `authenticate`) routes traffic away
|
||||
* from it, so the subscription guard is off while one is active. */
|
||||
@@ -6139,6 +6278,7 @@ export class ClaudeAcpAgent {
|
||||
session.terminalSlashCommands,
|
||||
this.toolCallCapabilities.air.client ? session.cwd : undefined,
|
||||
session.skillPaths,
|
||||
session.query,
|
||||
),
|
||||
},
|
||||
});
|
||||
@@ -10253,6 +10393,7 @@ export class ClaudeAcpAgent {
|
||||
session.terminalSlashCommands,
|
||||
this.toolCallCapabilities.air.client ? session.cwd : undefined,
|
||||
(session.skillPaths ??= new Map()),
|
||||
session.query,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12409,6 +12550,7 @@ function getAvailableSlashCommands(
|
||||
airSkillCwd?: string,
|
||||
// The resolved SKILL.md paths of the session, keyed by the cwd and the name.
|
||||
skillPaths?: Map<string, string | undefined>,
|
||||
query?: Query,
|
||||
): AvailableCommand[] {
|
||||
const skillPath = (name: string, cwd: string): string | undefined => {
|
||||
const key = `${cwd}\0${name}`;
|
||||
@@ -12429,6 +12571,18 @@ function getAvailableSlashCommands(
|
||||
"todos",
|
||||
];
|
||||
|
||||
const remoteControlCommands: AvailableCommand[] =
|
||||
query && supportsRemoteControl(query)
|
||||
? [
|
||||
{
|
||||
name: REMOTE_CONTROL_COMMAND_NAME,
|
||||
description:
|
||||
"Continue this session from claude.ai/code or the Claude mobile app; `off` disconnects",
|
||||
input: { hint: "[session name | off]" },
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
// The adapter replaces the terminal text of `/mcp` and runs its actions,
|
||||
// even when Claude Code tags the command terminal-bound.
|
||||
const advertised = commands
|
||||
@@ -12466,7 +12620,14 @@ function getAvailableSlashCommands(
|
||||
};
|
||||
})
|
||||
.filter((command: AvailableCommand) => !UNSUPPORTED_COMMANDS.includes(command.name));
|
||||
return [...advertised, MCP_AVAILABLE_COMMAND];
|
||||
|
||||
return [
|
||||
...advertised,
|
||||
MCP_AVAILABLE_COMMAND,
|
||||
...remoteControlCommands.filter(
|
||||
(command) => !claudeCodeOffersCommand(commands, terminalCommands, command.name),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
function formatUriAsLink(uri: string): string {
|
||||
|
||||
257
src/tests/remote-control.test.ts
Normal file
257
src/tests/remote-control.test.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { AgentSideConnection as AcpClient } from "@agentclientprotocol/sdk";
|
||||
import { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { mockSessionState } from "./session-doubles.js";
|
||||
|
||||
function createAgent(query: Record<string, unknown>, sessionOverrides: Record<string, any> = {}) {
|
||||
const sessionUpdate = vi.fn(async () => {});
|
||||
const client = { sessionUpdate } as unknown as AcpClient;
|
||||
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
||||
agent.sessions["session-1"] = mockSessionState({
|
||||
query: { supportedCommands: async () => [], ...query },
|
||||
...sessionOverrides,
|
||||
});
|
||||
return { agent, sessionUpdate };
|
||||
}
|
||||
|
||||
const prompt = (text: string) => ({
|
||||
sessionId: "session-1",
|
||||
prompt: [{ type: "text" as const, text }],
|
||||
});
|
||||
|
||||
const messageText = (sessionUpdate: ReturnType<typeof createAgent>["sessionUpdate"]) =>
|
||||
(sessionUpdate.mock.calls as any[][]).at(-1)?.[0].update.content.text;
|
||||
|
||||
describe("/remote-control", () => {
|
||||
it("starts Remote Control and reports the URL", async () => {
|
||||
const enableRemoteControl = vi.fn(async () => ({
|
||||
session_url: "https://claude.ai/code/session_123",
|
||||
}));
|
||||
const { agent, sessionUpdate } = createAgent({ enableRemoteControl });
|
||||
|
||||
const response = await agent.prompt(prompt("/remote-control"));
|
||||
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
expect(enableRemoteControl).toHaveBeenCalledWith(true, undefined);
|
||||
expect(messageText(sessionUpdate)).toContain("https://claude.ai/code/session_123");
|
||||
});
|
||||
|
||||
it("passes the argument as the session name", async () => {
|
||||
const enableRemoteControl = vi.fn(async () => ({ session_url: "https://claude.ai/code/s" }));
|
||||
const { agent } = createAgent({ enableRemoteControl });
|
||||
|
||||
await agent.prompt(prompt("/remote-control My laptop session"));
|
||||
|
||||
expect(enableRemoteControl).toHaveBeenCalledWith(true, "My laptop session");
|
||||
});
|
||||
|
||||
it("reconnects and renames on every run instead of toggling", async () => {
|
||||
const enableRemoteControl = vi.fn(async () => ({ session_url: "https://claude.ai/code/s" }));
|
||||
const { agent } = createAgent({ enableRemoteControl });
|
||||
|
||||
await agent.prompt(prompt("/remote-control"));
|
||||
await agent.prompt(prompt("/remote-control new-name"));
|
||||
|
||||
expect(enableRemoteControl).toHaveBeenNthCalledWith(1, true, undefined);
|
||||
expect(enableRemoteControl).toHaveBeenNthCalledWith(2, true, "new-name");
|
||||
});
|
||||
|
||||
it("disconnects with `off`", async () => {
|
||||
const enableRemoteControl = vi.fn(async () => ({}));
|
||||
const { agent, sessionUpdate } = createAgent({ enableRemoteControl });
|
||||
|
||||
const response = await agent.prompt(prompt("/remote-control off"));
|
||||
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
expect(enableRemoteControl).toHaveBeenCalledExactlyOnceWith(false);
|
||||
expect(messageText(sessionUpdate)).toContain("Remote Control is off");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/remote-control", "Could not start Remote Control"],
|
||||
["/remote-control off", "Could not stop Remote Control"],
|
||||
])("reports the failure of %s to the user", async (command, expected) => {
|
||||
const enableRemoteControl = vi.fn(async () => {
|
||||
throw new Error("Remote Control is disabled by policy");
|
||||
});
|
||||
const { agent, sessionUpdate } = createAgent({ enableRemoteControl });
|
||||
|
||||
const response = await agent.prompt(prompt(command));
|
||||
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
expect(messageText(sessionUpdate)).toContain(expected);
|
||||
expect(messageText(sessionUpdate)).toContain("Remote Control is disabled by policy");
|
||||
});
|
||||
|
||||
it.each(["/remote-control", "/remote-control off"])(
|
||||
"refuses %s while a turn is still running",
|
||||
async (command) => {
|
||||
const enableRemoteControl = vi.fn(async () => ({}));
|
||||
const { agent, sessionUpdate } = createAgent(
|
||||
{ enableRemoteControl },
|
||||
{ turnQueue: [{ settled: false }] },
|
||||
);
|
||||
|
||||
await expect(agent.prompt(prompt(command))).rejects.toThrow(/while Claude is working/);
|
||||
|
||||
expect(enableRemoteControl).not.toHaveBeenCalled();
|
||||
expect(sessionUpdate).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("refuses the command while Claude Code runs a cycle of its own", async () => {
|
||||
const enableRemoteControl = vi.fn(async () => ({}));
|
||||
const { agent, sessionUpdate } = createAgent({ enableRemoteControl });
|
||||
(agent as any).startForeground("session-1", "autonomous-cycle");
|
||||
|
||||
await expect(agent.prompt(prompt("/remote-control"))).rejects.toThrow(
|
||||
/while Claude is working/,
|
||||
);
|
||||
|
||||
expect(enableRemoteControl).not.toHaveBeenCalled();
|
||||
expect(sessionUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("answers while only settled turns remain in the queue", async () => {
|
||||
const enableRemoteControl = vi.fn(async () => ({ session_url: "https://claude.ai/code/s" }));
|
||||
const { agent } = createAgent({ enableRemoteControl }, { turnQueue: [{ settled: true }] });
|
||||
|
||||
await agent.prompt(prompt("/remote-control"));
|
||||
|
||||
expect(enableRemoteControl).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/rc", []],
|
||||
["/remote-control", [{ name: "remote-control", description: "", argumentHint: "" }]],
|
||||
])("leaves %s to Claude Code", async (command, advertised) => {
|
||||
const enableRemoteControl = vi.fn(async () => ({}));
|
||||
const push = vi.fn();
|
||||
async function* noMessages() {}
|
||||
const { agent } = createAgent(
|
||||
Object.assign(noMessages(), {
|
||||
enableRemoteControl,
|
||||
supportedCommands: async () => advertised,
|
||||
}) as any,
|
||||
{ input: { push } },
|
||||
);
|
||||
|
||||
await agent.startTurn(prompt(command), {
|
||||
inserted() {},
|
||||
ended() {},
|
||||
failed() {},
|
||||
});
|
||||
|
||||
expect(enableRemoteControl).not.toHaveBeenCalled();
|
||||
expect(push).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("handles the command when Claude Code lists it as terminal-only", async () => {
|
||||
const enableRemoteControl = vi.fn(async () => ({ session_url: "https://claude.ai/code/s" }));
|
||||
const supportedCommands = async () => [
|
||||
{ name: "remote-control", description: "", argumentHint: "" },
|
||||
];
|
||||
const { agent, sessionUpdate } = createAgent(
|
||||
{ enableRemoteControl, supportedCommands },
|
||||
{ terminalSlashCommands: ["remote-control"] },
|
||||
);
|
||||
|
||||
await agent.prompt(prompt("/remote-control"));
|
||||
await (agent as any).sendAvailableCommandsUpdate("session-1");
|
||||
|
||||
expect(enableRemoteControl).toHaveBeenCalledOnce();
|
||||
const advertised = (sessionUpdate.mock.calls as any[][])
|
||||
.map(([notification]) => notification.update.availableCommands)
|
||||
.find(Boolean);
|
||||
expect(advertised.filter((command: any) => command.name === "remote-control")).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe("while the request is in flight", () => {
|
||||
const events = { inserted() {}, ended() {}, failed() {} };
|
||||
|
||||
function pendingRequest() {
|
||||
let finish!: (result: { session_url: string }) => void;
|
||||
const enableRemoteControl = vi.fn(
|
||||
() =>
|
||||
new Promise<{ session_url: string }>((resolve) => {
|
||||
finish = resolve;
|
||||
}),
|
||||
);
|
||||
return { enableRemoteControl, finish: (result: { session_url: string }) => finish(result) };
|
||||
}
|
||||
|
||||
function createPendingAgent() {
|
||||
const request = pendingRequest();
|
||||
const push = vi.fn();
|
||||
const created = createAgent(
|
||||
Object.assign((async function* () {})(), {
|
||||
enableRemoteControl: request.enableRemoteControl,
|
||||
supportedCommands: async () => [],
|
||||
}) as any,
|
||||
{ input: { push } },
|
||||
);
|
||||
return { ...created, ...request, push };
|
||||
}
|
||||
|
||||
it("holds a following prompt back until the reply is sent", async () => {
|
||||
const { agent, sessionUpdate, enableRemoteControl, finish, push } = createPendingAgent();
|
||||
|
||||
const command = agent.startTurn(prompt("/remote-control"), events);
|
||||
await vi.waitFor(() => expect(enableRemoteControl).toHaveBeenCalled());
|
||||
const following = agent.startTurn(prompt("hello"), events);
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
|
||||
finish({ session_url: "https://claude.ai/code/s" });
|
||||
await Promise.all([command, following]);
|
||||
|
||||
expect(sessionUpdate).toHaveBeenCalledOnce();
|
||||
expect(push).toHaveBeenCalledOnce();
|
||||
expect(sessionUpdate.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
push.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a second command", async () => {
|
||||
const { agent, enableRemoteControl, finish } = createPendingAgent();
|
||||
|
||||
const command = agent.startTurn(prompt("/remote-control"), events);
|
||||
await vi.waitFor(() => expect(enableRemoteControl).toHaveBeenCalled());
|
||||
|
||||
await expect(agent.startTurn(prompt("/remote-control off"), events)).rejects.toThrow(
|
||||
/already being changed/,
|
||||
);
|
||||
expect(enableRemoteControl).toHaveBeenCalledOnce();
|
||||
|
||||
finish({ session_url: "https://claude.ai/code/s" });
|
||||
await command;
|
||||
});
|
||||
|
||||
it("lets prompts through again once the command is done", async () => {
|
||||
const { agent, finish, enableRemoteControl, push } = createPendingAgent();
|
||||
|
||||
const command = agent.startTurn(prompt("/remote-control"), events);
|
||||
await vi.waitFor(() => expect(enableRemoteControl).toHaveBeenCalled());
|
||||
finish({ session_url: "https://claude.ai/code/s" });
|
||||
await command;
|
||||
await agent.startTurn(prompt("hello"), events);
|
||||
|
||||
expect(push).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
it("advertises the command only when the query supports it", async () => {
|
||||
const supportedCommands = async () => [{ name: "compact", description: "", argumentHint: "" }];
|
||||
const supported = createAgent({ enableRemoteControl: vi.fn(), supportedCommands });
|
||||
const unsupported = createAgent({ supportedCommands });
|
||||
|
||||
await (supported.agent as any).sendAvailableCommandsUpdate("session-1");
|
||||
await (unsupported.agent as any).sendAvailableCommandsUpdate("session-1");
|
||||
|
||||
const names = (update: ReturnType<typeof createAgent>["sessionUpdate"]) =>
|
||||
(update.mock.calls as any[][])[0][0].update.availableCommands.map((c: any) => c.name);
|
||||
expect(names(supported.sessionUpdate)).toContain("remote-control");
|
||||
expect(names(unsupported.sessionUpdate)).not.toContain("remote-control");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user