diff --git a/.gitignore b/.gitignore index 3961778d5e..831b269065 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ qdrant_storage/ plans/ roo-cli-*.tar.gz* + +# Local Cursor agent rules/settings (not shared) +.cursor/ diff --git a/PRIVACY.md b/PRIVACY.md index f43760023d..7f535e2c6b 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Zoo Code Privacy Policy -**Last Updated: August 15th, 2026** +**Last Updated: October 21st, 2026** Zoo Code respects your privacy and is committed to transparency about how we handle your data. Below is a simple breakdown of where key pieces of data @@ -15,20 +15,30 @@ go—and, importantly, where they don't. provider (proxy mode), your code may transit Zoo Code servers only to forward it to the upstream provider. We do not store your code; it is deleted immediately after forwarding. Otherwise, your code is sent directly to the - provider. AI providers may store data per their privacy policies. + provider. AI providers may store data per their privacy policies. Remote + control, described below, is separate: that task transcript is stored for 7 + days. - **Commands**: Any commands executed through Zoo Code happen on your local environment. However, when you use AI-powered features, the relevant code and context from your commands may be transmitted to your chosen AI model provider - (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not have - access to or store this data, but AI providers may process it per their - privacy policies. + (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not store + that model-request context, but AI providers may process it per their privacy + policies. Remote control, described below, is separate: that task transcript + is stored for 7 days. - **Prompts & AI Requests**: When you use AI-powered features, your prompts and relevant project context are sent to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not store or process this data. These AI providers have their own privacy policies and may store data per their terms of service. If you choose Zoo Code Cloud as the provider (proxy mode), prompts may transit Zoo Code servers only to forward - them to the upstream model and are not stored. + them to the upstream model and are not stored. Remote control, described + below, is separate: that task transcript is stored for 7 days. +- **Remote control**: Turning on remote control is optional. When you do, Zoo + Code sends that task's conversation, the file contents included in the task, + and command output to your Zoo account. Zoo stores that transcript for 7 days + so you can view the task and approve, reply, or otherwise control it from the + Zoo dashboard, then deletes it. This storage is separate from model-provider + requests and from telemetry. Ending the remote session stops further uploads. - **API Keys & Credentials**: If you enter an API key (e.g., to connect an AI model), it is stored locally on your device and never sent to us or any third party, except the provider you have chosen. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e63979cf8b..b72dcc0434 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,3 +3,4 @@ export * from "./debug-log/index.js" export * from "./message-utils/index.js" export * from "./task-history/index.js" export * from "./worktree/index.js" +export * from "./zoo-remote-control/index.js" diff --git a/packages/core/src/zoo-remote-control/ZooRemoteControlClient.ts b/packages/core/src/zoo-remote-control/ZooRemoteControlClient.ts new file mode 100644 index 0000000000..7eac5d38de --- /dev/null +++ b/packages/core/src/zoo-remote-control/ZooRemoteControlClient.ts @@ -0,0 +1,124 @@ +import { partitionRemoteCommands } from "./bridgeHelpers.js" +import type { + ZooRemoteCommandAck, + ZooRemoteCommandsResponse, + ZooRemoteEvent, + ZooRemotePresence, + ZooRemoteSnapshot, +} from "./types.js" + +const REQUEST_TIMEOUT_MS = 30_000 + +export type ZooRemoteControlClientOptions = { + getBaseUrl: () => string + getSessionToken: () => string + fetchFn?: typeof fetch + log?: (message: string) => void +} + +/** + * Thin HTTP client for /api/extension/remote/* using the Zoo session token. + * Callers treat route-missing 404 as "server not ready" and back off. + * Ack 404 (command_not_found) must not use the same backoff path. + */ +export class ZooRemoteControlClient { + private readonly getBaseUrl: () => string + private readonly getSessionToken: () => string + private readonly fetchFn: typeof fetch + private readonly log: (message: string) => void + + constructor(options: ZooRemoteControlClientOptions) { + this.getBaseUrl = options.getBaseUrl + this.getSessionToken = options.getSessionToken + this.fetchFn = options.fetchFn ?? fetch + this.log = options.log ?? (() => {}) + } + + async postPresence(presence: ZooRemotePresence): Promise<{ ok: boolean; status: number }> { + return this.request("POST", "/api/extension/remote/presence", presence) + } + + async postEvents(instanceId: string, events: ZooRemoteEvent[]): Promise<{ ok: boolean; status: number }> { + return this.request("POST", "/api/extension/remote/events", { instanceId, events }) + } + + async postSnapshot(snapshot: ZooRemoteSnapshot): Promise<{ ok: boolean; status: number }> { + return this.request("POST", "/api/extension/remote/snapshot", snapshot) + } + + async getCommands(instanceId: string): Promise<{ + ok: boolean + status: number + body?: ZooRemoteCommandsResponse + }> { + const result = await this.requestJson( + "GET", + `/api/extension/remote/commands?instanceId=${encodeURIComponent(instanceId)}`, + undefined, + { parse: (data: unknown) => data }, + ) + if (!result.ok || result.body === undefined) return { ok: result.ok, status: result.status } + + const batch = partitionRemoteCommands(result.body) + if (!batch) { + this.log(`[ZooRemoteControl] GET /api/extension/remote/commands failed: invalid command envelope`) + return { ok: false, status: 0 } + } + return { ok: true, status: result.status, body: batch } + } + + async ackCommand(ack: ZooRemoteCommandAck): Promise<{ ok: boolean; status: number }> { + return this.request("POST", `/api/extension/remote/commands/${encodeURIComponent(ack.commandId)}/ack`, ack) + } + + private async request(method: string, path: string, body?: unknown): Promise<{ ok: boolean; status: number }> { + const result = await this.requestJson(method, path, body) + return { ok: result.ok, status: result.status } + } + + private async requestJson( + method: string, + path: string, + body: unknown | undefined, + schema?: { parse: (data: unknown) => T }, + ): Promise<{ ok: boolean; status: number; body?: T }> { + const token = this.getSessionToken() + if (!token) { + return { ok: false, status: 401 } + } + + const url = `${this.getBaseUrl().replace(/\/$/, "")}${path}` + + try { + const response = await this.fetchFn(url, { + method, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/json", + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }) + + if (!response.ok) { + if (response.status !== 404) { + this.log(`[ZooRemoteControl] ${method} ${path} -> ${response.status}`) + } + return { ok: false, status: response.status } + } + + if (!schema) { + return { ok: true, status: response.status } + } + + const json: unknown = await response.json() + return { ok: true, status: response.status, body: schema.parse(json) } + } catch (error) { + this.log( + `[ZooRemoteControl] ${method} ${path} failed: ${error instanceof Error ? error.message : String(error)}`, + ) + return { ok: false, status: 0 } + } + } +} diff --git a/packages/core/src/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts b/packages/core/src/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts new file mode 100644 index 0000000000..f510babe0d --- /dev/null +++ b/packages/core/src/zoo-remote-control/__tests__/ZooRemoteControlClient.spec.ts @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +import { providerIdentifiers } from "@roo-code/types" + +import { ZooRemoteControlClient } from "../ZooRemoteControlClient.js" + +describe("ZooRemoteControlClient", () => { + let fetchFn: ReturnType + let getSessionToken: ReturnType string>> + let getBaseUrl: ReturnType string>> + let log: ReturnType void>> + let client: ZooRemoteControlClient + + beforeEach(() => { + fetchFn = vi.fn() + getSessionToken = vi.fn(() => "zoo_ext_token") + getBaseUrl = vi.fn(() => "https://www.zoocode.dev/") + log = vi.fn() + client = new ZooRemoteControlClient({ + getBaseUrl, + getSessionToken, + fetchFn: fetchFn as unknown as typeof fetch, + log, + }) + }) + + it("returns 401 when no session token is present", async () => { + getSessionToken.mockReturnValue("") + const result = await client.postPresence({ + instanceId: "i1", + apiProvider: providerIdentifiers.zooGateway, + online: true, + }) + expect(result).toEqual({ ok: false, status: 401 }) + expect(fetchFn).not.toHaveBeenCalled() + }) + + it("attaches a 30s abort deadline to every request", async () => { + const timeoutSpy = vi.spyOn(AbortSignal, "timeout") + fetchFn.mockResolvedValue({ ok: true, status: 200 }) + await client.postPresence({ + instanceId: "i1", + apiProvider: providerIdentifiers.zooGateway, + online: true, + }) + const init = fetchFn.mock.calls[0]?.[1] as { signal?: AbortSignal } + expect(timeoutSpy).toHaveBeenCalledWith(30_000) + expect(init.signal).toBe(timeoutSpy.mock.results[0]?.value) + timeoutSpy.mockRestore() + }) + + it("posts presence with bearer auth", async () => { + fetchFn.mockResolvedValue({ ok: true, status: 200 }) + const presence = { + instanceId: "i1", + apiProvider: providerIdentifiers.zooGateway, + online: true, + } + const result = await client.postPresence(presence) + expect(result).toEqual({ ok: true, status: 200 }) + expect(fetchFn).toHaveBeenCalledWith( + "https://www.zoocode.dev/api/extension/remote/presence", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer zoo_ext_token", + "Content-Type": "application/json", + }), + body: JSON.stringify(presence), + }), + ) + }) + + it("posts events", async () => { + fetchFn.mockResolvedValue({ ok: true, status: 200 }) + const events = [{ type: "task_idle" as const, taskId: "t1", timestamp: 1 }] + await client.postEvents("inst-1", events) + expect(fetchFn).toHaveBeenCalledWith( + "https://www.zoocode.dev/api/extension/remote/events", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ instanceId: "inst-1", events }), + }), + ) + }) + + it("posts snapshots", async () => { + fetchFn.mockResolvedValue({ ok: true, status: 200 }) + const snapshot = { + instanceId: "inst-1", + taskId: "t1", + messages: [{ ts: 1, type: "say" as const, say: "text" as const, text: "hi" }], + mode: "code", + } + await client.postSnapshot(snapshot) + expect(fetchFn).toHaveBeenCalledWith( + "https://www.zoocode.dev/api/extension/remote/snapshot", + expect.objectContaining({ + method: "POST", + body: JSON.stringify(snapshot), + }), + ) + }) + + it("parses command list responses", async () => { + fetchFn.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + commands: [{ commandId: "c1", type: "message", payload: { text: "hi" } }], + }), + }) + const result = await client.getCommands("inst-1") + expect(result.ok).toBe(true) + expect(result.body?.commands).toHaveLength(1) + expect(result.body?.invalidCommandIds).toEqual([]) + expect(fetchFn).toHaveBeenCalledWith( + "https://www.zoocode.dev/api/extension/remote/commands?instanceId=inst-1", + expect.objectContaining({ method: "GET" }), + ) + }) + + it("keeps a valid command when another command in the batch is invalid", async () => { + fetchFn.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + commands: [ + { commandId: "bad", type: "not_a_real_command" }, + { commandId: "huge", type: "message", payload: { text: "x".repeat(100_001) } }, + { commandId: "good", type: "message", taskId: "t1", payload: { text: "hi" } }, + ], + }), + }) + const result = await client.getCommands("inst-1") + expect(result.ok).toBe(true) + expect(result.body?.commands.map((command) => command.commandId)).toEqual(["good"]) + expect(result.body?.invalidCommandIds).toEqual(["bad", "huge"]) + }) + + it("retains only the first 25 commands from an oversized batch", async () => { + fetchFn.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + commands: Array.from({ length: 26 }, (_, index) => ({ + commandId: `c${index}`, + type: "message", + payload: { text: "hi" }, + })), + }), + }) + const result = await client.getCommands("inst-1") + expect(result.ok).toBe(true) + expect(result.body?.commands).toHaveLength(25) + expect(result.body?.commands[24]?.commandId).toBe("c24") + expect(result.body?.invalidCommandIds).toEqual([]) + }) + + it("returns status 0 when getCommands JSON body is malformed", async () => { + fetchFn.mockResolvedValue({ + ok: true, + status: 200, + json: async () => { + throw new SyntaxError("Unexpected token") + }, + }) + const result = await client.getCommands("inst-1") + expect(result).toEqual({ ok: false, status: 0 }) + expect(log).toHaveBeenCalledWith(expect.stringContaining("Unexpected token")) + }) + + it("returns status 0 when the command envelope is not a list", async () => { + fetchFn.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ commands: "nope" }), + }) + const result = await client.getCommands("inst-1") + expect(result).toEqual({ ok: false, status: 0 }) + expect(log).toHaveBeenCalledWith(expect.stringContaining("invalid command envelope")) + }) + + it("acks commands", async () => { + fetchFn.mockResolvedValue({ ok: true, status: 200 }) + await client.ackCommand({ commandId: "c1", status: "acked" }) + expect(fetchFn).toHaveBeenCalledWith( + "https://www.zoocode.dev/api/extension/remote/commands/c1/ack", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ commandId: "c1", status: "acked" }), + }), + ) + }) + + it("returns non-ok status without throwing", async () => { + fetchFn.mockResolvedValue({ ok: false, status: 404 }) + const result = await client.getCommands("inst-1") + expect(result).toEqual({ ok: false, status: 404 }) + expect(log).not.toHaveBeenCalled() + }) + + it("logs non-404 failures", async () => { + fetchFn.mockResolvedValue({ ok: false, status: 500 }) + await client.postEvents("inst-1", []) + expect(log).toHaveBeenCalledWith(expect.stringContaining("500")) + }) + + it("returns status 0 when fetch throws", async () => { + fetchFn.mockRejectedValue(new Error("network down")) + const result = await client.postPresence({ + instanceId: "i1", + apiProvider: providerIdentifiers.zooGateway, + online: true, + }) + expect(result).toEqual({ ok: false, status: 0 }) + expect(log).toHaveBeenCalledWith(expect.stringContaining("network down")) + }) +}) diff --git a/packages/core/src/zoo-remote-control/__tests__/bridgeHelpers.spec.ts b/packages/core/src/zoo-remote-control/__tests__/bridgeHelpers.spec.ts new file mode 100644 index 0000000000..b39ffa712b --- /dev/null +++ b/packages/core/src/zoo-remote-control/__tests__/bridgeHelpers.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it } from "vitest" + +import { + classifyRemoteControlHttpResult, + generateZooRemoteInstanceId, + isRemoteAuthBlockedStatus, + isRemoteNotFoundStatus, + partialFlushDecision, + remoteMessageKey, + requeueRemoteEventsKeepingNewest, + resolveRemoteMessageAction, + blockingAskTimestamp, + partitionRemoteCommands, + resolveZooRemoteEventSeq, + workspaceNicknameFromFsPath, +} from "../bridgeHelpers.js" + +describe("bridgeHelpers", () => { + it("keeps valid commands when one entry in the batch is invalid", () => { + const batch = partitionRemoteCommands({ + commands: [ + { commandId: "bad", type: "nope" }, + { commandId: "good", type: "message", payload: { text: "hi" } }, + ], + }) + expect(batch?.commands.map((command) => command.commandId)).toEqual(["good"]) + expect(batch?.invalidCommandIds).toEqual(["bad"]) + expect(partitionRemoteCommands({ commands: "nope" })).toBeUndefined() + expect(partitionRemoteCommands(null)).toBeUndefined() + }) + + it("finds a blocking ask when a later say was appended", () => { + const messages = [ + { ts: 42, type: "ask", partial: false }, + { ts: 50, type: "say", partial: false }, + ] + expect(blockingAskTimestamp(messages, 42, false)).toBe(42) + expect(blockingAskTimestamp(messages, 42, true)).toBeUndefined() + expect(blockingAskTimestamp([{ ts: 42, type: "ask", partial: true }], 42, false)).toBeUndefined() + expect(blockingAskTimestamp(messages, 50, false)).toBeUndefined() + }) + + it("derives a nickname from posix and windows paths", () => { + expect(workspaceNicknameFromFsPath("/Users/me/proj/")).toBe("proj") + expect(workspaceNicknameFromFsPath("C:\\Users\\me\\proj\\")).toBe("proj") + expect(workspaceNicknameFromFsPath("///")).toBeUndefined() + expect(workspaceNicknameFromFsPath(undefined)).toBeUndefined() + }) + + it("prefers messageId for remote message keys", () => { + expect(remoteMessageKey("t1", { messageId: "m1", ts: 9 })).toBe("m1") + expect(remoteMessageKey("t1", { ts: 9 })).toBe("t1:9") + }) + + it("resolves message actions from explicit values or partial flag", () => { + expect(resolveRemoteMessageAction("created", true)).toBe("created") + expect(resolveRemoteMessageAction(undefined, true)).toBe("updated") + expect(resolveRemoteMessageAction(undefined, false)).toBe("created") + }) + + it("requeues failed events and keeps the newest under the cap", () => { + expect(requeueRemoteEventsKeepingNewest([1, 2], [3, 4], 3)).toEqual([2, 3, 4]) + }) + + it("decides when partial flushes should arm, wait, or flush", () => { + expect(partialFlushDecision(0, 100, 75)).toBe("arm") + expect(partialFlushDecision(100, 150, 75)).toBe("wait") + expect(partialFlushDecision(100, 200, 75)).toBe("flush") + }) + + it("classifies remote HTTP statuses", () => { + expect(isRemoteAuthBlockedStatus(401)).toBe(true) + expect(isRemoteAuthBlockedStatus(402)).toBe(true) + expect(isRemoteAuthBlockedStatus(403)).toBe(true) + expect(isRemoteAuthBlockedStatus(404)).toBe(false) + expect(isRemoteAuthBlockedStatus(undefined)).toBe(false) + expect(isRemoteNotFoundStatus(404)).toBe(true) + expect(isRemoteNotFoundStatus(500)).toBe(false) + expect(isRemoteNotFoundStatus(undefined)).toBe(false) + expect(classifyRemoteControlHttpResult({ ok: false, status: 401 })).toBe("auth_blocked") + expect(classifyRemoteControlHttpResult({ ok: false, status: 404 })).toBe("unavailable") + expect(classifyRemoteControlHttpResult({ ok: true, status: 200 })).toBe("success") + expect(classifyRemoteControlHttpResult({ ok: false, status: 500 })).toBe("failed") + }) + + it("normalizes event seq and instance ids", () => { + expect(resolveZooRemoteEventSeq(undefined)).toBe(0) + expect(resolveZooRemoteEventSeq(-1)).toBe(0) + expect(resolveZooRemoteEventSeq(12)).toBe(12) + expect(generateZooRemoteInstanceId(1, "sess-1")).toBe("sess-1") + expect(generateZooRemoteInstanceId(1, undefined)).toMatch(/^zoo-1-/) + }) +}) diff --git a/packages/core/src/zoo-remote-control/__tests__/eligibility.spec.ts b/packages/core/src/zoo-remote-control/__tests__/eligibility.spec.ts new file mode 100644 index 0000000000..26d2a757d7 --- /dev/null +++ b/packages/core/src/zoo-remote-control/__tests__/eligibility.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest" + +import { providerIdentifiers } from "@roo-code/types" + +import { isZooRemoteControlEligible } from "../eligibility.js" + +describe("isZooRemoteControlEligible", () => { + it("returns true when opted in with an eligible Zoo session", () => { + expect( + isZooRemoteControlEligible({ + remoteControlEnabled: true, + apiProvider: providerIdentifiers.zooGateway, + sessionToken: "zoo_ext_test", + }), + ).toBe(true) + }) + + it("returns false when the setting is off", () => { + expect( + isZooRemoteControlEligible({ + remoteControlEnabled: false, + apiProvider: providerIdentifiers.zooGateway, + sessionToken: "zoo_ext_test", + }), + ).toBe(false) + }) + + it("returns false when the active provider is not eligible", () => { + expect( + isZooRemoteControlEligible({ + remoteControlEnabled: true, + apiProvider: providerIdentifiers.openrouter, + sessionToken: "zoo_ext_test", + }), + ).toBe(false) + }) + + it("returns false when the session token is missing", () => { + expect( + isZooRemoteControlEligible({ + remoteControlEnabled: true, + apiProvider: providerIdentifiers.zooGateway, + sessionToken: undefined, + }), + ).toBe(false) + + expect( + isZooRemoteControlEligible({ + remoteControlEnabled: true, + apiProvider: providerIdentifiers.zooGateway, + sessionToken: "", + }), + ).toBe(false) + }) +}) diff --git a/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts b/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts new file mode 100644 index 0000000000..24aeef4cd7 --- /dev/null +++ b/packages/core/src/zoo-remote-control/__tests__/executeRemoteCommand.spec.ts @@ -0,0 +1,584 @@ +import { describe, expect, it, vi } from "vitest" + +import { executeRemoteCommand } from "../executeRemoteCommand.js" +import type { ZooRemoteCommandHost, ZooRemoteCommandHostTask } from "../executeRemoteCommand.js" + +function liveTask(overrides: Partial = {}): ZooRemoteCommandHostTask { + return { + taskId: "t1", + hasPendingAsk: () => false, + pendingAskTs: () => undefined, + approveAsk: vi.fn(), + denyAsk: vi.fn(), + submitMessage: vi.fn().mockResolvedValue(undefined), + ...overrides, + } +} + +function makeHost(overrides: Partial = {}): ZooRemoteCommandHost { + return { + isLifecycleCurrent: () => true, + isEligible: () => true, + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + getCurrentTask: () => undefined, + cancelCurrentTask: vi.fn().mockResolvedValue(undefined), + resumeTask: vi.fn().mockResolvedValue(undefined), + startNewTask: vi.fn().mockResolvedValue(undefined), + endRemoteSession: vi.fn().mockResolvedValue(undefined), + ...overrides, + } +} + +describe("executeRemoteCommand", () => { + it("rejects a message that would answer a different pending ask", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const task = liveTask({ hasPendingAsk: () => true, pendingAskTs: () => 42 }) + const host = makeHost({ + getCurrentTask: () => task, + }) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "message", taskId: "t1", payload: { text: "late", askTs: 7 } }, + epoch: 1, + client: { ackCommand }, + host, + }) + + expect(task.submitMessage).not.toHaveBeenCalled() + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "ask_mismatch", + }) + }) + + it("rejects a follow-up that does not name the pending ask", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const task = liveTask({ hasPendingAsk: () => true, pendingAskTs: () => 42 }) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "answer_followup", taskId: "t1", payload: { text: "later" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ + getCurrentTask: () => task, + }), + }) + + expect(task.submitMessage).not.toHaveBeenCalled() + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "missing_ask_id", + }) + }) + + it("acks and sends a message when eligible and lifecycle is current", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const task = liveTask() + + await executeRemoteCommand({ + command: { commandId: "c1", type: "message", taskId: "t1", payload: { text: "hi" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ getCurrentTask: () => task }), + }) + + expect(task.submitMessage).toHaveBeenCalledWith("hi", undefined) + expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + }) + + it("delivers a message to the task that was validated when the current task changes", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const first = liveTask({ taskId: "t1" }) + const second = liveTask({ taskId: "t2" }) + let reads = 0 + + await executeRemoteCommand({ + command: { commandId: "c1", type: "message", taskId: "t1", payload: { text: "hi" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ + getCurrentTask: () => { + reads += 1 + return reads === 1 ? first : second + }, + }), + }) + + expect(first.submitMessage).toHaveBeenCalledWith("hi", undefined) + expect(second.submitMessage).not.toHaveBeenCalled() + expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + }) + + it("switches mode after sending a message when payload.mode is set", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const handleModeSwitch = vi.fn().mockResolvedValue(undefined) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "message", taskId: "t1", payload: { text: "hi", mode: "code" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ handleModeSwitch, getCurrentTask: () => liveTask() }), + }) + + expect(handleModeSwitch).toHaveBeenCalledWith("code") + expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + }) + + it("skips host actions when lifecycle is stale", async () => { + const ackCommand = vi.fn() + + await executeRemoteCommand({ + command: { commandId: "c1", type: "message", payload: { text: "hi" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ + isLifecycleCurrent: () => false, + }), + }) + + expect(ackCommand).not.toHaveBeenCalled() + }) + + it("acks not_eligible when the host is ineligible", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "message", payload: { text: "hi" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ isEligible: () => false }), + }) + + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "not_eligible", + }) + }) + + it("acks missing_task_id when approve_ask omits the task id", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const approveAsk = vi.fn() + + await executeRemoteCommand({ + command: { commandId: "c1", type: "approve_ask", payload: { askTs: 1 } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ + getCurrentTask: () => liveTask({ hasPendingAsk: () => true, pendingAskTs: () => 1, approveAsk }), + }), + }) + + expect(approveAsk).not.toHaveBeenCalled() + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "missing_task_id", + }) + }) + + it("acks no_pending_ask when approve_ask has no pending ask", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const approveAsk = vi.fn() + + await executeRemoteCommand({ + command: { commandId: "c1", type: "approve_ask", taskId: "t1", payload: { askTs: 1 } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ + getCurrentTask: () => liveTask({ approveAsk }), + }), + }) + + expect(approveAsk).not.toHaveBeenCalled() + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "no_pending_ask", + }) + }) + + it("acks task_mismatch when command.taskId does not match the live task", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const cancelCurrentTask = vi.fn() + + await executeRemoteCommand({ + command: { commandId: "c1", type: "cancel_task", taskId: "other" }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ + getCurrentTask: () => liveTask(), + cancelCurrentTask, + }), + }) + + expect(cancelCurrentTask).not.toHaveBeenCalled() + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "task_mismatch", + }) + }) + + it("acks missing_task_id for resume_task without a task id", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "resume_task" }, + epoch: 1, + client: { ackCommand }, + host: makeHost(), + }) + + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "missing_task_id", + }) + }) + + it("keeps the claim when a follow-up mode switch fails after the message lands", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const task = liveTask() + const handleModeSwitch = vi.fn().mockRejectedValue(new Error("bad mode")) + const handledCommandIds = new Set() + const command = { + commandId: "c1", + type: "message" as const, + taskId: "t1", + payload: { text: "hi", mode: "code" }, + } + const host = makeHost({ handleModeSwitch, getCurrentTask: () => task }) + + await executeRemoteCommand({ command, epoch: 1, client: { ackCommand }, host, handledCommandIds }) + await executeRemoteCommand({ command, epoch: 1, client: { ackCommand }, host, handledCommandIds }) + + expect(task.submitMessage).toHaveBeenCalledTimes(1) + expect(ackCommand).toHaveBeenNthCalledWith(1, { + commandId: "c1", + status: "failed", + errorCode: "mode_switch_failed", + message: "bad mode", + }) + expect(ackCommand).toHaveBeenNthCalledWith(2, { commandId: "c1", status: "acked" }) + }) + + it("acks end_remote_session and leaves the remote session", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const endRemoteSession = vi.fn().mockResolvedValue(undefined) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "end_remote_session" }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ endRemoteSession }), + }) + + expect(endRemoteSession).toHaveBeenCalled() + expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + }) + + it("acks end_remote_session after the host invalidates the lifecycle", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + let current = true + const endRemoteSession = vi.fn(async () => { + current = false + }) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "end_remote_session" }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ endRemoteSession, isLifecycleCurrent: () => current }), + }) + + expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + }) + + it("does not ack end_remote_session when the host rejects", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "end_remote_session" }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ endRemoteSession: vi.fn().mockRejectedValue(new Error("nope")) }), + }) + + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "execution_failed", + message: "nope", + }) + }) + + it("acks missing_mode for switch_mode without a mode", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "switch_mode", payload: {} }, + epoch: 1, + client: { ackCommand }, + host: makeHost(), + }) + + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "missing_mode", + }) + }) + + it("acks execution_failed when the host throws", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + + const task = liveTask({ submitMessage: vi.fn().mockRejectedValue(new Error("boom")) }) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "message", taskId: "t1", payload: { text: "hi" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ getCurrentTask: () => task }), + }) + + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "execution_failed", + message: "boom", + }) + }) + + it("approves a pending ask and acks", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const approveAsk = vi.fn() + + await executeRemoteCommand({ + command: { commandId: "c1", type: "approve_ask", taskId: "t1", payload: { text: "ok", askTs: 42 } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ + getCurrentTask: () => liveTask({ hasPendingAsk: () => true, pendingAskTs: () => 42, approveAsk }), + }), + }) + + expect(approveAsk).toHaveBeenCalledWith({ text: "ok", images: undefined }) + expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + }) + + it("denies a pending ask and acks", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const denyAsk = vi.fn() + + await executeRemoteCommand({ + command: { commandId: "c1", type: "deny_ask", taskId: "t1", payload: { text: "no", askTs: 42 } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ + getCurrentTask: () => liveTask({ hasPendingAsk: () => true, pendingAskTs: () => 42, denyAsk }), + }), + }) + + expect(denyAsk).toHaveBeenCalledWith({ text: "no", images: undefined }) + expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + }) + + it("cancels the current task when taskId matches", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const cancelCurrentTask = vi.fn().mockResolvedValue(undefined) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "cancel_task", taskId: "t1" }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ + getCurrentTask: () => liveTask(), + cancelCurrentTask, + }), + }) + + expect(cancelCurrentTask).toHaveBeenCalledOnce() + expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + }) + + it("resumes a task by id", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const resumeTask = vi.fn().mockResolvedValue(undefined) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "resume_task", taskId: "t9" }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ resumeTask }), + }) + + expect(resumeTask).toHaveBeenCalledWith("t9") + expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + }) + + it("starts a new task", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const startNewTask = vi.fn().mockResolvedValue(undefined) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "start_task", payload: { text: "go" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ startNewTask }), + }) + + expect(startNewTask).toHaveBeenCalledWith({ text: "go", images: undefined }) + expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + }) + + it("switches mode", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const handleModeSwitch = vi.fn().mockResolvedValue(undefined) + + await executeRemoteCommand({ + command: { commandId: "c1", type: "switch_mode", payload: { mode: "architect" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ handleModeSwitch }), + }) + + expect(handleModeSwitch).toHaveBeenCalledWith("architect") + expect(ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + }) + + it("acks duplicate commandIds without re-running the host action", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + let markStarted: () => void = () => {} + const started = new Promise((resolve) => { + markStarted = resolve + }) + let releaseStart: () => void = () => {} + const canFinish = new Promise((resolve) => { + releaseStart = resolve + }) + const startNewTask = vi.fn(async () => { + markStarted() + await canFinish + }) + const handledCommandIds = new Set() + + const firstExecution = executeRemoteCommand({ + command: { commandId: "dup", type: "start_task", payload: { text: "first" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ startNewTask }), + handledCommandIds, + }) + await started + const secondExecution = executeRemoteCommand({ + command: { commandId: "dup", type: "start_task", payload: { text: "again" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ startNewTask }), + handledCommandIds, + }) + + expect(startNewTask).toHaveBeenCalledTimes(1) + releaseStart() + await Promise.all([firstExecution, secondExecution]) + expect(ackCommand).toHaveBeenCalledTimes(2) + expect(ackCommand).toHaveBeenLastCalledWith({ commandId: "dup", status: "acked" }) + }) + + it("releases a failed commandId so redelivery can retry", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const startNewTask = vi.fn().mockRejectedValueOnce(new Error("boom")).mockResolvedValueOnce(undefined) + const handledCommandIds = new Set() + + await executeRemoteCommand({ + command: { commandId: "retry-me", type: "start_task", payload: { text: "go" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ startNewTask }), + handledCommandIds, + }) + expect(handledCommandIds.has("retry-me")).toBe(false) + expect(ackCommand).toHaveBeenLastCalledWith({ + commandId: "retry-me", + status: "failed", + errorCode: "execution_failed", + message: "boom", + }) + + await executeRemoteCommand({ + command: { commandId: "retry-me", type: "start_task", payload: { text: "go" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ startNewTask }), + handledCommandIds, + }) + + expect(startNewTask).toHaveBeenCalledTimes(2) + expect(handledCommandIds.has("retry-me")).toBe(true) + expect(ackCommand).toHaveBeenLastCalledWith({ commandId: "retry-me", status: "acked" }) + }) + + it("acks missing_task_id for a message that does not name a task", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const task = liveTask() + + await executeRemoteCommand({ + command: { commandId: "c1", type: "message", payload: { text: "hi" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ getCurrentTask: () => task }), + }) + + expect(task.submitMessage).not.toHaveBeenCalled() + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "missing_task_id", + }) + }) + + it("acks missing_ask_id when approve_ask omits the ask timestamp", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const approveAsk = vi.fn() + + await executeRemoteCommand({ + command: { commandId: "c1", type: "approve_ask", taskId: "t1", payload: { text: "ok" } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ + getCurrentTask: () => liveTask({ hasPendingAsk: () => true, pendingAskTs: () => 42, approveAsk }), + }), + }) + + expect(approveAsk).not.toHaveBeenCalled() + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "missing_ask_id", + }) + }) + + it("acks ask_mismatch when approve_ask names a different ask", async () => { + const ackCommand = vi.fn().mockResolvedValue({ ok: true, status: 200 }) + const approveAsk = vi.fn() + + await executeRemoteCommand({ + command: { commandId: "c1", type: "approve_ask", taskId: "t1", payload: { askTs: 7 } }, + epoch: 1, + client: { ackCommand }, + host: makeHost({ + getCurrentTask: () => liveTask({ hasPendingAsk: () => true, pendingAskTs: () => 42, approveAsk }), + }), + }) + + expect(approveAsk).not.toHaveBeenCalled() + expect(ackCommand).toHaveBeenCalledWith({ + commandId: "c1", + status: "failed", + errorCode: "ask_mismatch", + }) + }) +}) diff --git a/packages/core/src/zoo-remote-control/__tests__/projectMessage.spec.ts b/packages/core/src/zoo-remote-control/__tests__/projectMessage.spec.ts new file mode 100644 index 0000000000..2fb609db45 --- /dev/null +++ b/packages/core/src/zoo-remote-control/__tests__/projectMessage.spec.ts @@ -0,0 +1,235 @@ +import { describe, expect, it } from "vitest" + +import { + projectClineMessageForRemote, + projectClineMessagesForSnapshot, + REMOTE_MESSAGE_IMAGE_MAX_CHARS, + REMOTE_MESSAGE_IMAGE_MAX_COUNT, + REMOTE_MESSAGE_JSON_MAX_CHARS, + REMOTE_MESSAGE_TEXT_MAX_CHARS, + REMOTE_SNAPSHOT_MAX_MESSAGES, +} from "../projectMessage.js" + +describe("projectClineMessageForRemote", () => { + it("truncates oversized tool diff payloads while keeping path metadata", () => { + const huge = "x".repeat(60_000) + const shaped = projectClineMessageForRemote({ + ts: 1, + type: "ask", + ask: "tool", + text: JSON.stringify({ + tool: "appliedDiff", + path: "src/a.ts", + diff: huge, + diffStats: { added: 1, removed: 0 }, + }), + }) + + expect(shaped.text).toBeTruthy() + const parsed = JSON.parse(shaped.text!) as { + path: string + diff: string + diffStats: { added: number } + } + expect(parsed.path).toBe("src/a.ts") + expect(parsed.diffStats.added).toBe(1) + expect(parsed.diff.length).toBeLessThan(huge.length) + expect(parsed.diff).toContain("[truncated") + }) + + it("shapes say:tool, command, and use_mcp_server asks", () => { + const huge = "y".repeat(REMOTE_MESSAGE_TEXT_MAX_CHARS + 100) + for (const message of [ + { + ts: 1, + type: "say" as const, + say: "tool" as const, + text: JSON.stringify({ content: huge, code: huge }), + }, + { + ts: 2, + type: "ask" as const, + ask: "command" as const, + text: JSON.stringify({ result: huge }), + }, + { + ts: 3, + type: "ask" as const, + ask: "use_mcp_server" as const, + text: JSON.stringify({ originalContent: huge }), + }, + ]) { + const shaped = projectClineMessageForRemote(message) + const parsed = JSON.parse(shaped.text!) as Record + for (const value of Object.values(parsed)) { + if (typeof value === "string" && value.length > 100) { + expect(value.length).toBeLessThanOrEqual(REMOTE_MESSAGE_TEXT_MAX_CHARS + 40) + expect(value).toContain("[truncated") + } + } + } + }) + + it("truncates non-JSON tool text and invalid JSON arrays/objects", () => { + const huge = "z".repeat(REMOTE_MESSAGE_TEXT_MAX_CHARS + 50) + expect(projectClineMessageForRemote({ ts: 1, type: "ask", ask: "tool", text: huge }).text).toContain( + "[truncated", + ) + expect( + projectClineMessageForRemote({ + ts: 2, + type: "ask", + ask: "tool", + text: JSON.stringify(["not", "an", "object"]), + }).text, + ).toBe(JSON.stringify(["not", "an", "object"])) + expect( + projectClineMessageForRemote({ + ts: 3, + type: "ask", + ask: "tool", + text: `{${"n".repeat(REMOTE_MESSAGE_TEXT_MAX_CHARS + 20)}`, + }).text, + ).toContain("[truncated") + }) + + it("enforces an aggregate serialized character budget across many large fields", () => { + const chunk = "z".repeat(20_000) + const shaped = projectClineMessageForRemote({ + ts: 1, + type: "ask", + ask: "tool", + text: JSON.stringify({ + a: chunk, + b: chunk, + c: chunk, + d: chunk, + e: chunk, + f: chunk, + }), + }) + expect(shaped.text!.length).toBeLessThanOrEqual(REMOTE_MESSAGE_JSON_MAX_CHARS) + expect(JSON.parse(shaped.text!)).toEqual(expect.any(Object)) + }) + + it("keeps valid JSON when a wide tool object is still over budget after string shrinking", () => { + const fields: Record = {} + for (let i = 0; i < 200; i++) { + fields[`k${i}`] = "z".repeat(600) + } + const shaped = projectClineMessageForRemote({ + ts: 1, + type: "ask", + ask: "tool", + text: JSON.stringify(fields), + }) + expect(shaped.text!.length).toBeLessThanOrEqual(REMOTE_MESSAGE_JSON_MAX_CHARS) + expect(JSON.parse(shaped.text!)).toEqual(expect.any(Object)) + }) + + it("recursively truncates nested tool JSON strings beyond top-level keys", () => { + const huge = "n".repeat(REMOTE_MESSAGE_TEXT_MAX_CHARS + 80) + const shaped = projectClineMessageForRemote({ + ts: 1, + type: "ask", + ask: "tool", + text: JSON.stringify({ + tool: "updateTodoList", + path: "src/a.ts", + meta: { + summary: huge, + nested: { note: huge, keep: "ok" }, + }, + todos: [ + { id: "1", content: huge }, + { id: "2", content: "short" }, + ], + }), + }) + const parsed = JSON.parse(shaped.text!) as { + path: string + meta: { summary: string; nested: { note: string; keep: string } } + todos: Array<{ id: string; content: string }> + } + expect(parsed.path).toBe("src/a.ts") + expect(parsed.meta.nested.keep).toBe("ok") + expect(parsed.meta.summary).toContain("[truncated") + expect(parsed.meta.nested.note).toContain("[truncated") + expect(parsed.todos[0]?.content).toContain("[truncated") + expect(parsed.todos[1]?.content).toBe("short") + expect(parsed.meta.summary.length).toBeLessThanOrEqual(REMOTE_MESSAGE_TEXT_MAX_CHARS + 40) + }) + + it("shapes batchDiffs entries and leaves non-object batch entries untouched", () => { + const huge = "d".repeat(REMOTE_MESSAGE_TEXT_MAX_CHARS + 10) + const shaped = projectClineMessageForRemote({ + ts: 1, + type: "ask", + ask: "tool", + text: JSON.stringify({ + batchDiffs: [ + { path: "a.ts", diff: huge, content: huge }, + "skip-me", + null, + { path: "b.ts", note: "ok" }, + ...Array.from({ length: 25 }, (_, i) => ({ path: `x${i}.ts`, diff: "small" })), + ], + }), + }) + const parsed = JSON.parse(shaped.text!) as { + batchDiffs: Array + } + expect(parsed.batchDiffs).toHaveLength(20) + expect(parsed.batchDiffs[0]).toMatchObject({ path: "a.ts" }) + expect((parsed.batchDiffs[0] as { diff: string }).diff).toContain("[truncated") + expect((parsed.batchDiffs[0] as { content: string }).content).toContain("[truncated") + expect(parsed.batchDiffs[1]).toBe("skip-me") + expect(parsed.batchDiffs[2]).toBeNull() + }) + + it("truncates plain text, reasoning, and image payloads", () => { + const hugeText = "t".repeat(REMOTE_MESSAGE_TEXT_MAX_CHARS + 20) + const hugeImage = "i".repeat(REMOTE_MESSAGE_IMAGE_MAX_CHARS + 20) + const shaped = projectClineMessageForRemote({ + ts: 1, + type: "say", + say: "text", + text: hugeText, + reasoning: hugeText, + images: [hugeImage, hugeImage, hugeImage], + }) + expect(shaped.text).toContain("[truncated") + expect(shaped.reasoning).toContain("[truncated") + expect(shaped.images).toHaveLength(REMOTE_MESSAGE_IMAGE_MAX_COUNT) + expect(shaped.images?.[0]).toContain("[truncated") + }) + + it("leaves short non-tool messages unchanged", () => { + const shaped = projectClineMessageForRemote({ + ts: 1, + type: "say", + say: "text", + text: "hello", + reasoning: "think", + images: ["img"], + }) + expect(shaped).toMatchObject({ + text: "hello", + reasoning: "think", + images: ["img"], + }) + }) + + it("limits snapshot windows", () => { + const messages = Array.from({ length: REMOTE_SNAPSHOT_MAX_MESSAGES + 40 }, (_, i) => ({ + ts: i, + type: "say" as const, + say: "text" as const, + text: `m${i}`, + })) + const window = projectClineMessagesForSnapshot(messages) + expect(window).toHaveLength(REMOTE_SNAPSHOT_MAX_MESSAGES) + expect(window[0]?.text).toBe(`m${40}`) + expect(window.at(-1)?.text).toBe(`m${REMOTE_SNAPSHOT_MAX_MESSAGES + 39}`) + }) +}) diff --git a/packages/core/src/zoo-remote-control/availability.ts b/packages/core/src/zoo-remote-control/availability.ts new file mode 100644 index 0000000000..be053ec4d8 --- /dev/null +++ b/packages/core/src/zoo-remote-control/availability.ts @@ -0,0 +1,10 @@ +let remoteControlAvailable = false + +/** Extension bridge publishes reachability; webview state reads it. */ +export function setZooRemoteControlAvailable(available: boolean): void { + remoteControlAvailable = available +} + +export function getZooRemoteControlAvailable(): boolean { + return remoteControlAvailable +} diff --git a/packages/core/src/zoo-remote-control/bridgeHelpers.ts b/packages/core/src/zoo-remote-control/bridgeHelpers.ts new file mode 100644 index 0000000000..49da0b5e24 --- /dev/null +++ b/packages/core/src/zoo-remote-control/bridgeHelpers.ts @@ -0,0 +1,136 @@ +/** Nickname-only workspace label — never an absolute disk path. */ +export function workspaceNicknameFromFsPath(workspaceRoot: string | undefined) { + if (!workspaceRoot) { + return undefined + } + + // Avoid trailing-separator regexes (CodeQL js/polynomial-redos on long `/` runs). + let end = workspaceRoot.length + while (end > 0) { + const code = workspaceRoot.charCodeAt(end - 1) + if (code !== 47 /* / */ && code !== 92 /* \ */) { + break + } + end -= 1 + } + if (end === 0) { + return undefined + } + + let start = 0 + for (let i = end - 1; i >= 0; i -= 1) { + const code = workspaceRoot.charCodeAt(i) + if (code === 47 || code === 92) { + start = i + 1 + break + } + } + + const nickname = workspaceRoot.slice(start, end) + return nickname.length > 0 ? nickname : undefined +} + +export function remoteMessageKey(taskId: string, message: { messageId?: string; ts: number }) { + return message.messageId ?? `${taskId}:${message.ts}` +} + +export function resolveRemoteMessageAction( + action: string | undefined, + partial: boolean | undefined, +): "created" | "updated" { + if (action === "created" || action === "updated") { + return action + } + + return partial ? "updated" : "created" +} + +/** Keep failed batch ahead of newer events; retain the newest `max` under pressure. */ +export function requeueRemoteEventsKeepingNewest(failedBatch: T[], pending: T[], max = 200) { + return [...failedBatch, ...pending].slice(-max) +} + +export function partialFlushDecision( + lastPartialFlushAt: number, + now: number, + minIntervalMs: number, +): "arm" | "wait" | "flush" { + if (lastPartialFlushAt === 0) { + return "arm" + } + + return now - lastPartialFlushAt < minIntervalMs ? "wait" : "flush" +} + +export function isRemoteAuthBlockedStatus(status: number | undefined) { + return status === 401 || status === 402 || status === 403 +} + +export function isRemoteNotFoundStatus(status: number | undefined) { + return status === 404 +} + +export function resolveZooRemoteEventSeq(existing: number | undefined) { + return typeof existing === "number" && Number.isFinite(existing) && existing >= 0 ? existing : 0 +} + +import { zooRemoteCommandSchema, type ZooRemoteCommand, type ZooRemoteCommandsResponse } from "./types.js" + +/** How many commands one poll may retain. The rest stay queued for the next poll. */ +export const MAX_REMOTE_COMMAND_BATCH = 25 + +/** + * Validate each command on its own. One invalid entry must not drop the rest of the batch, + * because the server has already claimed the whole poll and will only release what we ack. + */ +export function partitionRemoteCommands(data: unknown): ZooRemoteCommandsResponse | undefined { + if (!data || typeof data !== "object" || Array.isArray(data) || !("commands" in data)) return undefined + const entries = data.commands ?? [] + if (!Array.isArray(entries)) return undefined + + const commands: ZooRemoteCommand[] = [] + const invalidCommandIds: string[] = [] + for (const entry of entries.slice(0, MAX_REMOTE_COMMAND_BATCH)) { + const parsed = zooRemoteCommandSchema.safeParse(entry) + if (parsed.success) { + commands.push(parsed.data) + continue + } + const commandId = entry && typeof entry === "object" && "commandId" in entry ? entry.commandId : undefined + if (typeof commandId === "string" && commandId.length > 0 && commandId.length <= 200) { + invalidCommandIds.push(commandId) + } + } + return { commands, invalidCommandIds } +} + +/** Timestamp of the ask `lastMessageTs` still points at, even if a later say was appended. */ +export function blockingAskTimestamp( + messages: ReadonlyArray<{ ts?: number; type?: string; partial?: boolean }>, + lastMessageTs: number | undefined, + settled: boolean, +): number | undefined { + if (settled || typeof lastMessageTs !== "number") return undefined + const blocking = messages.find((message) => message.ts === lastMessageTs) + if (!blocking || blocking.type !== "ask" || blocking.partial === true) return undefined + return lastMessageTs +} + +export function generateZooRemoteInstanceId(now: number, sessionId: string | undefined) { + if (typeof sessionId === "string" && sessionId.length > 0) { + return sessionId + } + return `zoo-${now}-${Math.random().toString(36).slice(2, 10)}` +} + +export type RemoteControlHttpKind = "auth_blocked" | "unavailable" | "success" | "failed" + +export function classifyRemoteControlHttpResult(result: { ok: boolean; status?: number }): RemoteControlHttpKind { + if (isRemoteAuthBlockedStatus(result.status)) { + return "auth_blocked" + } + if (isRemoteNotFoundStatus(result.status)) { + return "unavailable" + } + return result.ok ? "success" : "failed" +} diff --git a/packages/core/src/zoo-remote-control/eligibility.ts b/packages/core/src/zoo-remote-control/eligibility.ts new file mode 100644 index 0000000000..f7985a827b --- /dev/null +++ b/packages/core/src/zoo-remote-control/eligibility.ts @@ -0,0 +1,24 @@ +import { providerIdentifiers } from "@roo-code/types" + +export type ZooRemoteControlEligibilityInput = { + remoteControlEnabled: boolean + apiProvider: string | undefined + sessionToken: string | undefined +} + +/** True when remote control is opted in and the session is eligible. */ +export function isZooRemoteControlEligible({ + remoteControlEnabled, + apiProvider, + sessionToken, +}: ZooRemoteControlEligibilityInput): boolean { + if (!remoteControlEnabled) { + return false + } + + if (apiProvider !== providerIdentifiers.zooGateway) { + return false + } + + return Boolean(sessionToken && sessionToken.length > 0) +} diff --git a/packages/core/src/zoo-remote-control/executeRemoteCommand.ts b/packages/core/src/zoo-remote-control/executeRemoteCommand.ts new file mode 100644 index 0000000000..40ef35254f --- /dev/null +++ b/packages/core/src/zoo-remote-control/executeRemoteCommand.ts @@ -0,0 +1,270 @@ +import type { ZooRemoteCommand } from "./types.js" +import type { ZooRemoteControlClient } from "./ZooRemoteControlClient.js" + +export type ZooRemoteCommandHostTask = { + taskId: string + hasPendingAsk: () => boolean + /** Timestamp of the ask currently blocking the task, when one is pending. */ + pendingAskTs: () => number | undefined + approveAsk: (payload?: { text?: string; images?: string[] }) => void + denyAsk: (payload?: { text?: string; images?: string[] }) => void + /** Deliver text on this task. Call it on the object returned by the same getCurrentTask read. */ + submitMessage: (text: string, images?: string[]) => Promise +} + +export type ZooRemoteCommandHost = { + isLifecycleCurrent: (epoch: number) => boolean + isEligible: () => boolean + handleModeSwitch: (mode: string) => Promise + getCurrentTask: () => ZooRemoteCommandHostTask | undefined + cancelCurrentTask: () => Promise + resumeTask: (taskId: string) => Promise + startNewTask: (payload: { text?: string; images?: string[] }) => Promise + /** Leave remote session without cancelling the task. */ + endRemoteSession: () => Promise +} + +async function failCommand( + client: Pick, + host: ZooRemoteCommandHost, + epoch: number, + commandId: string, + errorCode: string, + message?: string, +): Promise { + if (!host.isLifecycleCurrent(epoch)) { + return + } + await client.ackCommand({ + commandId, + status: "failed", + errorCode, + ...(message ? { message } : {}), + }) +} + +type TaskBind = { task: ZooRemoteCommandHostTask } | { errorCode: string } + +/** One synchronous read. Callers mutate `task` before their next await. */ +function bindTask(host: ZooRemoteCommandHost, command: ZooRemoteCommand): TaskBind { + if (!command.taskId) { + return { errorCode: "missing_task_id" } + } + const task = host.getCurrentTask() + if (task?.taskId !== command.taskId) { + return { errorCode: "task_mismatch" } + } + return { task } +} + +/** A message that can answer an ask must name that ask, so a late delivery cannot hit a newer one. */ +function messageAskError(task: ZooRemoteCommandHostTask, command: ZooRemoteCommand): string | undefined { + const pending = task.hasPendingAsk() + if (!pending) { + return command.type === "answer_followup" ? "no_pending_ask" : undefined + } + const askTs = command.payload?.askTs + if (typeof askTs !== "number") { + return "missing_ask_id" + } + if (task.pendingAskTs() !== askTs) { + return "ask_mismatch" + } + return undefined +} + +function approvalAskError(task: ZooRemoteCommandHostTask, command: ZooRemoteCommand): string | undefined { + if (!task.hasPendingAsk()) { + return "no_pending_ask" + } + const askTs = command.payload?.askTs + if (askTs === undefined) { + return "missing_ask_id" + } + if (task.pendingAskTs() !== askTs) { + return "ask_mismatch" + } + return undefined +} + +/** Apply one remote command through host callbacks, respecting lifecycle epoch checks. */ +export async function executeRemoteCommand(options: { + command: ZooRemoteCommand + epoch: number + client: Pick + host: ZooRemoteCommandHost + /** Session-scoped at-most-once set (especially for start_task). */ + handledCommandIds?: Set +}): Promise { + const { command, epoch, client, host, handledCommandIds } = options + + if (!host.isLifecycleCurrent(epoch)) { + return + } + + if (handledCommandIds?.has(command.commandId)) { + await client.ackCommand({ commandId: command.commandId, status: "acked" }) + return + } + // Claim for the in-flight window so concurrent polls cannot double-apply. + // Release on every non-applied path so the server can redeliver after a real failure. + handledCommandIds?.add(command.commandId) + let applied = false + + try { + if (!host.isEligible()) { + await failCommand(client, host, epoch, command.commandId, "not_eligible") + return + } + + try { + switch (command.type) { + case "message": + case "answer_followup": { + const bound = bindTask(host, command) + if ("errorCode" in bound) { + await failCommand(client, host, epoch, command.commandId, bound.errorCode) + return + } + const askError = messageAskError(bound.task, command) + if (askError) { + await failCommand(client, host, epoch, command.commandId, askError) + return + } + if (!host.isLifecycleCurrent(epoch)) { + return + } + await bound.task.submitMessage(command.payload?.text ?? "", command.payload?.images) + // The message has landed. Keep the claim if the follow-up mode switch fails. + applied = true + if (command.payload?.mode) { + if (!host.isLifecycleCurrent(epoch)) { + return + } + try { + await host.handleModeSwitch(command.payload.mode) + } catch (error) { + await failCommand( + client, + host, + epoch, + command.commandId, + "mode_switch_failed", + error instanceof Error ? error.message : String(error), + ) + return + } + } + break + } + case "approve_ask": + case "deny_ask": { + const bound = bindTask(host, command) + if ("errorCode" in bound) { + await failCommand(client, host, epoch, command.commandId, bound.errorCode) + return + } + const askError = approvalAskError(bound.task, command) + if (askError) { + await failCommand(client, host, epoch, command.commandId, askError) + return + } + if (!host.isLifecycleCurrent(epoch)) { + return + } + const answer = { text: command.payload?.text, images: command.payload?.images } + if (command.type === "approve_ask") { + bound.task.approveAsk(answer) + } else { + bound.task.denyAsk(answer) + } + applied = true + break + } + case "cancel_task": { + const bound = bindTask(host, command) + if ("errorCode" in bound) { + await failCommand(client, host, epoch, command.commandId, bound.errorCode) + return + } + if (!host.isLifecycleCurrent(epoch)) { + return + } + await host.cancelCurrentTask() + applied = true + break + } + case "resume_task": + if (!command.taskId) { + await failCommand(client, host, epoch, command.commandId, "missing_task_id") + return + } + if (!host.isLifecycleCurrent(epoch)) { + return + } + await host.resumeTask(command.taskId) + applied = true + break + case "start_task": + if (!host.isLifecycleCurrent(epoch)) { + return + } + await host.startNewTask({ + text: command.payload?.text, + images: command.payload?.images, + }) + applied = true + break + case "switch_mode": { + const mode = command.payload?.mode + if (!mode) { + await failCommand(client, host, epoch, command.commandId, "missing_mode") + return + } + if (!host.isLifecycleCurrent(epoch)) { + return + } + await host.handleModeSwitch(mode) + applied = true + break + } + case "end_remote_session": + if (!host.isLifecycleCurrent(epoch)) { + return + } + await host.endRemoteSession() + // Ending the session invalidates this epoch, so the shared ack below would be skipped. + await client.ackCommand({ commandId: command.commandId, status: "acked" }) + applied = true + return + default: { + const _exhaustive: never = command.type + await failCommand(client, host, epoch, command.commandId, "unknown_command", String(_exhaustive)) + return + } + } + + if (!host.isLifecycleCurrent(epoch)) { + return + } + // Ack 404 (command_not_found) must not mark the bridge unavailable. + await client.ackCommand({ commandId: command.commandId, status: "acked" }) + } catch (error) { + // Keep the claim if the host action already landed; only failed attempts are retriable. + if (!applied) { + await failCommand( + client, + host, + epoch, + command.commandId, + "execution_failed", + error instanceof Error ? error.message : String(error), + ) + } + } + } finally { + if (!applied) { + handledCommandIds?.delete(command.commandId) + } + } +} diff --git a/packages/core/src/zoo-remote-control/index.ts b/packages/core/src/zoo-remote-control/index.ts new file mode 100644 index 0000000000..5599fc1592 --- /dev/null +++ b/packages/core/src/zoo-remote-control/index.ts @@ -0,0 +1,7 @@ +export * from "./eligibility.js" +export * from "./availability.js" +export * from "./bridgeHelpers.js" +export * from "./executeRemoteCommand.js" +export * from "./projectMessage.js" +export * from "./types.js" +export * from "./ZooRemoteControlClient.js" diff --git a/packages/core/src/zoo-remote-control/projectMessage.ts b/packages/core/src/zoo-remote-control/projectMessage.ts new file mode 100644 index 0000000000..422063bfac --- /dev/null +++ b/packages/core/src/zoo-remote-control/projectMessage.ts @@ -0,0 +1,130 @@ +import type { ClineMessage } from "@roo-code/types" + +/** Cap remote tool payloads so dashboard mirror stays usable without huge transfers. */ +export const REMOTE_MESSAGE_TEXT_MAX_CHARS = 48_000 +export const REMOTE_MESSAGE_IMAGE_MAX_COUNT = 2 +export const REMOTE_MESSAGE_IMAGE_MAX_CHARS = 8_000 +export const REMOTE_SNAPSHOT_MAX_MESSAGES = 80 +/** Cap nested arrays (e.g. batchDiffs) so snapshots stay bounded. */ +export const REMOTE_MESSAGE_ARRAY_MAX_ITEMS = 20 +/** Aggregate serialized-character budget for a single tool JSON payload. */ +export const REMOTE_MESSAGE_JSON_MAX_CHARS = 96_000 + +function truncateString(value: string, max: number): string { + if (max <= 0) { + return "" + } + if (value.length <= max) { + return value + } + // Keep the returned length within `max` (marker fits inside the budget). + const marker = `\n…[truncated ${value.length - max} chars]` + const head = Math.max(0, max - marker.length) + return `${value.slice(0, head)}${marker}` +} + +/** Bound nested JSON while preserving keys/array slots; `stringCap` limits each string. */ +function boundJsonValue(value: unknown, stringCap: number): unknown { + if (typeof value === "string") { + return truncateString(value, stringCap) + } + + if (Array.isArray(value)) { + return value.slice(0, REMOTE_MESSAGE_ARRAY_MAX_ITEMS).map((entry) => boundJsonValue(entry, stringCap)) + } + + if (value && typeof value === "object") { + const record: Record = {} + for (const [key, child] of Object.entries(value as Record)) { + record[key] = boundJsonValue(child, stringCap) + } + return record + } + + return value +} + +/** Drop trailing keys or items until the payload is valid JSON inside the budget. */ +function jsonWithinBudget(value: unknown, budget: number): string { + let current = value + for (;;) { + const serialized = JSON.stringify(current) + if (serialized.length <= budget) { + return serialized + } + if (Array.isArray(current) && current.length > 0) { + current = current.slice(0, -1) + continue + } + if (current && typeof current === "object" && !Array.isArray(current)) { + const entries = Object.entries(current as Record) + if (entries.length > 0) { + const next: Record = {} + for (const [key, item] of entries.slice(0, -1)) { + next[key] = item + } + current = next + continue + } + } + return '{"truncated":true}' + } +} + +function shapeToolText(text: string): string { + try { + const parsed: unknown = JSON.parse(text) + if (parsed === null || typeof parsed !== "object") { + return truncateString(text, REMOTE_MESSAGE_TEXT_MAX_CHARS) + } + + let stringCap = REMOTE_MESSAGE_TEXT_MAX_CHARS + let bounded = boundJsonValue(parsed, stringCap) + let serialized = JSON.stringify(bounded) + + // If the payload is still too large, shrink every string together so structure + // (keys, array slots, small metadata) stays intact under the aggregate budget. + while (serialized.length > REMOTE_MESSAGE_JSON_MAX_CHARS && stringCap > 512) { + stringCap = Math.floor(stringCap * 0.5) + bounded = boundJsonValue(parsed, stringCap) + serialized = JSON.stringify(bounded) + } + + if (serialized.length > REMOTE_MESSAGE_JSON_MAX_CHARS) { + return jsonWithinBudget(bounded, REMOTE_MESSAGE_JSON_MAX_CHARS) + } + + return serialized + } catch { + return truncateString(text, REMOTE_MESSAGE_TEXT_MAX_CHARS) + } +} + +/** Shape a ClineMessage for remote publish (size-safe, still useful for asks/diffs). */ +export function projectClineMessageForRemote(message: ClineMessage): ClineMessage { + const next: ClineMessage = { ...message } + + if (typeof next.text === "string") { + const isToolish = + next.ask === "tool" || next.say === "tool" || next.ask === "command" || next.ask === "use_mcp_server" + + next.text = isToolish ? shapeToolText(next.text) : truncateString(next.text, REMOTE_MESSAGE_TEXT_MAX_CHARS) + } + + if (typeof next.reasoning === "string") { + next.reasoning = truncateString(next.reasoning, REMOTE_MESSAGE_TEXT_MAX_CHARS) + } + + if (Array.isArray(next.images) && next.images.length > 0) { + next.images = next.images + .slice(0, REMOTE_MESSAGE_IMAGE_MAX_COUNT) + .map((image) => truncateString(image, REMOTE_MESSAGE_IMAGE_MAX_CHARS)) + } + + return next +} + +export function projectClineMessagesForSnapshot(messages: ClineMessage[]): ClineMessage[] { + const window = messages.slice(-REMOTE_SNAPSHOT_MAX_MESSAGES) + return window.map(projectClineMessageForRemote) +} diff --git a/packages/core/src/zoo-remote-control/types.ts b/packages/core/src/zoo-remote-control/types.ts new file mode 100644 index 0000000000..de55daa711 --- /dev/null +++ b/packages/core/src/zoo-remote-control/types.ts @@ -0,0 +1,102 @@ +import { z } from "zod" + +import { clineMessageSchema } from "@roo-code/types" + +/** + * Public wire types for Zoo account remote task control. + * Paths live under /api/extension/remote/* and use the extension session token. + */ + +export const zooRemoteCommandTypeSchema = z.enum([ + "message", + "approve_ask", + "deny_ask", + "cancel_task", + "resume_task", + "start_task", + "switch_mode", + "answer_followup", + "end_remote_session", +]) + +export type ZooRemoteCommandType = z.infer + +export const zooRemoteCommandSchema = z.object({ + commandId: z.string().min(1).max(200), + type: zooRemoteCommandTypeSchema, + taskId: z.string().max(200).optional(), + payload: z + .object({ + text: z.string().max(100_000).optional(), + images: z.array(z.string().max(1_500_000)).max(8).optional(), + mode: z.string().max(64).optional(), + /** Timestamp of the ask the user approved or denied. */ + askTs: z.number().optional(), + }) + .optional(), + timestamp: z.number().optional(), +}) + +export type ZooRemoteCommand = z.infer + +/** Commands kept from one poll. Invalid entries are returned separately so they can be acked. */ +export type ZooRemoteCommandsResponse = { + commands: ZooRemoteCommand[] + invalidCommandIds: string[] +} + +export const zooRemoteEventTypeSchema = z.enum([ + "message", + "task_interactive", + "task_idle", + "task_resumable", + "task_completed", + "task_aborted", + "mode_switched", +]) + +export type ZooRemoteEventType = z.infer + +export const zooRemoteMessageActionSchema = z.enum(["created", "updated"]) + +export const zooRemoteEventSchema = z.object({ + type: zooRemoteEventTypeSchema, + taskId: z.string().min(1), + message: clineMessageSchema.optional(), + action: zooRemoteMessageActionSchema.optional(), + mode: z.string().optional(), + seq: z.number().int().nonnegative().optional(), + timestamp: z.number(), +}) + +export type ZooRemoteEvent = z.infer + +export const zooRemotePresenceSchema = z.object({ + instanceId: z.string().min(1), + apiProvider: z.string().min(1), + workspacePath: z.string().optional(), + taskId: z.string().optional(), + online: z.boolean(), + editorName: z.string().optional(), + extensionVersion: z.string().optional(), +}) + +export type ZooRemotePresence = z.infer + +export const zooRemoteCommandAckSchema = z.object({ + commandId: z.string().min(1), + status: z.enum(["acked", "failed"]), + errorCode: z.string().optional(), + message: z.string().optional(), +}) + +export type ZooRemoteCommandAck = z.infer + +export const zooRemoteSnapshotSchema = z.object({ + instanceId: z.string().min(1), + taskId: z.string().min(1), + messages: z.array(clineMessageSchema).max(500), + mode: z.string().optional(), +}) + +export type ZooRemoteSnapshot = z.infer diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index a5393ddc66..3cb8539d41 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -432,6 +432,10 @@ export type ExtensionState = Pick< zooCodeUserEmail?: string zooCodeUserImage?: string zooCodeBaseUrl?: string + /** True when the user has started a Zoo remote-control session from the task header. */ + remoteControlEnabled?: boolean + /** True when the Zoo remote-control bridge is active and the remote endpoint is reachable. */ + remoteControlAvailable?: boolean deviceName?: string debug?: boolean @@ -526,6 +530,8 @@ export interface WebviewMessage { | "updateVSCodeSetting" | "getVSCodeSetting" | "vsCodeSetting" + | "startRemoteControlSession" + | "endRemoteControlSession" | "requestTerminalProfiles" | "openTerminalProfilePicker" | "updateCondensingPrompt" diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index abcd3c28a0..d5982b398b 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -30,6 +30,7 @@ vi.mock("vscode", () => ({ dispose: vi.fn(), }), onDidChangeWorkspaceFolders: vi.fn(), + onDidChangeConfiguration: vi.fn().mockReturnValue({ dispose: vi.fn() }), }, languages: { registerCodeActionsProvider: vi.fn(), diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index dc7880644c..ed803baa0b 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -14,6 +14,8 @@ import debounce from "lodash.debounce" import delay from "delay" import pWaitFor from "p-wait-for" import { serializeError } from "serialize-error" +import { blockingAskTimestamp } from "@roo-code/core" + import { Package } from "../../shared/package" import { @@ -2375,7 +2377,7 @@ export class Task extends EventEmitter implements TaskLike { queuedMessageId, autoDenyDetail: this.pendingAutoDenyDetail, } - this.askResponse = undefined + // Keep askResponse set so hasPendingAsk stays false until the next ask clears it. this.askResponseText = undefined this.askResponseImages = undefined this.pendingAutoDenyDetail = undefined @@ -2470,6 +2472,12 @@ export class Task extends EventEmitter implements TaskLike { this.handleWebviewAskResponse("noButtonClicked", text, images) } + /** True while `ask()` is blocked waiting for a webview/remote response. */ + public hasPendingAsk(): boolean { + const settled = this.askResponse !== undefined || this.abort + return blockingAskTimestamp(this.clineMessages, this.lastMessageTs, settled) !== undefined + } + public supersedePendingAsk(): void { this.lastMessageTs = Date.now() } diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 3d88df632b..d0944685d9 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -91,6 +91,7 @@ import WorkspaceTracker from "../../integrations/workspace/WorkspaceTracker" import { McpHub } from "../../services/mcp/McpHub" import { McpServerManager } from "../../services/mcp/McpServerManager" +import { getZooRemoteControlAvailable } from "@roo-code/core" import { MarketplaceManager } from "../../services/marketplace" import { ShadowCheckpointService } from "../../services/checkpoints/ShadowCheckpointService" import { CodeIndexManagerRegistry } from "../../services/code-index/code-index-manager-registry" @@ -3037,6 +3038,8 @@ export class ClineProvider } })(), ...zooCodeState, + remoteControlEnabled: this.context.globalState.get("zooRemoteControl.sessionActive") === true, + remoteControlAvailable: getZooRemoteControlAvailable(), platform: process.platform, arch: process.arch, debug: vscode.workspace.getConfiguration(Package.name).get("debug", false), diff --git a/src/core/webview/__tests__/webviewMessageHandler.spec.ts b/src/core/webview/__tests__/webviewMessageHandler.spec.ts index d5c72de742..5f063ede56 100644 --- a/src/core/webview/__tests__/webviewMessageHandler.spec.ts +++ b/src/core/webview/__tests__/webviewMessageHandler.spec.ts @@ -67,6 +67,17 @@ vi.mock("../rulesMessageHandler", () => ({ handleOpenRulesDirectory: vi.fn(), })) +const mockSetSessionActive = vi.hoisted(() => vi.fn().mockResolvedValue(undefined)) +const mockGetZooRemoteControlBridge = vi.hoisted(() => + vi.fn((): { setSessionActive: typeof mockSetSessionActive } | undefined => ({ + setSessionActive: mockSetSessionActive, + })), +) + +vi.mock("../../../extension", () => ({ + getZooRemoteControlBridge: () => mockGetZooRemoteControlBridge(), +})) + vi.mock("@roo-code/telemetry", () => ({ TelemetryService: { hasInstance: vi.fn().mockReturnValue(false), @@ -796,6 +807,11 @@ vi.mock("vscode", () => { env: { isTelemetryEnabled: true, }, + ConfigurationTarget: { + Global: 1, + Workspace: 2, + WorkspaceFolder: 3, + }, } }) @@ -2971,6 +2987,80 @@ describe("webviewMessageHandler - telemetrySetting", () => { }) }) +describe("webviewMessageHandler - remote control session", () => { + beforeEach(() => { + vi.clearAllMocks() + mockSetSessionActive.mockResolvedValue(undefined) + mockGetZooRemoteControlBridge.mockImplementation(() => ({ setSessionActive: mockSetSessionActive })) + }) + + it("starts and ends a remote session through the bridge", async () => { + await webviewMessageHandler(mockClineProvider, { type: "startRemoteControlSession" }) + await webviewMessageHandler(mockClineProvider, { type: "endRemoteControlSession" }) + + expect(mockSetSessionActive).toHaveBeenNthCalledWith(1, true) + expect(mockSetSessionActive).toHaveBeenNthCalledWith(2, false) + }) + + it("no-ops when the bridge is unavailable", async () => { + mockGetZooRemoteControlBridge.mockReturnValue(undefined) + + await expect( + webviewMessageHandler(mockClineProvider, { type: "startRemoteControlSession" }), + ).resolves.not.toThrow() + expect(mockSetSessionActive).not.toHaveBeenCalled() + expect(mockClineProvider.log).not.toHaveBeenCalled() + }) + + it("selects the narrowest ConfigurationTarget for updateVSCodeSetting", async () => { + const update = vi.fn().mockResolvedValue(undefined) + const inspect = vi + .fn() + .mockReturnValueOnce({ workspaceFolderValue: true }) + .mockReturnValueOnce({ workspaceValue: false }) + .mockReturnValueOnce({ globalValue: undefined }) + + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + inspect, + update, + get: vi.fn(), + } as never) + + await webviewMessageHandler(mockClineProvider, { + type: "updateVSCodeSetting", + setting: "terminal.integrated.inheritEnv", + bool: true, + }) + expect(update).toHaveBeenLastCalledWith( + "terminal.integrated.inheritEnv", + true, + vscode.ConfigurationTarget.WorkspaceFolder, + ) + + await webviewMessageHandler(mockClineProvider, { + type: "updateVSCodeSetting", + setting: "terminal.integrated.inheritEnv", + bool: false, + }) + expect(update).toHaveBeenLastCalledWith( + "terminal.integrated.inheritEnv", + false, + vscode.ConfigurationTarget.Workspace, + ) + + await webviewMessageHandler(mockClineProvider, { + type: "updateVSCodeSetting", + setting: "terminal.integrated.inheritEnv", + bool: true, + }) + expect(update).toHaveBeenLastCalledWith( + "terminal.integrated.inheritEnv", + true, + vscode.ConfigurationTarget.Global, + ) + }) +}) + describe("webviewMessageHandler - collapseCommandOutputByDefault", () => { beforeEach(() => { vi.clearAllMocks() diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 5596b90e67..aedabe6d95 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2065,12 +2065,33 @@ export const webviewMessageHandler = async ( stopTts() break + case "startRemoteControlSession": + case "endRemoteControlSession": { + try { + const { getZooRemoteControlBridge } = await import("../../extension") + await getZooRemoteControlBridge()?.setSessionActive(message.type === "startRemoteControlSession") + } catch (error) { + provider.log( + `[ZooRemoteControl] session toggle failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + break + } case "updateVSCodeSetting": { - const { setting, value } = message + const { setting } = message + const value = message.bool ?? message.value if (setting !== undefined && value !== undefined) { if (ALLOWED_VSCODE_SETTINGS.has(setting)) { - await vscode.workspace.getConfiguration().update(setting, value, true) + const config = vscode.workspace.getConfiguration() + const inspected = config.inspect(setting) + const target = + inspected?.workspaceFolderValue !== undefined + ? vscode.ConfigurationTarget.WorkspaceFolder + : inspected?.workspaceValue !== undefined + ? vscode.ConfigurationTarget.Workspace + : vscode.ConfigurationTarget.Global + await config.update(setting, value, target) } else { vscode.window.showErrorMessage(`Cannot update restricted VSCode setting: ${setting}`) } diff --git a/src/extension.ts b/src/extension.ts index 6ce42aacd5..e1888836c6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -40,6 +40,7 @@ import { McpServerManager } from "./services/mcp/McpServerManager" import { CodeIndexManagerRegistry } from "./services/code-index/code-index-manager-registry" import { CodeIndexScope } from "./services/code-index/code-index-scope" import { MdmService } from "./services/mdm/MdmService" +import { ZooRemoteControlBridge } from "./services/zoo-remote-control/ZooRemoteControlBridge" import { migrateSettings } from "./utils/migrateSettings" import { autoImportSettings } from "./utils/autoImportSettings" import { API } from "./extension/api" @@ -397,13 +398,38 @@ export async function activate(context: vscode.ExtensionContext) { }) const providerFactory = new ClineProviderFactory(context, outputChannel, webviewFocusTracker) - return new API(outputChannel, provider, providerFactory, socketPath, enableLogging) + const api = new API(outputChannel, provider, providerFactory, socketPath, enableLogging) + + zooRemoteControlBridge = new ZooRemoteControlBridge({ + api, + provider, + context, + log: (message) => outputChannel.appendLine(message), + }) + context.subscriptions.push(zooRemoteControlBridge) + + return api +} + +let zooRemoteControlBridge: ZooRemoteControlBridge | undefined + +export function getZooRemoteControlBridge(): ZooRemoteControlBridge | undefined { + return zooRemoteControlBridge } // This method is called when your extension is deactivated. export async function deactivate() { outputChannel.appendLine(`${Package.name} extension deactivated`) + try { + await zooRemoteControlBridge?.shutdown() + } catch (error) { + outputChannel.appendLine( + `Failed to shut down Zoo remote control: ${error instanceof Error ? error.message : String(error)}`, + ) + } + zooRemoteControlBridge = undefined + if (cloudService && CloudService.hasInstance()) { try { if (settingsUpdatedHandler) { diff --git a/src/services/zoo-remote-control/ZooRemoteControlBridge.ts b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts new file mode 100644 index 0000000000..29b5e623c1 --- /dev/null +++ b/src/services/zoo-remote-control/ZooRemoteControlBridge.ts @@ -0,0 +1,665 @@ +import * as vscode from "vscode" + +import { + classifyRemoteControlHttpResult, + executeRemoteCommand, + generateZooRemoteInstanceId, + isZooRemoteControlEligible, + partialFlushDecision, + projectClineMessageForRemote, + projectClineMessagesForSnapshot, + remoteMessageKey, + requeueRemoteEventsKeepingNewest, + resolveRemoteMessageAction, + resolveZooRemoteEventSeq, + setZooRemoteControlAvailable, + workspaceNicknameFromFsPath, + ZooRemoteControlClient, + type ZooRemoteCommand, + type ZooRemoteEvent, +} from "@roo-code/core" +import { + providerIdentifiers, + RooCodeEventName, + type ClineMessage, + type ProviderSettings, + type RooCodeAPI, +} from "@roo-code/types" + +import type { ClineProvider } from "../../core/webview/ClineProvider" +import { Package } from "../../shared/package" +import { getCachedZooCodeToken, getZooCodeBaseUrl } from "../zoo-code-auth" + +const PRESENCE_INTERVAL_MS = 15_000 +const COMMAND_POLL_INTERVAL_MS = 2_000 +const EVENT_FLUSH_INTERVAL_MS = 100 +const PARTIAL_EVENT_MIN_INTERVAL_MS = 75 +const UNAVAILABLE_BACKOFF_MS = 60_000 +const NOT_ELIGIBLE_BACKOFF_MS = 30_000 +const EVENT_SEQ_STATE_KEY = "zooRemoteControl.eventSeq" +/** Built-in remote session flag (not a VS Code Configuration setting). */ +const SESSION_ACTIVE_STATE_KEY = "zooRemoteControl.sessionActive" +const CONSENTED_TASK_STATE_KEY = "zooRemoteControl.consentedTaskId" +const LEGACY_SETTING_KEY = "remoteControl.enabled" + +export type ZooRemoteControlBridgeDeps = { + api: Pick + provider: ClineProvider + context: vscode.ExtensionContext + log?: (message: string) => void + client?: ZooRemoteControlClient + now?: () => number +} + +/** Publishes task UI events to Zoo and applies inbound remote commands when eligible. */ +export class ZooRemoteControlBridge implements vscode.Disposable { + private readonly api: ZooRemoteControlBridgeDeps["api"] + private readonly provider: ClineProvider + private readonly context: vscode.ExtensionContext + private readonly log: (message: string) => void + private readonly client: ZooRemoteControlClient + private readonly now: () => number + private readonly instanceId: string + + private disposed = false + private active = false + private serverAvailable = true + private lastPostedAvailability: boolean | undefined + private unavailableUntil = 0 + private eventSeq: number + private pendingEvents: ZooRemoteEvent[] = [] + private lastPartialFlushAt = 0 + private pendingPartials = new Map() + /** Resolves true once instance id is durable in globalState (or already was). */ + /** Serializes event-seq writes so updates cannot complete out of order. */ + private eventSeqPersistChain: Promise = Promise.resolve() + private eventSeqPersistFailed = false + /** Bumped on deactivate/dispose so in-flight polls/flushes become no-ops. */ + private lifecycleEpoch = 0 + /** Serializes eligibility sync so activate and deactivate cannot overlap. */ + private syncChain: Promise = Promise.resolve() + /** Set before the identity await so a second activate cannot pass the first check. */ + private activating = false + private commandPollInFlight = false + private flushInFlight = false + /** At-most-once delivery for remote commands (esp. start_task) within an active session. */ + private handledCommandIds = new Set() + + private presenceTimer: ReturnType | undefined + private commandTimer: ReturnType | undefined + private flushTimer: ReturnType | undefined + private readonly disposables: vscode.Disposable[] = [] + + private readonly onMessage = (payload: { taskId: string; action?: string; message: ClineMessage }) => { + void this.handleMessageEvent(payload) + } + + private taskEventHandler(type: ZooRemoteEvent["type"]) { + return (taskId: string) => { + this.enqueueEvent({ type, taskId, timestamp: this.now() }) + } + } + + private readonly onTaskInteractive = this.taskEventHandler("task_interactive") + private readonly onTaskIdle = this.taskEventHandler("task_idle") + private readonly onTaskResumable = this.taskEventHandler("task_resumable") + private readonly onTaskCompleted = this.taskEventHandler("task_completed") + private readonly onTaskAborted = this.taskEventHandler("task_aborted") + + private readonly onModeSwitched = (taskId: string, mode: string) => { + this.enqueueEvent({ type: "mode_switched", taskId, mode, timestamp: this.now() }) + } + + private readonly onProviderProfileChanged = () => { + void this.syncActiveState() + } + + constructor(deps: ZooRemoteControlBridgeDeps) { + this.api = deps.api + this.provider = deps.provider + this.context = deps.context + this.log = deps.log ?? (() => {}) + this.now = deps.now ?? (() => Date.now()) + // sessionId is per window. A shared globalState id lets a second window ack the first window's commands. + this.instanceId = generateZooRemoteInstanceId(this.now(), vscode.env.sessionId) + this.eventSeq = this.resolveEventSeq() + this.client = + deps.client ?? + new ZooRemoteControlClient({ + getBaseUrl: getZooCodeBaseUrl, + getSessionToken: getCachedZooCodeToken, + log: this.log, + }) + + if (typeof this.provider.on === "function") { + this.provider.on(RooCodeEventName.ProviderProfileChanged, this.onProviderProfileChanged) + } + + if (typeof this.context.secrets?.onDidChange === "function") { + this.disposables.push( + this.context.secrets.onDidChange((event) => { + if (event.key === "zoo-code-session-token") { + void this.syncActiveState() + } + }), + ) + } + + void this.migrateLegacySettingThenSync() + } + + /** Test helper / webview: whether the user has an active remote session. */ + public isSessionActive(): boolean { + return this.context.globalState.get(SESSION_ACTIVE_STATE_KEY) === true + } + + /** Start or end remote session (does not cancel the task). */ + public async setSessionActive(active: boolean): Promise { + try { + if (active) { + const taskId = this.provider.getCurrentTask()?.taskId + if (!taskId) { + throw new Error("remote session requires a task") + } + // Record the task before the flag. A failed consent write must not turn the session on. + await this.context.globalState.update(CONSENTED_TASK_STATE_KEY, taskId) + await this.context.globalState.update(SESSION_ACTIVE_STATE_KEY, true) + } else { + await this.context.globalState.update(SESSION_ACTIVE_STATE_KEY, false) + try { + await this.context.globalState.update(CONSENTED_TASK_STATE_KEY, undefined) + } catch (error) { + this.log( + `[ZooRemoteControl] failed to clear consented task: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + await this.syncActiveState() + } finally { + await this.provider.postStateToWebview() + } + } + + private async migrateLegacySettingThenSync(): Promise { + try { + const existing = this.context.globalState.get(SESSION_ACTIVE_STATE_KEY) + if (existing === undefined) { + const legacy = + vscode.workspace.getConfiguration(Package.name).get(LEGACY_SETTING_KEY, false) === true + if (legacy) { + await this.context.globalState.update(SESSION_ACTIVE_STATE_KEY, true) + try { + await vscode.workspace + .getConfiguration(Package.name) + .update(LEGACY_SETTING_KEY, undefined, vscode.ConfigurationTarget.Global) + } catch { + // Best-effort clear of the removed setting. + } + } + } + } catch (error) { + this.log( + `[ZooRemoteControl] legacy setting migrate failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } + await this.syncActiveState() + } + + /** Awaitable teardown so offline presence can finish before extension deactivate returns. */ + public async shutdown(): Promise { + if (this.disposed) return + this.lifecycleEpoch += 1 + await this.deactivate({ notifyOffline: true }) + this.releaseResources() + } + + public dispose(): void { + if (this.disposed) return + this.lifecycleEpoch += 1 + void this.deactivate({ notifyOffline: true }) + this.releaseResources() + } + + private releaseResources(): void { + this.disposed = true + setZooRemoteControlAvailable(false) + if (typeof this.provider.off === "function") { + this.provider.off(RooCodeEventName.ProviderProfileChanged, this.onProviderProfileChanged) + } + for (const disposable of this.disposables) { + disposable.dispose() + } + this.disposables.length = 0 + } + + private isLifecycleCurrent(epoch: number): boolean { + return !this.disposed && this.active && epoch === this.lifecycleEpoch + } + + /** Test helper */ + public isActive(): boolean { + return this.active + } + + /** Test helper */ + public getInstanceId(): string { + return this.instanceId + } + + private resolveEventSeq(): number { + return resolveZooRemoteEventSeq(this.context.globalState.get(EVENT_SEQ_STATE_KEY)) + } + + private persistEventSeq(): void { + this.eventSeqPersistChain = this.eventSeqPersistChain + .then(async () => { + if (this.disposed || this.eventSeqPersistFailed) return + await this.context.globalState.update(EVENT_SEQ_STATE_KEY, this.eventSeq) + }) + .catch((error: unknown) => { + this.eventSeqPersistFailed = true + this.pendingEvents = [] + this.log( + `[ZooRemoteControl] failed to persist event seq: ${error instanceof Error ? error.message : String(error)}`, + ) + }) + } + + private getApiProvider(): string | undefined { + try { + const getSettings = this.provider.contextProxy?.getProviderSettings + if (typeof getSettings !== "function") return undefined + const settings: ProviderSettings = getSettings.call(this.provider.contextProxy) + return settings?.apiProvider + } catch { + return undefined + } + } + + private evaluateEligible(): boolean { + return isZooRemoteControlEligible({ + remoteControlEnabled: this.isSessionActive(), + apiProvider: this.getApiProvider(), + sessionToken: getCachedZooCodeToken() || undefined, + }) + } + + private async syncActiveState(): Promise { + let release!: () => void + const gate = new Promise((resolve) => (release = resolve)) + const previous = this.syncChain + this.syncChain = gate + await previous + try { + await this.applyEligibility() + } finally { + release() + } + } + + private async applyEligibility(): Promise { + if (this.disposed) return + + try { + const eligible = this.evaluateEligible() + if (eligible && !this.active) { + await this.activate() + } else if (!eligible && this.active) { + await this.deactivate({ notifyOffline: true }) + } else if (eligible && this.active) { + await this.publishPresence(true) + } + } catch (error) { + this.log(`[ZooRemoteControl] sync failed: ${error instanceof Error ? error.message : String(error)}`) + } + } + + private async activate(): Promise { + if (this.active || this.activating || this.disposed) return + + this.activating = true + try { + if (this.disposed) return + + this.active = true + this.setServerAvailable(true) + this.bindApiListeners(true) + this.presenceTimer = setInterval(() => void this.publishPresence(true), PRESENCE_INTERVAL_MS) + this.commandTimer = setInterval(() => void this.pollCommands(), COMMAND_POLL_INTERVAL_MS) + this.flushTimer = setInterval(() => void this.flushEvents(), EVENT_FLUSH_INTERVAL_MS) + + this.log("[ZooRemoteControl] enabled") + await this.publishPresence(true) + await this.publishSnapshot() + } finally { + this.activating = false + } + } + + private async deactivate({ notifyOffline }: { notifyOffline: boolean }): Promise { + if (!this.active) { + this.clearTimers() + this.bindApiListeners(false) + return + } + + this.lifecycleEpoch += 1 + this.active = false + this.clearTimers() + this.bindApiListeners(false) + this.pendingEvents = [] + this.pendingPartials.clear() + this.handledCommandIds.clear() + this.publishAvailabilityToWebview() + + if (notifyOffline) { + // Always try offline presence while a session token remains (even if provider changed). + await this.publishPresence(false, { force: true }) + } + + this.log("[ZooRemoteControl] disabled") + } + + private clearTimers(): void { + for (const key of ["presenceTimer", "commandTimer", "flushTimer"] as const) { + const timer = this[key] + if (timer) { + clearInterval(timer) + this[key] = undefined + } + } + } + + private bindApiListeners(bind: boolean): void { + const method = bind ? "on" : "off" + this.api[method](RooCodeEventName.Message, this.onMessage) + this.api[method](RooCodeEventName.TaskInteractive, this.onTaskInteractive) + this.api[method](RooCodeEventName.TaskIdle, this.onTaskIdle) + this.api[method](RooCodeEventName.TaskResumable, this.onTaskResumable) + this.api[method](RooCodeEventName.TaskCompleted, this.onTaskCompleted) + this.api[method](RooCodeEventName.TaskAborted, this.onTaskAborted) + this.api[method](RooCodeEventName.TaskModeSwitched, this.onModeSwitched) + } + + private acceptsTaskContent(taskId: string | undefined): boolean { + const consented = this.context.globalState.get(CONSENTED_TASK_STATE_KEY) + return typeof consented === "string" && consented.length > 0 && taskId === consented + } + + private async handleMessageEvent(payload: { + taskId: string + action?: string + message: ClineMessage + }): Promise { + if (!this.active || !this.acceptsTaskContent(payload.taskId)) return + + const shaped = { + taskId: payload.taskId, + action: payload.action, + message: projectClineMessageForRemote(payload.message), + } + + const key = remoteMessageKey(shaped.taskId, shaped.message) + if (shaped.message.partial === true) { + this.pendingPartials.set(key, shaped) + // Start the throttle window on the first partial without flushing so rapid + // updates to the same message coalesce to the latest text. + const decision = partialFlushDecision(this.lastPartialFlushAt, this.now(), PARTIAL_EVENT_MIN_INTERVAL_MS) + if (decision === "arm") { + this.lastPartialFlushAt = this.now() + return + } + if (decision === "wait") return + this.flushPendingPartials() + return + } + + this.pendingPartials.delete(key) + this.enqueueMessageEvent(shaped) + } + + private flushPendingPartials(): void { + if (this.pendingPartials.size === 0) return + this.lastPartialFlushAt = this.now() + for (const shaped of this.pendingPartials.values()) { + this.enqueueMessageEvent(shaped) + } + this.pendingPartials.clear() + } + + private enqueueMessageEvent(shaped: { taskId: string; action?: string; message: ClineMessage }): void { + this.enqueueEvent({ + type: "message", + taskId: shaped.taskId, + action: resolveRemoteMessageAction(shaped.action, shaped.message.partial), + message: shaped.message, + timestamp: this.now(), + }) + } + + private enqueueEvent(event: Omit): void { + if (!this.active || this.eventSeqPersistFailed || !this.acceptsTaskContent(event.taskId)) return + + this.pendingEvents.push({ ...event, seq: this.eventSeq++ }) + this.persistEventSeq() + } + + private markBackoff(ms: number): void { + this.serverAvailable = false + this.unavailableUntil = this.now() + ms + this.publishAvailabilityToWebview() + } + + private setServerAvailable(available: boolean): void { + this.serverAvailable = available + if (available) { + this.unavailableUntil = 0 + } + this.publishAvailabilityToWebview() + } + + private publishAvailabilityToWebview(): void { + const available = this.active && this.canCallServer() + if (this.lastPostedAvailability === available) return + this.lastPostedAvailability = available + setZooRemoteControlAvailable(available) + void this.provider.postStateToWebview() + } + + private canCallServer(): boolean { + return this.serverAvailable || this.now() >= this.unavailableUntil + } + + private async publishPresence(online: boolean, options?: { force?: boolean }): Promise { + if (!options?.force && !this.canCallServer() && online) return + if (!getCachedZooCodeToken()) return + + // Nickname only — never send an absolute disk path (home dir / username fingerprint). + const workspacePath = workspaceNicknameFromFsPath(vscode.workspace.workspaceFolders?.[0]?.uri.fsPath) + const currentTaskId = this.provider.getCurrentTask()?.taskId + const taskId = this.acceptsTaskContent(currentTaskId) ? currentTaskId : undefined + const epoch = this.lifecycleEpoch + const result = await this.client.postPresence({ + instanceId: this.instanceId, + apiProvider: this.getApiProvider() ?? providerIdentifiers.zooGateway, + workspacePath, + taskId, + online, + editorName: vscode.env.appName, + extensionVersion: Package.version, + }) + + // Online publishes must still belong to the active lifecycle. Offline notify is allowed + // after deactivate (active=false) as long as a newer activation has not started. + if (online) { + if (!this.isLifecycleCurrent(epoch)) return + } else if (this.active && epoch !== this.lifecycleEpoch) return + + const kind = classifyRemoteControlHttpResult(result) + if (kind === "auth_blocked") { + this.markBackoff(NOT_ELIGIBLE_BACKOFF_MS) + if (this.active) { + await this.deactivate({ notifyOffline: false }) + } + return + } + if (kind === "unavailable") { + this.markBackoff(UNAVAILABLE_BACKOFF_MS) + return + } + if (kind === "success") { + this.setServerAvailable(true) + } + } + + private async publishSnapshot(): Promise { + if (!this.active || !this.canCallServer()) return + + const task = this.provider.getCurrentTask() + if (!task?.taskId || !this.acceptsTaskContent(task.taskId) || !Array.isArray(task.clineMessages)) return + + let mode: string | undefined + try { + mode = task.taskMode + } catch { + mode = undefined + } + + const epoch = this.lifecycleEpoch + const result = await this.client.postSnapshot({ + instanceId: this.instanceId, + taskId: task.taskId, + messages: projectClineMessagesForSnapshot(task.clineMessages), + mode, + }) + if (!this.isLifecycleCurrent(epoch)) return + + const kind = classifyRemoteControlHttpResult(result) + if (kind === "auth_blocked") { + this.markBackoff(NOT_ELIGIBLE_BACKOFF_MS) + return + } + if (kind === "unavailable") { + this.markBackoff(UNAVAILABLE_BACKOFF_MS) + return + } + if (kind === "success") { + this.setServerAvailable(true) + } + } + + private async flushEvents(): Promise { + if (!this.active || this.eventSeqPersistFailed || !this.canCallServer() || this.flushInFlight) return + + this.flushInFlight = true + try { + await this.flushEventsOnce() + } finally { + this.flushInFlight = false + } + } + + private async flushEventsOnce(): Promise { + const epoch = this.lifecycleEpoch + this.flushPendingPartials() + if (this.pendingEvents.length === 0) return + + const batch = this.pendingEvents.splice(0, this.pendingEvents.length) + const result = await this.client.postEvents(this.instanceId, batch) + if (!this.isLifecycleCurrent(epoch)) return + + const kind = classifyRemoteControlHttpResult(result) + if (kind === "auth_blocked") { + this.markBackoff(NOT_ELIGIBLE_BACKOFF_MS) + // Keep the failed batch ahead of anything enqueued during the await; cap from the + // tail so the newest events survive under pressure. + this.pendingEvents = requeueRemoteEventsKeepingNewest(batch, this.pendingEvents) + return + } + if (kind === "unavailable") { + this.markBackoff(UNAVAILABLE_BACKOFF_MS) + // Drop batch while the public endpoint is not available yet. + return + } + if (kind === "failed") { + this.pendingEvents = requeueRemoteEventsKeepingNewest(batch, this.pendingEvents) + return + } + this.setServerAvailable(true) + } + + private async pollCommands(): Promise { + if (!this.active || this.disposed || this.commandPollInFlight || !this.canCallServer()) return + + if (!this.evaluateEligible()) { + await this.deactivate({ notifyOffline: true }) + return + } + + this.commandPollInFlight = true + const epoch = this.lifecycleEpoch + try { + const result = await this.client.getCommands(this.instanceId) + if (!this.isLifecycleCurrent(epoch)) return + + const kind = classifyRemoteControlHttpResult(result) + if (kind === "auth_blocked") { + this.markBackoff(NOT_ELIGIBLE_BACKOFF_MS) + await this.deactivate({ notifyOffline: false }) + return + } + if (kind === "unavailable") { + this.markBackoff(UNAVAILABLE_BACKOFF_MS) + return + } + if (kind !== "success" || !result.body) return + + this.setServerAvailable(true) + + for (const commandId of result.body.invalidCommandIds ?? []) { + if (!this.isLifecycleCurrent(epoch)) return + await this.client.ackCommand({ commandId, status: "failed", errorCode: "invalid_command" }) + } + + for (const command of result.body.commands) { + if (!this.isLifecycleCurrent(epoch)) return + await this.executeCommand(command, epoch) + } + } finally { + this.commandPollInFlight = false + } + } + + private async executeCommand(command: ZooRemoteCommand, epoch: number): Promise { + await executeRemoteCommand({ + command, + epoch, + client: this.client, + handledCommandIds: this.handledCommandIds, + host: { + isLifecycleCurrent: (value) => this.isLifecycleCurrent(value), + isEligible: () => this.evaluateEligible(), + handleModeSwitch: (mode) => this.provider.handleModeSwitch(mode), + getCurrentTask: () => { + const task = this.provider.getCurrentTask() + if (!task) return undefined + return { + taskId: task.taskId, + hasPendingAsk: () => task.hasPendingAsk(), + pendingAskTs: () => + task.hasPendingAsk() && typeof task.lastMessageTs === "number" + ? task.lastMessageTs + : undefined, + approveAsk: (payload) => task.approveAsk(payload), + denyAsk: (payload) => task.denyAsk(payload), + submitMessage: (text, images) => task.submitUserMessage(text, images), + } + }, + cancelCurrentTask: () => this.api.cancelCurrentTask(), + resumeTask: (taskId) => this.api.resumeTask(taskId), + startNewTask: async (payload) => void (await this.api.startNewTask(payload)), + endRemoteSession: () => this.setSessionActive(false), + }, + }) + } +} diff --git a/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts new file mode 100644 index 0000000000..2c28d38611 --- /dev/null +++ b/src/services/zoo-remote-control/__tests__/ZooRemoteControlBridge.spec.ts @@ -0,0 +1,1223 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { EventEmitter } from "events" + +import { providerIdentifiers, RooCodeEventName } from "@roo-code/types" + +import type { ZooRemoteControlClient } from "@roo-code/core" + +import { ZooRemoteControlBridge } from "../ZooRemoteControlBridge" + +vi.mock("vscode", () => ({ + workspace: { + getConfiguration: vi.fn(() => ({ + get: vi.fn((_key: string, defaultValue?: boolean) => defaultValue), + update: vi.fn().mockResolvedValue(undefined), + })), + workspaceFolders: [{ uri: { fsPath: "/tmp/project" } }], + }, + ConfigurationTarget: { Global: 1, Workspace: 2, WorkspaceFolder: 3 }, + env: { + sessionId: "test-session-id", + appName: "Code", + }, +})) + +vi.mock("../../zoo-code-auth", () => ({ + getCachedZooCodeToken: vi.fn(() => ""), + getZooCodeBaseUrl: vi.fn(() => "https://www.zoocode.dev"), +})) + +import * as vscode from "vscode" +import { getCachedZooCodeToken } from "../../zoo-code-auth" + +describe("ZooRemoteControlBridge", () => { + let api: EventEmitter & { + sendMessage: ReturnType + cancelCurrentTask: ReturnType + resumeTask: ReturnType + startNewTask: ReturnType + } + let provider: EventEmitter & { + contextProxy: { getProviderSettings: ReturnType } + getCurrentTask: ReturnType + handleModeSwitch: ReturnType + postStateToWebview: ReturnType + } + let client: { + postPresence: ReturnType + postEvents: ReturnType + postSnapshot: ReturnType + getCommands: ReturnType + ackCommand: ReturnType + } + let context: { + globalState: { get: ReturnType; update: ReturnType } + secrets: { onDidChange: ReturnType } + } + let sessionActive = false + let storedState: Record + + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + + sessionActive = false + storedState = { + "zooRemoteControl.instanceId": "other-window", + "zooRemoteControl.eventSeq": 0, + "zooRemoteControl.consentedTaskId": "task-1", + } + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn((_key: string, defaultValue?: boolean) => defaultValue ?? false), + update: vi.fn().mockResolvedValue(undefined), + } as never) + + vi.mocked(getCachedZooCodeToken).mockReturnValue("") + + api = Object.assign(new EventEmitter(), { + sendMessage: vi.fn().mockResolvedValue(undefined), + cancelCurrentTask: vi.fn().mockResolvedValue(undefined), + resumeTask: vi.fn().mockResolvedValue(undefined), + startNewTask: vi.fn().mockResolvedValue("task-1"), + }) + + provider = Object.assign(new EventEmitter(), { + contextProxy: { + getProviderSettings: vi.fn(() => ({ apiProvider: providerIdentifiers.openrouter })), + }, + getCurrentTask: vi.fn(() => undefined), + handleModeSwitch: vi.fn().mockResolvedValue(undefined), + postStateToWebview: vi.fn().mockResolvedValue(undefined), + }) + + client = { + postPresence: vi.fn().mockResolvedValue({ ok: true, status: 200 }), + postEvents: vi.fn().mockResolvedValue({ ok: true, status: 200 }), + postSnapshot: vi.fn().mockResolvedValue({ ok: true, status: 200 }), + getCommands: vi.fn().mockResolvedValue({ ok: true, status: 200, body: { commands: [] } }), + ackCommand: vi.fn().mockResolvedValue({ ok: true, status: 200 }), + } + + context = { + globalState: { + get: vi.fn((key: string) => { + if (key === "zooRemoteControl.sessionActive") return sessionActive + return storedState[key] + }), + update: vi.fn(async (key: string, value: unknown) => { + if (key === "zooRemoteControl.sessionActive") { + sessionActive = value === true + } + storedState[key] = value + }), + }, + secrets: { + onDidChange: vi.fn(() => ({ dispose: vi.fn() })), + }, + } + }) + + afterEach(() => { + vi.useRealTimers() + }) + + function createBridge() { + return new ZooRemoteControlBridge({ + api: api as never, + provider: provider as never, + context: context as never, + client: client as unknown as ZooRemoteControlClient, + now: () => Date.now(), + }) + } + + function useLiveTask(options?: { taskId?: string; pendingAskTs?: number }) { + const task = { + taskId: options?.taskId ?? "task-1", + lastMessageTs: options?.pendingAskTs, + clineMessages: + options?.pendingAskTs === undefined + ? [] + : [ + { ts: options.pendingAskTs, type: "ask", ask: "command", partial: false }, + { ts: options.pendingAskTs + 1, type: "say", say: "text", text: "status" }, + ], + hasPendingAsk: () => options?.pendingAskTs !== undefined, + approveAsk: vi.fn(), + denyAsk: vi.fn(), + submitUserMessage: api.sendMessage, + } + provider.getCurrentTask.mockReturnValue(task) + return task + } + + it("does not publish presence on shutdown when the user never opted in", async () => { + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + await bridge.shutdown() + bridge.dispose() + expect(client.postPresence).not.toHaveBeenCalled() + }) + + it("stays inactive when remote control is disabled", async () => { + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + expect(bridge.isActive()).toBe(false) + expect(client.postPresence).not.toHaveBeenCalled() + bridge.dispose() + }) + + it("activates when opted in with an eligible Zoo session", async () => { + sessionActive = true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + + expect(bridge.isActive()).toBe(true) + expect(client.postPresence).toHaveBeenCalledWith( + expect.objectContaining({ + instanceId: "test-session-id", + apiProvider: providerIdentifiers.zooGateway, + online: true, + workspacePath: "project", + }), + ) + const presence = client.postPresence.mock.calls[0]?.[0] as { workspacePath?: string } + expect(presence.workspacePath).toBe("project") + expect(presence.workspacePath).not.toMatch(/[/\\]/) + bridge.dispose() + }) + + it("posts only the workspace folder basename, never an absolute path", async () => { + sessionActive = true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + ;(vscode.workspace as unknown as { workspaceFolders: { uri: { fsPath: string } }[] }).workspaceFolders = [ + { uri: { fsPath: "C:\\Users\\alice\\Desktop\\secret-client" } }, + ] + + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + + expect(client.postPresence).toHaveBeenCalledWith( + expect.objectContaining({ + workspacePath: "secret-client", + }), + ) + const presence = client.postPresence.mock.calls[0]?.[0] as { workspacePath?: string } + expect(presence.workspacePath).not.toMatch(/[/\\]|Users|alice/i) + bridge.dispose() + ;(vscode.workspace as unknown as { workspaceFolders: { uri: { fsPath: string } }[] }).workspaceFolders = [ + { uri: { fsPath: "/tmp/project" } }, + ] + }) + + it("does not leave a duplicate listener when eligibility drops during activation", async () => { + sessionActive = true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + context.globalState.get.mockImplementation((key: string) => { + if (key === "zooRemoteControl.eventSeq") return 0 + if (key === "zooRemoteControl.sessionActive") return true + return undefined + }) + let releaseId: () => void = () => {} + const idGate = new Promise((resolve) => { + releaseId = resolve + }) + context.globalState.update.mockImplementation(async (key: string, value: unknown) => { + if (key === "zooRemoteControl.instanceId") { + await idGate + } + if (key === "zooRemoteControl.sessionActive") { + sessionActive = value === true + } + }) + + const bridge = createBridge() + await Promise.resolve() + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.anthropic, + }) + provider.emit(RooCodeEventName.ProviderProfileChanged, { + name: "Anthropic", + provider: providerIdentifiers.anthropic, + }) + releaseId() + await vi.advanceTimersByTimeAsync(0) + + expect(bridge.isActive()).toBe(false) + expect(api.listenerCount(RooCodeEventName.Message)).toBe(0) + bridge.dispose() + }) + + it("does not activate when the provider is not eligible", async () => { + sessionActive = true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.anthropic, + }) + + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + + expect(bridge.isActive()).toBe(false) + bridge.dispose() + }) + + it("executes a remote message command and acks success", async () => { + sessionActive = true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + useLiveTask() + client.getCommands.mockResolvedValue({ + ok: true, + status: 200, + body: { + commands: [ + { + commandId: "cmd-1", + type: "message", + taskId: "task-1", + payload: { text: "hello from remote" }, + }, + ], + }, + }) + + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + await vi.advanceTimersByTimeAsync(2_000) + + expect(api.sendMessage).toHaveBeenCalledWith("hello from remote", undefined) + expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "cmd-1", status: "acked" }) + bridge.dispose() + }) + + it("backs off when the remote endpoints return 404", async () => { + sessionActive = true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + client.postPresence.mockResolvedValue({ ok: false, status: 404 }) + client.getCommands.mockResolvedValue({ ok: false, status: 404 }) + + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + expect(bridge.isActive()).toBe(true) + + client.postPresence.mockClear() + await vi.advanceTimersByTimeAsync(15_000) + // Still in 404 backoff window — presence should not fire again yet. + expect(client.postPresence).not.toHaveBeenCalled() + + bridge.dispose() + }) + + it("publishes non-partial message events while active", async () => { + sessionActive = true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + + api.emit(RooCodeEventName.Message, { + taskId: "task-1", + action: "created", + message: { ts: 1, type: "say", say: "text", text: "hi", partial: false }, + }) + + await vi.advanceTimersByTimeAsync(100) + + expect(client.postEvents).toHaveBeenCalledWith( + "test-session-id", + expect.arrayContaining([ + expect.objectContaining({ + type: "message", + taskId: "task-1", + message: expect.objectContaining({ text: "hi" }), + }), + ]), + ) + bridge.dispose() + }) + + it("does not upload another task after the user opts in for one task", async () => { + sessionActive = true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + useLiveTask({ taskId: "task-1" }) + + const bridge = createBridge() + await bridge.setSessionActive(true) + await vi.advanceTimersByTimeAsync(0) + client.postEvents.mockClear() + client.postSnapshot.mockClear() + + api.emit(RooCodeEventName.Message, { + taskId: "task-2", + action: "created", + message: { ts: 2, type: "say", say: "text", text: "other", partial: false }, + }) + useLiveTask({ taskId: "task-2" }) + await bridge["publishSnapshot"]() + await vi.advanceTimersByTimeAsync(100) + + expect(client.postEvents).not.toHaveBeenCalled() + expect(client.postSnapshot).not.toHaveBeenCalled() + + api.emit(RooCodeEventName.Message, { + taskId: "task-1", + action: "created", + message: { ts: 3, type: "say", say: "text", text: "kept", partial: false }, + }) + await vi.advanceTimersByTimeAsync(100) + expect(client.postEvents).toHaveBeenCalledWith( + "test-session-id", + expect.arrayContaining([ + expect.objectContaining({ taskId: "task-1", message: expect.objectContaining({ text: "kept" }) }), + ]), + ) + bridge.dispose() + }) + + it("stops the session when clearing the consented task fails", async () => { + const bridge = await activateEligibleBridge() + client.postPresence.mockClear() + provider.postStateToWebview.mockClear() + context.globalState.update.mockImplementation(async (key: string, value: unknown) => { + if (key === "zooRemoteControl.consentedTaskId") { + throw new Error("consent write failed") + } + if (key === "zooRemoteControl.sessionActive") { + sessionActive = value === true + } + storedState[key] = value + }) + + await bridge.setSessionActive(false) + + expect(bridge.isActive()).toBe(false) + expect(client.postPresence).toHaveBeenCalledWith(expect.objectContaining({ online: false })) + expect(provider.postStateToWebview).toHaveBeenCalled() + bridge.dispose() + }) + + it("does not start a session when the consented task cannot be stored", async () => { + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + useLiveTask({ taskId: "task-1" }) + context.globalState.update.mockImplementation(async (key: string) => { + if (key === "zooRemoteControl.consentedTaskId") { + throw new Error("consent write failed") + } + }) + + const bridge = createBridge() + await expect(bridge.setSessionActive(true)).rejects.toThrow("consent write failed") + expect(bridge.isActive()).toBe(false) + expect(client.postPresence).not.toHaveBeenCalled() + expect(provider.postStateToWebview).toHaveBeenCalled() + bridge.dispose() + }) + + it("does not start a session when there is no live task", async () => { + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + + const bridge = createBridge() + await expect(bridge.setSessionActive(true)).rejects.toThrow("remote session requires a task") + expect(bridge.isSessionActive()).toBe(false) + expect(bridge.isActive()).toBe(false) + expect(context.globalState.update).not.toHaveBeenCalledWith("zooRemoteControl.sessionActive", true) + expect(client.postPresence).not.toHaveBeenCalled() + bridge.dispose() + }) + + it("does not upload task content when no task was consented", async () => { + delete storedState["zooRemoteControl.consentedTaskId"] + sessionActive = true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + api.emit(RooCodeEventName.Message, { + taskId: "task-1", + action: "created", + message: { ts: 1, type: "say", say: "text", text: "hi", partial: false }, + }) + await vi.advanceTimersByTimeAsync(100) + + expect(client.postEvents).not.toHaveBeenCalled() + bridge.dispose() + }) + + it("does not start a second event post while one is still in flight", async () => { + sessionActive = true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + let releasePost: () => void = () => {} + const postGate = new Promise((resolve) => { + releasePost = resolve + }) + client.postEvents.mockImplementation(() => postGate.then(() => ({ ok: true, status: 200 }))) + + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + api.emit(RooCodeEventName.Message, { + taskId: "task-1", + action: "created", + message: { ts: 1, type: "say", say: "text", text: "first", partial: false }, + }) + await vi.advanceTimersByTimeAsync(100) + api.emit(RooCodeEventName.Message, { + taskId: "task-1", + action: "created", + message: { ts: 2, type: "say", say: "text", text: "second", partial: false }, + }) + await vi.advanceTimersByTimeAsync(100) + expect(client.postEvents).toHaveBeenCalledTimes(1) + + releasePost() + await vi.advanceTimersByTimeAsync(100) + expect(client.postEvents).toHaveBeenCalledTimes(2) + bridge.dispose() + }) + + it("deactivates when the session is no longer eligible", async () => { + sessionActive = true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + expect(bridge.isActive()).toBe(true) + + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.openrouter, + }) + provider.emit(RooCodeEventName.ProviderProfileChanged, { + name: "OpenRouter", + provider: providerIdentifiers.openrouter, + }) + await vi.advanceTimersByTimeAsync(0) + + expect(bridge.isActive()).toBe(false) + bridge.dispose() + }) + + async function activateEligibleBridge() { + sessionActive = true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + expect(bridge.isActive()).toBe(true) + return bridge + } + + it("approves and denies asks on the current task", async () => { + const task = useLiveTask({ pendingAskTs: 42 }) + client.getCommands.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { + commands: [ + { commandId: "a1", type: "approve_ask", taskId: "task-1", payload: { text: "ok", askTs: 42 } }, + { commandId: "d1", type: "deny_ask", taskId: "task-1", payload: { text: "no", askTs: 42 } }, + ], + }, + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + + expect(task.approveAsk).toHaveBeenCalledWith({ text: "ok", images: undefined }) + expect(task.denyAsk).toHaveBeenCalledWith({ text: "no", images: undefined }) + expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "a1", status: "acked" }) + expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "d1", status: "acked" }) + bridge.dispose() + }) + + it("fails ask commands that do not name a task", async () => { + provider.getCurrentTask.mockReturnValue(undefined) + client.getCommands.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { + commands: [ + { commandId: "a1", type: "approve_ask" }, + { commandId: "d1", type: "deny_ask" }, + ], + }, + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + + expect(client.ackCommand).toHaveBeenCalledWith({ + commandId: "a1", + status: "failed", + errorCode: "missing_task_id", + }) + expect(client.ackCommand).toHaveBeenCalledWith({ + commandId: "d1", + status: "failed", + errorCode: "missing_task_id", + }) + bridge.dispose() + }) + + it("rejects commands when eligibility is lost mid-flight", async () => { + client.getCommands.mockImplementation(async () => { + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.openrouter, + }) + return { + ok: true, + status: 200, + body: { commands: [{ commandId: "m1", type: "message", payload: { text: "late" } }] }, + } + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + + expect(client.ackCommand).toHaveBeenCalledWith({ + commandId: "m1", + status: "failed", + errorCode: "not_eligible", + }) + bridge.dispose() + }) + + it("cancels, resumes, starts, and switches mode from remote commands", async () => { + useLiveTask() + provider.handleModeSwitch = vi.fn().mockResolvedValue(undefined) + client.getCommands.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { + commands: [ + { commandId: "c1", type: "cancel_task", taskId: "task-1" }, + { commandId: "r1", type: "resume_task", taskId: "task-9" }, + { commandId: "s1", type: "start_task", payload: { text: "new work" } }, + { commandId: "m1", type: "switch_mode", payload: { mode: "code" } }, + ], + }, + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + + expect(api.cancelCurrentTask).toHaveBeenCalled() + expect(api.resumeTask).toHaveBeenCalledWith("task-9") + expect(api.startNewTask).toHaveBeenCalledWith({ text: "new work", images: undefined }) + expect(provider.handleModeSwitch).toHaveBeenCalledWith("code") + expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "c1", status: "acked" }) + expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "r1", status: "acked" }) + expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "s1", status: "acked" }) + expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "m1", status: "acked" }) + bridge.dispose() + }) + + it("fails resume_task when taskId is missing", async () => { + client.getCommands.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { commands: [{ commandId: "r1", type: "resume_task" }] }, + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + + expect(client.ackCommand).toHaveBeenCalledWith({ + commandId: "r1", + status: "failed", + errorCode: "missing_task_id", + }) + bridge.dispose() + }) + + it("acks duplicate start_task commandIds without starting another task", async () => { + client.getCommands + .mockResolvedValueOnce({ + ok: true, + status: 200, + body: { + commands: [{ commandId: "dup-start", type: "start_task", payload: { text: "first" } }], + }, + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + body: { + commands: [{ commandId: "dup-start", type: "start_task", payload: { text: "again" } }], + }, + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + await vi.advanceTimersByTimeAsync(2_000) + + expect(api.startNewTask).toHaveBeenCalledTimes(1) + expect(api.startNewTask).toHaveBeenCalledWith({ text: "first", images: undefined }) + expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "dup-start", status: "acked" }) + bridge.dispose() + }) + + it("acks execution_failed when a command throws", async () => { + useLiveTask() + api.sendMessage.mockRejectedValueOnce(new Error("boom")) + client.getCommands.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { commands: [{ commandId: "m1", type: "message", taskId: "task-1", payload: { text: "x" } }] }, + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + + expect(client.ackCommand).toHaveBeenCalledWith({ + commandId: "m1", + status: "failed", + errorCode: "execution_failed", + message: "boom", + }) + bridge.dispose() + }) + + it("emits lifecycle events while active", async () => { + const bridge = await activateEligibleBridge() + + api.emit(RooCodeEventName.TaskInteractive, "task-1") + api.emit(RooCodeEventName.TaskIdle, "task-1") + api.emit(RooCodeEventName.TaskResumable, "task-1") + api.emit(RooCodeEventName.TaskCompleted, "task-1") + api.emit(RooCodeEventName.TaskAborted, "task-1") + api.emit(RooCodeEventName.TaskModeSwitched, "task-1", "code") + + await vi.advanceTimersByTimeAsync(100) + + expect(client.postEvents).toHaveBeenCalledWith( + "test-session-id", + expect.arrayContaining([ + expect.objectContaining({ type: "task_interactive", taskId: "task-1" }), + expect.objectContaining({ type: "task_idle", taskId: "task-1" }), + expect.objectContaining({ type: "task_resumable", taskId: "task-1" }), + expect.objectContaining({ type: "task_completed", taskId: "task-1" }), + expect.objectContaining({ type: "task_aborted", taskId: "task-1" }), + expect.objectContaining({ type: "mode_switched", taskId: "task-1", mode: "code" }), + ]), + ) + bridge.dispose() + }) + + it("re-queues event batches when postEvents fails non-404", async () => { + client.postEvents.mockResolvedValue({ ok: false, status: 500 }) + const bridge = await activateEligibleBridge() + + api.emit(RooCodeEventName.TaskIdle, "task-1") + await vi.advanceTimersByTimeAsync(100) + expect(client.postEvents).toHaveBeenCalled() + + client.postEvents.mockClear() + client.postEvents.mockResolvedValue({ ok: true, status: 200 }) + await vi.advanceTimersByTimeAsync(100) + + expect(client.postEvents).toHaveBeenCalledWith( + "test-session-id", + expect.arrayContaining([expect.objectContaining({ type: "task_idle", taskId: "task-1" })]), + ) + bridge.dispose() + }) + + it("ignores command poll responses that are not ok", async () => { + client.getCommands.mockResolvedValue({ ok: false, status: 500 }) + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + expect(client.ackCommand).not.toHaveBeenCalled() + bridge.dispose() + }) + + it("sends message commands to the local API", async () => { + useLiveTask() + client.getCommands.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { + commands: [ + { commandId: "m1", type: "message", taskId: "task-1", payload: { text: "hello", images: ["img"] } }, + ], + }, + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + + expect(api.sendMessage).toHaveBeenCalledWith("hello", ["img"]) + expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "m1", status: "acked" }) + bridge.dispose() + }) + + it("answers follow-ups and can switch mode from the payload", async () => { + useLiveTask({ pendingAskTs: 42 }) + provider.handleModeSwitch = vi.fn().mockResolvedValue(undefined) + client.getCommands.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { + commands: [ + { + commandId: "f1", + type: "answer_followup", + taskId: "task-1", + payload: { text: "option a", mode: "architect", askTs: 42 }, + }, + ], + }, + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + + expect(api.sendMessage).toHaveBeenCalledWith("option a", undefined) + expect(provider.handleModeSwitch).toHaveBeenCalledWith("architect") + expect(client.ackCommand).toHaveBeenCalledWith({ commandId: "f1", status: "acked" }) + bridge.dispose() + }) + + it("fails switch_mode when mode is missing", async () => { + client.getCommands.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { commands: [{ commandId: "m1", type: "switch_mode", payload: {} }] }, + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + + expect(client.ackCommand).toHaveBeenCalledWith({ + commandId: "m1", + status: "failed", + errorCode: "missing_mode", + }) + bridge.dispose() + }) + + it("deactivates on auth/billing presence failures", async () => { + const bridge = await activateEligibleBridge() + client.postPresence.mockResolvedValueOnce({ ok: false, status: 401 }) + await vi.advanceTimersByTimeAsync(15_000) + expect(bridge.isActive()).toBe(false) + bridge.dispose() + }) + + it("marks the bridge unavailable on command 404 and recovers after backoff", async () => { + const bridge = await activateEligibleBridge() + client.getCommands.mockResolvedValueOnce({ ok: false, status: 404 }) + await vi.advanceTimersByTimeAsync(2_000) + + client.getCommands.mockClear() + client.postPresence.mockClear() + await vi.advanceTimersByTimeAsync(15_000) + expect(client.postPresence).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(50_000) + client.postPresence.mockResolvedValue({ ok: true, status: 200 }) + client.getCommands.mockResolvedValue({ ok: true, status: 200, body: { commands: [] } }) + await vi.advanceTimersByTimeAsync(15_000) + expect(client.postPresence).toHaveBeenCalled() + bridge.dispose() + }) + + it("deactivates when command polling returns 403", async () => { + const bridge = await activateEligibleBridge() + client.getCommands.mockResolvedValueOnce({ ok: false, status: 403 }) + await vi.advanceTimersByTimeAsync(2_000) + expect(bridge.isActive()).toBe(false) + bridge.dispose() + }) + + it("publishes a snapshot when the current task has messages", async () => { + storedState["zooRemoteControl.consentedTaskId"] = "task-snap" + provider.getCurrentTask.mockReturnValue({ + taskId: "task-snap", + taskMode: "code", + clineMessages: [ + { ts: 1, type: "say", say: "text", text: "hello" }, + { ts: 2, type: "ask", ask: "tool", text: JSON.stringify({ path: "a.ts", diff: "x" }) }, + ], + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(0) + + expect(client.postSnapshot).toHaveBeenCalledWith( + expect.objectContaining({ + instanceId: "test-session-id", + taskId: "task-snap", + mode: "code", + messages: expect.arrayContaining([expect.objectContaining({ text: "hello" })]), + }), + ) + bridge.dispose() + }) + + it("coalesces partial message events and flushes them", async () => { + const bridge = await activateEligibleBridge() + client.postEvents.mockClear() + + api.emit(RooCodeEventName.Message, { + taskId: "task-1", + action: "updated", + message: { ts: 9, type: "say", say: "text", text: "par", partial: true, messageId: "m9" }, + }) + await vi.advanceTimersByTimeAsync(100) + + expect(client.postEvents).toHaveBeenCalledWith( + "test-session-id", + expect.arrayContaining([ + expect.objectContaining({ + type: "message", + action: "updated", + message: expect.objectContaining({ text: "par", partial: true }), + }), + ]), + ) + bridge.dispose() + }) + + it("throttles rapid partial flushes", async () => { + const bridge = await activateEligibleBridge() + client.postEvents.mockClear() + + api.emit(RooCodeEventName.Message, { + taskId: "task-1", + message: { ts: 1, type: "say", say: "text", text: "a", partial: true, messageId: "p1" }, + }) + api.emit(RooCodeEventName.Message, { + taskId: "task-1", + message: { ts: 1, type: "say", say: "text", text: "ab", partial: true, messageId: "p1" }, + }) + await vi.advanceTimersByTimeAsync(50) + expect(client.postEvents).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(100) + expect(client.postEvents).toHaveBeenCalledTimes(1) + expect(client.postEvents).toHaveBeenCalledWith("test-session-id", [ + expect.objectContaining({ + type: "message", + taskId: "task-1", + message: expect.objectContaining({ text: "ab", partial: true, messageId: "p1" }), + }), + ]) + bridge.dispose() + }) + + it("drops event batches on postEvents 404", async () => { + client.postEvents.mockResolvedValue({ ok: false, status: 404 }) + const bridge = await activateEligibleBridge() + api.emit(RooCodeEventName.TaskIdle, "task-1") + await vi.advanceTimersByTimeAsync(100) + + client.postEvents.mockClear() + client.postEvents.mockResolvedValue({ ok: true, status: 200 }) + await vi.advanceTimersByTimeAsync(100) + // 404 path drops the batch rather than re-queueing it. + expect(client.postEvents).not.toHaveBeenCalled() + bridge.dispose() + }) + + it("re-queues event batches when postEvents returns 401", async () => { + client.postEvents.mockResolvedValue({ ok: false, status: 401 }) + const bridge = await activateEligibleBridge() + api.emit(RooCodeEventName.TaskIdle, "task-1") + await vi.advanceTimersByTimeAsync(100) + expect(client.postEvents).toHaveBeenCalled() + + client.postEvents.mockClear() + client.postEvents.mockResolvedValue({ ok: true, status: 200 }) + // not-eligible backoff is 30s; flush only retries after canCallServer() opens again. + await vi.advanceTimersByTimeAsync(30_000 + 100) + expect(client.postEvents).toHaveBeenCalledWith( + "test-session-id", + expect.arrayContaining([expect.objectContaining({ type: "task_idle", taskId: "task-1" })]), + ) + bridge.dispose() + }) + + it("syncs when already active by republishing presence", async () => { + const bridge = await activateEligibleBridge() + client.postPresence.mockClear() + provider.emit(RooCodeEventName.ProviderProfileChanged, { + name: "Zoo Gateway", + provider: providerIdentifiers.zooGateway, + }) + await vi.advanceTimersByTimeAsync(0) + expect(client.postPresence).toHaveBeenCalled() + expect(bridge.isActive()).toBe(true) + bridge.dispose() + }) + + it("reacts to session start/end and secret changes", async () => { + let secretListener: ((event: { key: string }) => void) | undefined + context.secrets.onDidChange.mockImplementation((listener: (event: { key: string }) => void) => { + secretListener = listener + return { dispose: vi.fn() } + }) + + sessionActive = false + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + expect(bridge.isActive()).toBe(false) + + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + useLiveTask() + await bridge.setSessionActive(true) + await vi.advanceTimersByTimeAsync(0) + expect(bridge.isActive()).toBe(true) + + vi.mocked(getCachedZooCodeToken).mockReturnValue("") + secretListener?.({ key: "zoo-code-session-token" }) + await vi.advanceTimersByTimeAsync(0) + expect(bridge.isActive()).toBe(false) + bridge.dispose() + }) + + it("uses this window's session id when another window stored an instance id", async () => { + sessionActive = true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + expect(bridge.getInstanceId()).toBe("test-session-id") + expect(context.globalState.update).not.toHaveBeenCalledWith("zooRemoteControl.instanceId", expect.anything()) + expect(client.postPresence).toHaveBeenCalledWith(expect.objectContaining({ instanceId: "test-session-id" })) + bridge.dispose() + }) + + it("awaits serialized event-seq persistence and stops enqueueing after persist failure", async () => { + sessionActive = true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.zooGateway, + }) + + let rejectSeq: (error: Error) => void = () => {} + const seqWrite = new Promise((_resolve, reject) => { + rejectSeq = reject + }) + context.globalState.update.mockImplementation((key: string) => { + if (key === "zooRemoteControl.eventSeq") { + return seqWrite + } + return Promise.resolve() + }) + + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + expect(bridge.isActive()).toBe(true) + + api.emit(RooCodeEventName.TaskIdle, "task-1") + await Promise.resolve() + await Promise.resolve() + expect(context.globalState.update).toHaveBeenCalledWith("zooRemoteControl.eventSeq", expect.any(Number)) + + rejectSeq(new Error("seq write failed")) + await Promise.resolve() + await Promise.resolve() + + client.postEvents.mockClear() + api.emit(RooCodeEventName.TaskIdle, "task-2") + await vi.advanceTimersByTimeAsync(200) + expect(client.postEvents).not.toHaveBeenCalled() + bridge.dispose() + }) + + it("tolerates provider settings lookup failures", async () => { + sessionActive = true + vi.mocked(getCachedZooCodeToken).mockReturnValue("zoo_ext_token") + provider.contextProxy.getProviderSettings.mockImplementation(() => { + throw new Error("settings boom") + }) + const bridge = createBridge() + await vi.advanceTimersByTimeAsync(0) + expect(bridge.isActive()).toBe(false) + bridge.dispose() + }) + + it("skips snapshot publish when the current task has no messages", async () => { + provider.getCurrentTask.mockReturnValue({ taskId: "task-empty", clineMessages: undefined }) + const bridge = await activateEligibleBridge() + expect(client.postSnapshot).not.toHaveBeenCalled() + bridge.dispose() + }) + + it("publishes snapshot 404 as unavailable without crashing", async () => { + storedState["zooRemoteControl.consentedTaskId"] = "task-snap" + provider.getCurrentTask.mockReturnValue({ + taskId: "task-snap", + clineMessages: [{ ts: 1, type: "say", say: "text", text: "hi" }], + }) + client.postSnapshot.mockResolvedValue({ ok: false, status: 404 }) + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(0) + expect(client.postSnapshot).toHaveBeenCalled() + // Snapshot 404 shares the 60s unavailable backoff — presence must not fire again yet. + client.postPresence.mockClear() + await vi.advanceTimersByTimeAsync(15_000) + expect(client.postPresence).not.toHaveBeenCalled() + bridge.dispose() + }) + + it("deactivates during command poll when eligibility is lost before fetch", async () => { + const bridge = await activateEligibleBridge() + provider.contextProxy.getProviderSettings.mockReturnValue({ + apiProvider: providerIdentifiers.openrouter, + }) + client.getCommands.mockClear() + await vi.advanceTimersByTimeAsync(2_000) + expect(bridge.isActive()).toBe(false) + expect(client.getCommands).not.toHaveBeenCalled() + bridge.dispose() + }) + + it("ignores command poll results after dispose", async () => { + let resolveCommands: (value: { + ok: boolean + status: number + body: { commands: Array<{ commandId: string; type: string; payload?: { text?: string } }> } + }) => void = () => {} + client.getCommands.mockImplementation( + () => + new Promise((resolve) => { + resolveCommands = resolve + }), + ) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + expect(client.getCommands).toHaveBeenCalledTimes(1) + + bridge.dispose() + resolveCommands({ + ok: true, + status: 200, + body: { + commands: [{ commandId: "late-1", type: "message", payload: { text: "too late" } }], + }, + }) + await Promise.resolve() + await Promise.resolve() + + expect(api.sendMessage).not.toHaveBeenCalled() + expect(client.ackCommand).not.toHaveBeenCalled() + }) + + it("does not start overlapping command polls while one is in flight", async () => { + let resolveCommands: (value: { ok: boolean; status: number; body: { commands: [] } }) => void = () => {} + client.getCommands.mockImplementation( + () => + new Promise((resolve) => { + resolveCommands = resolve + }), + ) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + expect(client.getCommands).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(2_000) + expect(client.getCommands).toHaveBeenCalledTimes(1) + + resolveCommands({ ok: true, status: 200, body: { commands: [] } }) + await Promise.resolve() + await Promise.resolve() + + await vi.advanceTimersByTimeAsync(2_000) + expect(client.getCommands).toHaveBeenCalledTimes(2) + bridge.dispose() + }) + + it("acks invalid commands from the poll and still runs the valid ones", async () => { + useLiveTask() + client.getCommands.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { + commands: [{ commandId: "good", type: "message", taskId: "task-1", payload: { text: "hi" } }], + invalidCommandIds: ["bad"], + }, + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + + expect(client.ackCommand).toHaveBeenCalledWith({ + commandId: "bad", + status: "failed", + errorCode: "invalid_command", + }) + expect(api.sendMessage).toHaveBeenCalledWith("hi", undefined) + bridge.dispose() + }) + + it("acks unknown command types as failed", async () => { + client.getCommands.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { + commands: [{ commandId: "u1", type: "not_a_real_command" }], + }, + }) + + const bridge = await activateEligibleBridge() + await vi.advanceTimersByTimeAsync(2_000) + + expect(client.ackCommand).toHaveBeenCalledWith( + expect.objectContaining({ + commandId: "u1", + status: "failed", + errorCode: "unknown_command", + }), + ) + bridge.dispose() + }) +}) diff --git a/webview-ui/src/components/chat/TaskHeader.tsx b/webview-ui/src/components/chat/TaskHeader.tsx index 0941a22e2b..fc5911695e 100644 --- a/webview-ui/src/components/chat/TaskHeader.tsx +++ b/webview-ui/src/components/chat/TaskHeader.tsx @@ -11,7 +11,7 @@ import { } from "lucide-react" import prettyBytes from "pretty-bytes" -import type { ClineMessage } from "@roo-code/types" +import { providerIdentifiers, type ClineMessage } from "@roo-code/types" import { getModelMaxOutputTokens } from "@roo/api" @@ -63,7 +63,14 @@ const TaskHeader = ({ todos, }: TaskHeaderProps) => { const { t } = useTranslation() - const { apiConfiguration, currentTaskItem } = useExtensionState() + const { + apiConfiguration, + currentTaskItem, + zooCodeIsAuthenticated, + zooCodeBaseUrl, + remoteControlEnabled, + remoteControlAvailable, + } = useExtensionState() const { id: modelId, info: model } = useSelectedModel(apiConfiguration) const [isTaskExpanded, setIsTaskExpanded] = useState(false) @@ -71,6 +78,27 @@ const TaskHeader = ({ const textRef = useRef(null) const contextWindow = model?.contextWindow || 1 + const isZooGateway = apiConfiguration?.apiProvider === providerIdentifiers.zooGateway + const canShowRemoteSteer = Boolean(zooCodeIsAuthenticated && currentTaskItem) + + const openRemoteDashboard = () => { + const base = (zooCodeBaseUrl || "https://www.zoocode.dev").replace(/\/$/, "") + vscode.postMessage({ type: "openExternal", url: `${base}/dashboard/agents/ide` }) + } + + const openProviderSettings = () => { + vscode.postMessage({ type: "switchTab", tab: "settings" }) + } + + const startRemoteSessionAndOpenDashboard = () => { + vscode.postMessage({ type: "startRemoteControlSession" }) + openRemoteDashboard() + } + + const endRemoteSession = () => { + vscode.postMessage({ type: "endRemoteControlSession" }) + } + // Calculate maxTokens (reserved for output) once for reuse in percentage and tooltip const maxTokens = useMemo( () => @@ -142,6 +170,82 @@ const TaskHeader = ({ )} + {canShowRemoteSteer && isZooGateway && remoteControlEnabled && remoteControlAvailable && ( +
e.stopPropagation()} + data-testid="remote-steer-active"> + {t("chat:task.steerRemotelyActive")} +
+ + +
+
+ )} + {canShowRemoteSteer && isZooGateway && remoteControlEnabled && !remoteControlAvailable && ( +
e.stopPropagation()} + data-testid="remote-steer-unavailable"> + {t("chat:task.steerRemotelyUnavailable")} +
+ + +
+
+ )} + {canShowRemoteSteer && isZooGateway && !remoteControlEnabled && ( +
e.stopPropagation()} + data-testid="remote-steer-offer"> + + {t("chat:task.steerRemotelyHint")} + + +
+ )} + {canShowRemoteSteer && !isZooGateway && ( +
e.stopPropagation()} + data-testid="remote-steer-needs-gateway"> + + {t("chat:task.steerRemotelyNeedsGateway")} + + +
+ )}
({ VSCodeBadge: ({ children }: { children: React.ReactNode }) =>
{children}
, })) +vi.mock("@src/components/common/DismissibleUpsell", () => ({ + default: ({ + children, + onClick, + upsellId, + }: { + children: React.ReactNode + onClick?: () => void + upsellId: string + }) => ( + + ), +})) + // Create a variable to hold the mock state const mockExtensionState: { apiConfiguration: ProviderSettings currentTaskItem: { id: string } | null clineMessages: any[] taskHistory: any[] + zooCodeIsAuthenticated?: boolean + zooCodeBaseUrl?: string + remoteControlEnabled?: boolean + remoteControlAvailable?: boolean } = { apiConfiguration: { apiProvider: providerIdentifiers.anthropic, @@ -50,6 +70,9 @@ const mockExtensionState: { currentTaskItem: { id: "test-task-id" }, clineMessages: [], taskHistory: [], + zooCodeIsAuthenticated: false, + remoteControlEnabled: false, + remoteControlAvailable: false, } // Mock the ExtensionStateContext @@ -330,4 +353,117 @@ describe("TaskHeader", () => { expect(screen.getByText("25%")).toBeInTheDocument() }) }) + + describe("remote steer control", () => { + beforeEach(() => { + mockPostMessage.mockClear() + mockExtensionState.apiConfiguration = { + apiProvider: providerIdentifiers.zooGateway, + } as ProviderSettings + mockExtensionState.zooCodeIsAuthenticated = true + mockExtensionState.remoteControlEnabled = false + mockExtensionState.remoteControlAvailable = false + mockExtensionState.zooCodeBaseUrl = undefined + mockExtensionState.currentTaskItem = { id: "test-task-id" } + mockExtensionState.clineMessages = [{ type: "say", say: "completion_result", ts: 1, text: "done" }] + }) + + afterEach(() => { + mockExtensionState.apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "test-api-key", + apiModelId: "claude-3-opus-20240229", + } as ProviderSettings + mockExtensionState.zooCodeIsAuthenticated = false + mockExtensionState.remoteControlEnabled = false + mockExtensionState.remoteControlAvailable = false + mockExtensionState.zooCodeBaseUrl = undefined + mockExtensionState.currentTaskItem = { id: "test-task-id" } + mockExtensionState.clineMessages = [] + }) + + it("shows an immediate control to start a remote session, including on completed tasks", () => { + mockExtensionState.zooCodeBaseUrl = "https://www.zoocode.dev" + renderTaskHeader() + const offer = screen.getByTestId("remote-steer-offer") + expect(offer).toHaveTextContent("chat:task.steerRemotelyHint") + fireEvent.click(screen.getByRole("button", { name: "chat:task.steerRemotely" })) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "startRemoteControlSession" }) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "openExternal", + url: "https://www.zoocode.dev/dashboard/agents/ide", + }) + }) + + it("shows the active remote session strip with dashboard and end-session actions", () => { + mockExtensionState.remoteControlEnabled = true + mockExtensionState.remoteControlAvailable = true + mockExtensionState.zooCodeBaseUrl = "https://www.zoocode.dev" + renderTaskHeader() + + expect(screen.getByTestId("remote-steer-active")).toHaveTextContent("chat:task.steerRemotelyActive") + fireEvent.click(screen.getByText("chat:task.openRemoteDashboard")) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "openExternal", + url: "https://www.zoocode.dev/dashboard/agents/ide", + }) + fireEvent.click(screen.getByText("chat:task.endRemoteSession")) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "endRemoteControlSession" }) + }) + + it("shows unavailable state when remote control is enabled but the bridge is down", () => { + mockExtensionState.remoteControlEnabled = true + mockExtensionState.remoteControlAvailable = false + renderTaskHeader() + + expect(screen.getByTestId("remote-steer-unavailable")).toHaveTextContent( + "chat:task.steerRemotelyUnavailable", + ) + expect(screen.queryByTestId("remote-steer-active")).not.toBeInTheDocument() + }) + + it("prompts to switch to Zoo Gateway when the active profile is not eligible", () => { + mockExtensionState.apiConfiguration = { + apiProvider: providerIdentifiers.anthropic, + apiKey: "test-api-key", + apiModelId: "claude-3-opus-20240229", + } as ProviderSettings + renderTaskHeader() + + expect(screen.getByTestId("remote-steer-needs-gateway")).toHaveTextContent( + "chat:task.steerRemotelyNeedsGateway", + ) + fireEvent.click(screen.getByRole("button", { name: "chat:task.steerRemotelyOpenSettings" })) + expect(mockPostMessage).toHaveBeenCalledWith({ type: "switchTab", tab: "settings" }) + }) + + it("hides all remote-steer banners when unauthenticated or there is no current task", () => { + mockExtensionState.zooCodeIsAuthenticated = false + renderTaskHeader() + expect(screen.queryByTestId("remote-steer-offer")).not.toBeInTheDocument() + expect(screen.queryByTestId("remote-steer-active")).not.toBeInTheDocument() + expect(screen.queryByTestId("remote-steer-unavailable")).not.toBeInTheDocument() + expect(screen.queryByTestId("remote-steer-needs-gateway")).not.toBeInTheDocument() + + mockExtensionState.zooCodeIsAuthenticated = true + mockExtensionState.currentTaskItem = null as never + renderTaskHeader() + expect(screen.queryByTestId("remote-steer-offer")).not.toBeInTheDocument() + expect(screen.queryByTestId("remote-steer-active")).not.toBeInTheDocument() + expect(screen.queryByTestId("remote-steer-unavailable")).not.toBeInTheDocument() + expect(screen.queryByTestId("remote-steer-needs-gateway")).not.toBeInTheDocument() + }) + + it("normalizes a trailing slash on the Zoo base URL for dashboard links", () => { + mockExtensionState.remoteControlEnabled = true + mockExtensionState.remoteControlAvailable = true + mockExtensionState.zooCodeBaseUrl = "https://www.zoocode.dev/" + renderTaskHeader() + fireEvent.click(screen.getByText("chat:task.openRemoteDashboard")) + expect(mockPostMessage).toHaveBeenCalledWith({ + type: "openExternal", + url: "https://www.zoocode.dev/dashboard/agents/ide", + }) + }) + }) }) diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index d934d1105b..cc53763ff5 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -19,7 +19,15 @@ "openUiHistory": "Obrir historial d'UI", "backToParentTask": "Tasca principal", "waitingOnSubtask": "Esperant subtasca", - "goToSubtask": "Anar a la subtasca" + "goToSubtask": "Anar a la subtasca", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Sends this task's conversation, file contents, and command output to your Zoo account. Zoo keeps that transcript for 7 days so you can approve, reply, and continue the task from the dashboard.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Desfixar", "pin": "Fixar", diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 1ac60a097e..b994c2ec03 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -19,7 +19,15 @@ "openUiHistory": "UI-Verlauf öffnen", "backToParentTask": "Übergeordnete Aufgabe", "waitingOnSubtask": "Wartet auf Unteraufgabe", - "goToSubtask": "Zur Unteraufgabe" + "goToSubtask": "Zur Unteraufgabe", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Sends this task's conversation, file contents, and command output to your Zoo account. Zoo keeps that transcript for 7 days so you can approve, reply, and continue the task from the dashboard.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Lösen von oben", "pin": "Anheften", diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 51aee7a438..5d15ff57bd 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -19,7 +19,15 @@ "openUiHistory": "Open UI History", "backToParentTask": "Parent task", "waitingOnSubtask": "Waiting on subtask", - "goToSubtask": "Go to subtask" + "goToSubtask": "Go to subtask", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Sends this task's conversation, file contents, and command output to your Zoo account. Zoo keeps that transcript for 7 days so you can approve, reply, and continue the task from the dashboard.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Unpin", "pin": "Pin", diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index fbe66d6eb3..d88f81a543 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -19,7 +19,15 @@ "openUiHistory": "Abrir historial de UI", "backToParentTask": "Tarea principal", "waitingOnSubtask": "Esperando subtarea", - "goToSubtask": "Ir a la subtarea" + "goToSubtask": "Ir a la subtarea", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Sends this task's conversation, file contents, and command output to your Zoo account. Zoo keeps that transcript for 7 days so you can approve, reply, and continue the task from the dashboard.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Desfijar", "pin": "Fijar", diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index a8f5766c85..6d522c52b5 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -19,7 +19,15 @@ "openUiHistory": "Ouvrir l'historique de l'UI", "backToParentTask": "Tâche parente", "waitingOnSubtask": "En attente de sous-tâche", - "goToSubtask": "Aller à la sous-tâche" + "goToSubtask": "Aller à la sous-tâche", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Sends this task's conversation, file contents, and command output to your Zoo account. Zoo keeps that transcript for 7 days so you can approve, reply, and continue the task from the dashboard.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Désépingler", "pin": "Épingler", diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index c6dbe9978d..5546f2461d 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -19,7 +19,15 @@ "openUiHistory": "UI इतिहास खोलें", "backToParentTask": "मूल कार्य", "waitingOnSubtask": "उपकार्य की प्रतीक्षा", - "goToSubtask": "उपकार्य पर जाएं" + "goToSubtask": "उपकार्य पर जाएं", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Sends this task's conversation, file contents, and command output to your Zoo account. Zoo keeps that transcript for 7 days so you can approve, reply, and continue the task from the dashboard.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "पिन करें", "pin": "अवपिन करें", diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index c59be94c72..a67d03ae3a 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -19,7 +19,15 @@ "openUiHistory": "Buka Riwayat UI", "backToParentTask": "Tugas Induk", "waitingOnSubtask": "Menunggu subtugas", - "goToSubtask": "Pergi ke subtugas" + "goToSubtask": "Pergi ke subtugas", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Sends this task's conversation, file contents, and command output to your Zoo account. Zoo keeps that transcript for 7 days so you can approve, reply, and continue the task from the dashboard.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "history": { "title": "Riwayat" diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index bb809a41b6..4048bdd32a 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -19,7 +19,15 @@ "openUiHistory": "Apri cronologia UI", "backToParentTask": "Attività principale", "waitingOnSubtask": "In attesa di sottoattività", - "goToSubtask": "Vai alla sottoattività" + "goToSubtask": "Vai alla sottoattività", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Sends this task's conversation, file contents, and command output to your Zoo account. Zoo keeps that transcript for 7 days so you can approve, reply, and continue the task from the dashboard.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Rilascia", "pin": "Fissa", diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 18d7d0a7a8..ba479845d0 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -19,7 +19,15 @@ "openUiHistory": "UI履歴を開く", "backToParentTask": "親タスク", "waitingOnSubtask": "サブタスク待ち", - "goToSubtask": "サブタスクへ" + "goToSubtask": "サブタスクへ", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Sends this task's conversation, file contents, and command output to your Zoo account. Zoo keeps that transcript for 7 days so you can approve, reply, and continue the task from the dashboard.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "ピン留めを解除", "pin": "ピン留め", diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 95971b5e31..5d57d534d8 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -19,7 +19,15 @@ "openUiHistory": "UI 기록 열기", "backToParentTask": "상위 작업", "waitingOnSubtask": "하위 작업 대기 중", - "goToSubtask": "하위 작업으로 이동" + "goToSubtask": "하위 작업으로 이동", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Sends this task's conversation, file contents, and command output to your Zoo account. Zoo keeps that transcript for 7 days so you can approve, reply, and continue the task from the dashboard.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "고정 해제하기", "pin": "고정하기", diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index b8d2689327..35070f637f 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -19,7 +19,15 @@ "openUiHistory": "UI-geschiedenis openen", "backToParentTask": "Bovenliggende taak", "waitingOnSubtask": "Wacht op subtaak", - "goToSubtask": "Ga naar subtaak" + "goToSubtask": "Ga naar subtaak", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Sends this task's conversation, file contents, and command output to your Zoo account. Zoo keeps that transcript for 7 days so you can approve, reply, and continue the task from the dashboard.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Losmaken", "pin": "Vastmaken", diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index 1f8271f16e..d4541d57ab 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -19,7 +19,15 @@ "openUiHistory": "Otwórz historię UI", "backToParentTask": "Zadanie nadrzędne", "waitingOnSubtask": "Oczekuje na podzadanie", - "goToSubtask": "Przejdź do podzadania" + "goToSubtask": "Przejdź do podzadania", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Sends this task's conversation, file contents, and command output to your Zoo account. Zoo keeps that transcript for 7 days so you can approve, reply, and continue the task from the dashboard.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Odepnij", "pin": "Przypnij", diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 231cf54d70..72a6d91a1c 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -19,7 +19,15 @@ "openUiHistory": "Abrir histórico da UI", "backToParentTask": "Tarefa pai", "waitingOnSubtask": "Aguardando subtarefa", - "goToSubtask": "Ir para subtarefa" + "goToSubtask": "Ir para subtarefa", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Sends this task's conversation, file contents, and command output to your Zoo account. Zoo keeps that transcript for 7 days so you can approve, reply, and continue the task from the dashboard.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Desfixar", "pin": "Fixar", diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index 0bcb4176a3..87b319ac27 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -19,7 +19,15 @@ "openUiHistory": "Открыть историю UI", "backToParentTask": "Родительская задача", "waitingOnSubtask": "Ожидание подзадачи", - "goToSubtask": "Перейти к подзадаче" + "goToSubtask": "Перейти к подзадаче", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Sends this task's conversation, file contents, and command output to your Zoo account. Zoo keeps that transcript for 7 days so you can approve, reply, and continue the task from the dashboard.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Открепить", "pin": "Закрепить", diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index 40c7d37b45..c2d4fc8dee 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -19,7 +19,15 @@ "openUiHistory": "UI Geçmişini Aç", "backToParentTask": "Üst görev", "waitingOnSubtask": "Alt görev bekleniyor", - "goToSubtask": "Alt göreve git" + "goToSubtask": "Alt göreve git", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Sends this task's conversation, file contents, and command output to your Zoo account. Zoo keeps that transcript for 7 days so you can approve, reply, and continue the task from the dashboard.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Sabitlemeyi iptal et", "pin": "Sabitle", diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index ef0bd5b59a..7e5aba5333 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -19,7 +19,15 @@ "openUiHistory": "Mở lịch sử UI", "backToParentTask": "Nhiệm vụ cha", "waitingOnSubtask": "Đang chờ nhiệm vụ con", - "goToSubtask": "Đến nhiệm vụ con" + "goToSubtask": "Đến nhiệm vụ con", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Sends this task's conversation, file contents, and command output to your Zoo account. Zoo keeps that transcript for 7 days so you can approve, reply, and continue the task from the dashboard.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "Bỏ ghim khỏi đầu", "pin": "Ghim lên đầu", diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index b3e8f35297..ca62241736 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -19,7 +19,15 @@ "openUiHistory": "打开 UI 历史", "backToParentTask": "父任务", "waitingOnSubtask": "等待子任务", - "goToSubtask": "前往子任务" + "goToSubtask": "前往子任务", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Sends this task's conversation, file contents, and command output to your Zoo account. Zoo keeps that transcript for 7 days so you can approve, reply, and continue the task from the dashboard.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "取消置顶", "pin": "置顶", diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 11a90a9178..95c3679835 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -19,7 +19,15 @@ "openUiHistory": "開啟 UI 歷史紀錄", "backToParentTask": "上層工作", "waitingOnSubtask": "等待子任務", - "goToSubtask": "前往子任務" + "goToSubtask": "前往子任務", + "steerRemotely": "Steer this task remotely", + "steerRemotelyHint": "Sends this task's conversation, file contents, and command output to your Zoo account. Zoo keeps that transcript for 7 days so you can approve, reply, and continue the task from the dashboard.", + "steerRemotelyNeedsGateway": "Switch this profile to Zoo Gateway before you can steer the task remotely.", + "steerRemotelyOpenSettings": "Open settings", + "steerRemotelyActive": "Remote session is active, control this task from the Zoo dashboard.", + "steerRemotelyUnavailable": "Remote session is on, but the Zoo dashboard endpoint is unreachable. Retry shortly or check your connection.", + "openRemoteDashboard": "Open dashboard", + "endRemoteSession": "End remote session" }, "unpin": "取消釘選", "pin": "釘選",