mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
feat: show messages typed in other Remote Control clients (#1371)
A message typed in claude.ai or the Claude mobile app while Remote
Control is on never appeared in the client that hosts the session, even
though Claude's reply to it did. This makes both sides of that
conversation show up.
Two causes, one commit each:
- **Messages from other clients were dropped.** Claude Code echoes them
to the adapter as a replayed user message with `origin: { kind: "human"
}` and a uuid the adapter never created. That matched no turn, so it
fell into the "unrelated replay" branch and was ignored. Human-origin,
top-level, non-synthetic messages that match no turn are now forwarded
as `user_message_chunk` (mapped to `user_message` on ACP v2). The echoes
of the adapter's own prompts still match their turn first and stay out
of the feed.
- **The stream reader was not running after `/remote-control`.** The
reader that forwards Claude Code's output starts with the first prompt,
and `/remote-control` answers without a turn. In a thread where it was
the first thing run, nothing read the stream, so neither the phone's
message nor Claude's reply reached the client. The command now starts
the reader. This also fixes the reply side for #1197.
## Testing
- `npm run check`, `npm run build` and `npm run test:run` pass, with new
tests in `remote-user-messages.test.ts` and `remote-control.test.ts`.
The tests for both fixes fail without them.
- Ran the built adapter as a custom agent in Zed: after
`/remote-control`, a message sent from the Claude iOS app showed up in
the Zed thread along with Claude's reply.
- Not tested: messages with images or attachments, and whether resuming
a session can replay old human messages through the same path.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
e9f986b8a9
commit
5b11f470b1
@@ -87,6 +87,7 @@ import {
|
||||
SDKPartialAssistantMessage,
|
||||
SessionMessage,
|
||||
SDKUserMessage,
|
||||
SDKUserMessageReplay,
|
||||
Settings,
|
||||
SlashCommand,
|
||||
ThinkingConfig,
|
||||
@@ -598,6 +599,19 @@ function parseRemoteControlCommand(prompt: PromptRequest["prompt"]): RemoteContr
|
||||
return argument ? { action: "connect", name: argument } : { action: "connect" };
|
||||
}
|
||||
|
||||
/** A top-level message a person typed in another client of the session, such
|
||||
* as claude.ai or the Claude mobile app over Remote Control. Tool results and
|
||||
* the CLI's own synthetic messages are user-role too, but carry no human
|
||||
* origin. */
|
||||
function isRemoteHumanMessage(message: SDKUserMessage | SDKUserMessageReplay): boolean {
|
||||
return (
|
||||
message.type === "user" &&
|
||||
message.origin?.kind === "human" &&
|
||||
message.parent_tool_use_id === null &&
|
||||
!message.isSynthetic
|
||||
);
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -1079,6 +1093,10 @@ 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 uuids of the user messages this adapter pushed whose echo has not come
|
||||
* back yet. Claude Code stamps the echo with the same human origin as a
|
||||
* message typed in another client, so the uuid is what tells the two apart. */
|
||||
sentUserUuids?: Set<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>;
|
||||
@@ -3780,6 +3798,11 @@ export class ClaudeAcpAgent {
|
||||
"Remote Control is already being changed. Try again in a moment.",
|
||||
);
|
||||
}
|
||||
// Once Remote Control is on, other clients can start turns or send
|
||||
// messages on their own. Something has to read the query stream for
|
||||
// those to reach this client, and a command that answers without a turn
|
||||
// would otherwise leave the stream unread until the next prompt.
|
||||
this.ensureConsumer(session, params.sessionId);
|
||||
// Answered by the adapter without a model turn; v2 clients treat an
|
||||
// `ended` without a prior `inserted` as a refused prompt.
|
||||
events.inserted(randomUUID());
|
||||
@@ -3890,6 +3913,7 @@ export class ClaudeAcpAgent {
|
||||
|
||||
session.turnQueue ??= [];
|
||||
session.turnQueue.push(turn);
|
||||
(session.sentUserUuids ??= new Set()).add(promptUuid);
|
||||
session.input.push(userMessage);
|
||||
this.ensureConsumer(session, params.sessionId);
|
||||
// The prompt is queued, so its turn goes ahead even if the client misses
|
||||
@@ -4326,6 +4350,7 @@ export class ClaudeAcpAgent {
|
||||
// The held turn's work had ended at its result; the steer resumes it.
|
||||
this.startForeground(sessionId, turnInFlight);
|
||||
}
|
||||
(session.sentUserUuids ??= new Set()).add(steeredUuid);
|
||||
session.input.push(userMessage);
|
||||
const firstText = params.prompt[0]?.type === "text" ? params.prompt[0].text : "";
|
||||
await this.publishGoalFromPrompt(sessionId, firstText, steeredUuid);
|
||||
@@ -7722,6 +7747,9 @@ export class ClaudeAcpAgent {
|
||||
if (session.pendingGoalUpdate?.commandUuid === message.uuid) {
|
||||
session.pendingGoalUpdate.started = true;
|
||||
}
|
||||
// Taken out for every echo, not only in the branch below, so the
|
||||
// set does not grow with each prompt.
|
||||
const sentByAdapter = session.sentUserUuids?.delete(message.uuid) ?? false;
|
||||
const queued = findUnsettledTurn(message.uuid);
|
||||
if (queued) {
|
||||
// Only (re)activate if this isn't already the active turn — a
|
||||
@@ -7808,7 +7836,34 @@ export class ClaudeAcpAgent {
|
||||
) {
|
||||
session.activeTurn.steeredAwaitingResult = true;
|
||||
}
|
||||
// Unrelated replay (e.g. the echo of an already-settled turn).
|
||||
// A message typed in another client of the session (Remote
|
||||
// Control from claude.ai or the mobile app). The adapter's own
|
||||
// prompts and steers are stamped human too, so they are told
|
||||
// apart by uuid: a steer matches no turn, and a turn can settle
|
||||
// before its echo comes.
|
||||
const remoteContent =
|
||||
!sentByAdapter && isRemoteHumanMessage(message)
|
||||
? stripLocalCommandMetadata(message.message.content)
|
||||
: null;
|
||||
if (remoteContent !== null) {
|
||||
for (const notification of toAcpNotifications(
|
||||
remoteContent as ContentBlockParam[] | string,
|
||||
"user",
|
||||
params.sessionId,
|
||||
session.toolUseCache,
|
||||
routedNotificationClient,
|
||||
this.logger,
|
||||
{
|
||||
clientCapabilities: this.clientCapabilities,
|
||||
toolCallCapabilities: this.toolCallCapabilities,
|
||||
cwd: session.cwd,
|
||||
messageId: messageIdForGrouping(message),
|
||||
},
|
||||
)) {
|
||||
await sendUpdate(notification);
|
||||
}
|
||||
}
|
||||
// Otherwise an unrelated replay (e.g. the echo of an already-settled turn).
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export type ClearContextSession<Turn extends ClearContextTurn = ClearContextTurn
|
||||
turnQueue?: Turn[];
|
||||
pendingExitPlanContextReset?: ClearContextReset;
|
||||
contextUsedTokens?: number;
|
||||
sentUserUuids?: Set<string>;
|
||||
input: { push(message: SDKUserMessage): unknown };
|
||||
};
|
||||
|
||||
@@ -158,6 +159,7 @@ export async function continuePlanInFreshContext<
|
||||
}
|
||||
assertRestartActive(freshSession);
|
||||
|
||||
(freshSession.sentUserUuids ??= new Set()).add(turn.promptUuid);
|
||||
freshSession.input.push(host.continuationMessage(sessionId, reset.plan, turn.promptUuid));
|
||||
assertRestartActive(freshSession);
|
||||
host.ensureConsumer(freshSession, sessionId);
|
||||
|
||||
@@ -144,6 +144,9 @@ describe("continuePlanInFreshContext", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
// Its echo carries the same human origin as a message typed in another
|
||||
// client, so the fresh session must know the uuid is its own.
|
||||
expect(freshSession.sentUserUuids).toEqual(new Set([turn.promptUuid]));
|
||||
expect(host.ensureConsumer).toHaveBeenCalledWith(freshSession, "public-session");
|
||||
});
|
||||
|
||||
|
||||
@@ -2,12 +2,19 @@ import { describe, it, expect, vi } from "vitest";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { mockSessionState } from "./session-doubles.js";
|
||||
|
||||
/** A stream that stays open, like the real one: a consumer that reaches its end
|
||||
* closes the session. */
|
||||
async function* openStream(): AsyncGenerator<never> {
|
||||
await new Promise<never>(() => {});
|
||||
yield* [];
|
||||
}
|
||||
|
||||
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 },
|
||||
query: Object.assign(openStream(), { supportedCommands: async () => [] }, query),
|
||||
...sessionOverrides,
|
||||
});
|
||||
return { agent, sessionUpdate };
|
||||
@@ -183,10 +190,7 @@ describe("/remote-control", () => {
|
||||
const request = pendingRequest();
|
||||
const push = vi.fn();
|
||||
const created = createAgent(
|
||||
Object.assign((async function* () {})(), {
|
||||
enableRemoteControl: request.enableRemoteControl,
|
||||
supportedCommands: async () => [],
|
||||
}) as any,
|
||||
{ enableRemoteControl: request.enableRemoteControl },
|
||||
{ input: { push } },
|
||||
);
|
||||
return { ...created, ...request, push };
|
||||
@@ -240,6 +244,16 @@ describe("/remote-control", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("starts reading the query stream so other clients' messages reach this one", async () => {
|
||||
const { agent } = createAgent({
|
||||
enableRemoteControl: vi.fn(async () => ({ session_url: "https://claude.ai/code/s" })),
|
||||
});
|
||||
|
||||
await agent.prompt(prompt("/remote-control"));
|
||||
|
||||
expect(agent.sessions["session-1"].consumer).toBeDefined();
|
||||
});
|
||||
|
||||
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 });
|
||||
|
||||
205
src/tests/remote-user-messages.test.ts
Normal file
205
src/tests/remote-user-messages.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { mockSessionState, successfulResultMessage, wrapQuery } from "./session-doubles.js";
|
||||
|
||||
const HUMAN = { kind: "human" };
|
||||
|
||||
function userReplay(uuid: string, text: string, extra: Record<string, unknown> = {}) {
|
||||
return userReplayContent(uuid, [{ type: "text", text }], extra);
|
||||
}
|
||||
|
||||
function userReplayContent(uuid: string, content: unknown[], extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
type: "user",
|
||||
message: { role: "user", content },
|
||||
parent_tool_use_id: null,
|
||||
uuid,
|
||||
session_id: "test-session",
|
||||
isReplay: true,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** The echo of a message the adapter pushed. The adapter stamps every prompt
|
||||
* and steer with a human origin (`promptToClaude`), and Claude Code echoes it
|
||||
* back unchanged. */
|
||||
const echoOfAdapterMessage = (uuid: string, text: string) =>
|
||||
userReplay(uuid, text, { origin: HUMAN });
|
||||
|
||||
const idle = { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
|
||||
function createAgent() {
|
||||
const updates: any[] = [];
|
||||
const client = {
|
||||
sessionUpdate: vi.fn(async (notification: any) => {
|
||||
updates.push(notification.update);
|
||||
}),
|
||||
} as unknown as AcpClient;
|
||||
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
||||
const userChunks = () =>
|
||||
updates.filter((update) => update.sessionUpdate === "user_message_chunk");
|
||||
const userTexts = () => userChunks().map((update) => update.content.text);
|
||||
return { agent, userTexts, userChunks };
|
||||
}
|
||||
|
||||
async function promptWith(messages: (promptUuid: string) => unknown[]) {
|
||||
return (await promptWithChunks(messages)).map((chunk) => chunk.content.text);
|
||||
}
|
||||
|
||||
async function promptWithChunks(messages: (promptUuid: string) => unknown[]) {
|
||||
const { agent, userChunks } = createAgent();
|
||||
const input = new Pushable<any>();
|
||||
async function* generator() {
|
||||
const { value: sent } = await input[Symbol.asyncIterator]().next();
|
||||
yield echoOfAdapterMessage(sent.uuid, "typed in Zed");
|
||||
yield* messages(sent.uuid);
|
||||
yield successfulResultMessage();
|
||||
yield idle;
|
||||
}
|
||||
agent.sessions["test-session"] = mockSessionState({ query: wrapQuery(generator()), input });
|
||||
await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "typed in Zed" }],
|
||||
});
|
||||
return userChunks();
|
||||
}
|
||||
|
||||
describe("user messages typed in another client of the session", () => {
|
||||
it("does not repeat the echo of the prompt the adapter sent", async () => {
|
||||
expect(await promptWith(() => [])).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["has no origin", {}],
|
||||
["is synthetic", { origin: HUMAN, isSynthetic: true }],
|
||||
["comes from a peer session", { origin: { kind: "peer", from: "other" } }],
|
||||
["comes from a subagent", { origin: HUMAN, parent_tool_use_id: "toolu_1" }],
|
||||
])("ignores a replayed message that %s", async (_description, extra) => {
|
||||
expect(await promptWith(() => [userReplay("other-1", "not a person", extra)])).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not repeat a steer the adapter sent while a turn was running", async () => {
|
||||
const { agent, userTexts } = createAgent();
|
||||
const input = new Pushable<any>();
|
||||
async function* generator() {
|
||||
const iterator = input[Symbol.asyncIterator]();
|
||||
const { value: prompt } = await iterator.next();
|
||||
yield echoOfAdapterMessage(prompt.uuid, "start the work");
|
||||
const { value: steer } = await iterator.next();
|
||||
yield echoOfAdapterMessage(steer.uuid, "also update the tests");
|
||||
yield successfulResultMessage();
|
||||
yield idle;
|
||||
}
|
||||
agent.sessions["test-session"] = mockSessionState({ query: wrapQuery(generator()), input });
|
||||
|
||||
const turn = agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "start the work" }],
|
||||
});
|
||||
await vi.waitFor(() => expect(agent.sessions["test-session"].turnQueue).toHaveLength(1));
|
||||
await agent.steer({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "also update the tests" }],
|
||||
});
|
||||
await turn;
|
||||
|
||||
expect(userTexts()).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not repeat the echo of a turn that settled before its echo came", async () => {
|
||||
const { agent, userTexts } = createAgent();
|
||||
const input = new Pushable<any>();
|
||||
async function* generator() {
|
||||
const { value: prompt } = await input[Symbol.asyncIterator]().next();
|
||||
yield successfulResultMessage();
|
||||
yield idle;
|
||||
yield echoOfAdapterMessage(prompt.uuid, "typed in Zed");
|
||||
}
|
||||
agent.sessions["test-session"] = mockSessionState({ query: wrapQuery(generator()), input });
|
||||
|
||||
await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "typed in Zed" }],
|
||||
});
|
||||
await agent.sessions["test-session"].consumer;
|
||||
|
||||
expect(userTexts()).toEqual([]);
|
||||
expect(agent.sessions["test-session"].sentUserUuids?.size ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it("forgets a sent uuid once its echo has come", async () => {
|
||||
const { agent } = createAgent();
|
||||
const input = new Pushable<any>();
|
||||
async function* generator() {
|
||||
const { value: prompt } = await input[Symbol.asyncIterator]().next();
|
||||
yield echoOfAdapterMessage(prompt.uuid, "typed in Zed");
|
||||
yield successfulResultMessage();
|
||||
yield idle;
|
||||
}
|
||||
agent.sessions["test-session"] = mockSessionState({ query: wrapQuery(generator()), input });
|
||||
|
||||
await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "typed in Zed" }],
|
||||
});
|
||||
|
||||
expect(agent.sessions["test-session"].sentUserUuids?.size ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
const text = (value: string) => ({ type: "text", text: value });
|
||||
const png = {
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "iVBORw0KGgo=" },
|
||||
};
|
||||
// The shape Claude Code delivered for a photo uploaded in the mobile app: one
|
||||
// message, one base64 JPEG block, no text.
|
||||
const photo = {
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/jpeg", data: "/9j/4AAQ" },
|
||||
};
|
||||
const urlImage = { type: "image", source: { type: "url", url: "https://example.com/a.png" } };
|
||||
const pdf = {
|
||||
type: "document",
|
||||
source: { type: "base64", media_type: "application/pdf", data: "JVBERi0=" },
|
||||
};
|
||||
|
||||
it.each([
|
||||
["text", [text("typed on the phone")], [text("typed on the phone")]],
|
||||
[
|
||||
"text and an image",
|
||||
[text("what is in this picture?"), png],
|
||||
[
|
||||
text("what is in this picture?"),
|
||||
{ type: "image", data: "iVBORw0KGgo=", mimeType: "image/png" },
|
||||
],
|
||||
],
|
||||
["a photo on its own", [photo], [{ type: "image", data: "/9j/4AAQ", mimeType: "image/jpeg" }]],
|
||||
[
|
||||
"only a url image",
|
||||
[urlImage],
|
||||
[expect.objectContaining({ type: "image", uri: "https://example.com/a.png" })],
|
||||
],
|
||||
[
|
||||
"text with a document, keeping the text",
|
||||
[text("summarize the attached file"), pdf],
|
||||
[text("summarize the attached file")],
|
||||
],
|
||||
[
|
||||
"text with command markup removed",
|
||||
[text("<command-name>/review</command-name>please look at this")],
|
||||
[text("please look at this")],
|
||||
],
|
||||
[
|
||||
"only command markup, skipping it",
|
||||
[text("<local-command-stdout>output</local-command-stdout>")],
|
||||
[],
|
||||
],
|
||||
])("shows %s sent from another client", async (_description, content, expected) => {
|
||||
const chunks = await promptWithChunks(() => [
|
||||
userReplayContent("phone-1", content, { origin: HUMAN }),
|
||||
]);
|
||||
|
||||
expect(chunks.map((chunk) => chunk.content)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user