diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 47203673..8eee9892 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -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; /** 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; @@ -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; } } diff --git a/src/clear-context-coordinator.ts b/src/clear-context-coordinator.ts index 36f76f36..06bb40bf 100644 --- a/src/clear-context-coordinator.ts +++ b/src/clear-context-coordinator.ts @@ -44,6 +44,7 @@ export type ClearContextSession; 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); diff --git a/src/tests/clear-context-coordinator.test.ts b/src/tests/clear-context-coordinator.test.ts index 72f060ce..50106ef6 100644 --- a/src/tests/clear-context-coordinator.test.ts +++ b/src/tests/clear-context-coordinator.test.ts @@ -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"); }); diff --git a/src/tests/remote-control.test.ts b/src/tests/remote-control.test.ts index 7859a3bd..49f37219 100644 --- a/src/tests/remote-control.test.ts +++ b/src/tests/remote-control.test.ts @@ -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 { + await new Promise(() => {}); + yield* []; +} + function createAgent(query: Record, sessionOverrides: Record = {}) { 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 }); diff --git a/src/tests/remote-user-messages.test.ts b/src/tests/remote-user-messages.test.ts new file mode 100644 index 00000000..0691cef9 --- /dev/null +++ b/src/tests/remote-user-messages.test.ts @@ -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 = {}) { + return userReplayContent(uuid, [{ type: "text", text }], extra); +} + +function userReplayContent(uuid: string, content: unknown[], extra: Record = {}) { + 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(); + 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(); + 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(); + 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(); + 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("/reviewplease look at this")], + [text("please look at this")], + ], + [ + "only command markup, skipping it", + [text("output")], + [], + ], + ])("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); + }); +});