Files
cline/apps/examples/desktop-app/sidecar/context.test.ts
T
1fbcfab05d test: cover session search fallback on hub timeout and rejection (#13642)
* feat: add searchable session history

Rebased onto main and updated to supersede the sidebar search dialog
from #13533: the sidebar search icon now opens the indexed command bar
(Cmd/Ctrl+P) instead of a sidebar-local cmdk dialog that eagerly loaded
the entire session history via loadAllSessions(). CommandDialog gains a
shouldFilter passthrough so server-ranked FTS hits are displayed as-is.

* fix: harden session history search

* fix: evict failed restoration sessions from search

* fix: preserve deletion when search eviction fails

* fix: address session search review feedback

* fix: preserve search suppression during reconciliation

* test: cover sidecar search fallback on hub timeout and rejection

The existing search_sessions tests only exercised the index-hit and
empty-index-fallback paths with an immediately-resolved hub reply.
Add coverage for the two other realistic Hub-connection failure
modes the fallback is meant to tolerate: the hub call rejecting, and
the hub call hanging past the 750ms withSearchDeadline race.

---------

Co-authored-by: abeatrix <beatrix@cline.bot>
Co-authored-by: Bee <68532117+abeatrix@users.noreply.github.com>
2026-08-28 19:06:47 +02:00

1247 lines
35 KiB
TypeScript

import { existsSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { RuntimeCapabilities } from "@cline/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { materializeUserFiles } from "./attachments";
import type { LiveSession, SidecarContext } from "./types";
const createCoreMock = vi.hoisted(() => vi.fn());
const connectMock = vi.hoisted(() => vi.fn());
const ensureCompatibleLocalHubUrlMock = vi.hoisted(() => vi.fn());
const hubCommandMock = vi.hoisted(() => vi.fn());
const hubGetConnectionErrorMock = vi.hoisted(() => vi.fn());
const hubGetUrlMock = vi.hoisted(() => vi.fn());
const hubIsConnectedMock = vi.hoisted(() => vi.fn());
const nodeHubClientCtorMock = vi.hoisted(() => vi.fn());
const subscribeMock = vi.hoisted(() => vi.fn());
const updateCapabilitiesMock = vi.hoisted(() => vi.fn());
vi.mock("@ai-sdk/provider-utils", () => ({
createProviderDefinedToolFactory: vi.fn(() => vi.fn()),
}));
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
ClineCore: {
create: createCoreMock,
},
ensureCompatibleLocalHubUrl: ensureCompatibleLocalHubUrlMock,
NodeHubClient: class {
constructor(options: unknown) {
nodeHubClientCtorMock(options);
}
connect = connectMock;
command = hubCommandMock;
getConnectionError = hubGetConnectionErrorMock;
getUrl = hubGetUrlMock;
isConnected = hubIsConnectedMock;
subscribe = subscribeMock;
updateCapabilities = updateCapabilitiesMock;
dispose = vi.fn();
},
};
});
function readEvents(ctx: SidecarContext): Array<{
event: { name: string; payload: Record<string, unknown> };
}> {
const [client] = ctx.wsClients;
const send = client?.send;
if (!send || typeof send !== "function" || !("mock" in send)) {
return [];
}
return (send as ReturnType<typeof vi.fn>).mock.calls.map(([raw]) =>
JSON.parse(String(raw)),
);
}
describe("Code sidecar runtime capabilities", () => {
beforeEach(() => {
createCoreMock.mockReset();
connectMock.mockReset();
ensureCompatibleLocalHubUrlMock.mockReset();
hubCommandMock.mockReset();
hubGetConnectionErrorMock.mockReset();
hubGetUrlMock.mockReset();
hubIsConnectedMock.mockReset();
nodeHubClientCtorMock.mockReset();
subscribeMock.mockReset();
updateCapabilitiesMock.mockReset();
connectMock.mockResolvedValue(undefined);
ensureCompatibleLocalHubUrlMock.mockResolvedValue(
"ws://127.0.0.1:25463/hub",
);
hubCommandMock.mockResolvedValue({ ok: true, payload: {} });
hubGetConnectionErrorMock.mockReturnValue(null);
hubGetUrlMock.mockReturnValue("ws://127.0.0.1:25463/hub");
hubIsConnectedMock.mockReturnValue(true);
subscribeMock.mockReturnValue(() => {});
updateCapabilitiesMock.mockResolvedValue(undefined);
createCoreMock.mockResolvedValue({
runtimeAddress: "ws://127.0.0.1:25463/hub",
subscribe: vi.fn(() => () => {}),
dispose: vi.fn(),
});
});
it("registers the desktop capability factory with core", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
expect(createCoreMock).toHaveBeenCalledWith(
expect.objectContaining({
backendMode: "hub",
capabilities: expect.objectContaining({
toolExecutors: expect.objectContaining({
askQuestion: expect.any(Function),
}),
requestToolApproval: expect.any(Function),
}),
hub: expect.objectContaining({
strategy: "require-hub",
workspaceRoot: "/workspace/project",
cwd: "/workspace/project",
clientType: "code-sidecar",
displayName: "Cline Desktop sidecar",
}),
}),
);
const hubOptions = createCoreMock.mock.calls[0][0].hub;
expect(hubOptions).not.toHaveProperty("endpoint");
expect(hubOptions).not.toHaveProperty("authToken");
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:25463/hub",
clientType: "code-sidecar-observer",
displayName: "Cline Desktop observer",
}),
);
});
it("wires the desktop logger and telemetry through the shared Hub client", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const logger = {
debug: vi.fn(),
log: vi.fn(),
error: vi.fn(),
};
const telemetry = { capture: vi.fn() };
const ctx = createSidecarContext("/workspace/project", {
logger,
telemetry: telemetry as never,
});
await initializeSessionManager(ctx);
expect(createCoreMock).toHaveBeenCalledWith(
expect.objectContaining({
clientName: "cline-code",
logger,
telemetry,
}),
);
});
it("reports the connected shared Hub endpoint in process context", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const { handleCommand } = await import("./commands");
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
const session = {
config: {},
messages: [],
promptsInQueue: [],
busy: true,
startedAt: Date.now(),
status: "running",
} satisfies LiveSession;
ctx.liveSessions.set("running-session", session);
ctx.liveSessions.set("idle-session", {
...session,
busy: false,
status: "idle",
});
await expect(handleCommand(ctx, "get_process_context")).resolves.toEqual(
expect.objectContaining({
runningSessionCount: 1,
hub: {
status: "connected",
url: "ws://127.0.0.1:25463/hub",
error: null,
},
}),
);
});
it("starts or reuses the shared Hub when a command needs a client", async () => {
const { createSidecarContext, ensureSharedHubClient } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
const hubClient = await ensureSharedHubClient(ctx);
expect(hubClient).toBe(ctx.hubClient);
expect(ensureCompatibleLocalHubUrlMock).toHaveBeenCalledWith({
strategy: "require-hub",
workspaceRoot: "/workspace/project",
cwd: "/workspace/project",
});
expect(nodeHubClientCtorMock).toHaveBeenCalledWith(
expect.objectContaining({
url: "ws://127.0.0.1:25463/hub",
clientType: "code-sidecar-observer",
}),
);
expect(connectMock).toHaveBeenCalledOnce();
});
it("returns indexed search results without listing every session", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const ctx = createSidecarContext("/workspace/project");
const oversizedPrompt = `<user_input mode="act">${"generate an image ".repeat(3_000)}</user_input>`;
const hits = [
{
sessionId: "session-1",
documentId: "session-1:0",
ordinal: 0,
role: "user",
startedAt: "2026-08-27T12:00:00.000Z",
workspaceRoot: "/workspace/project",
title: oversizedPrompt,
snippet: oversizedPrompt,
score: -1,
},
];
const command = vi.fn(async () => ({ ok: true, payload: { hits } }));
const list = vi.fn(async () => []);
ctx.hubClient = { command } as never;
ctx.sessionManager = { list } as never;
const results = (await handleCommand(ctx, "search_sessions", {
query: "generate",
})) as Array<{ title: string; snippet: string }>;
expect(results).toEqual([
expect.objectContaining({
sessionId: "session-1",
documentId: "session-1:0",
}),
]);
expect(results[0]?.title.length).toBeLessThanOrEqual(240);
expect(results[0]?.snippet.length).toBeLessThanOrEqual(480);
expect(results[0]?.title).not.toContain("user_input");
expect(results[0]?.snippet).not.toContain("user_input");
expect(command).toHaveBeenCalledWith("session.search", {
query: "generate",
limit: 50,
workspaceRoot: undefined,
});
expect(list).not.toHaveBeenCalled();
});
it("falls back to session metadata while the index has no hits", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const ctx = createSidecarContext("/workspace/project");
const command = vi.fn(async () => ({ ok: true, payload: { hits: [] } }));
const oversizedPrompt = `<user_input mode="act">${"generate an image ".repeat(3_000)}</user_input>`;
const list = vi.fn(async () => [
{
sessionId: "session-1",
startedAt: "2026-08-27T12:00:00.000Z",
workspaceRoot: "/workspace/project",
prompt: oversizedPrompt,
metadata: { title: oversizedPrompt },
},
]);
ctx.hubClient = { command } as never;
ctx.sessionManager = { list } as never;
const results = (await handleCommand(ctx, "search_sessions", {
query: "generate",
})) as Array<{ title: string; snippet: string }>;
expect(results).toEqual([
expect.objectContaining({
sessionId: "session-1",
documentId: "session-1:metadata",
}),
]);
expect(results[0]?.title.length).toBeLessThanOrEqual(240);
expect(results[0]?.snippet.length).toBeLessThanOrEqual(480);
expect(results[0]?.title).not.toContain("user_input");
expect(results[0]?.snippet).not.toContain("user_input");
expect(list).toHaveBeenCalledOnce();
});
it("falls back to session metadata when the hub search call rejects", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const ctx = createSidecarContext("/workspace/project");
const command = vi.fn(async () => {
throw new Error("hub connection lost");
});
const list = vi.fn(async () => [
{
sessionId: "session-1",
startedAt: "2026-08-27T12:00:00.000Z",
workspaceRoot: "/workspace/project",
prompt: "generate an image of a puppy",
metadata: { title: "generate an image of a puppy" },
},
]);
ctx.hubClient = { command } as never;
ctx.sessionManager = { list } as never;
const results = (await handleCommand(ctx, "search_sessions", {
query: "generate",
})) as Array<{ sessionId: string; documentId: string }>;
expect(results).toEqual([
expect.objectContaining({
sessionId: "session-1",
documentId: "session-1:metadata",
}),
]);
expect(command).toHaveBeenCalledOnce();
expect(list).toHaveBeenCalledOnce();
});
it("falls back to session metadata when the hub search call exceeds the deadline", async () => {
vi.useFakeTimers();
try {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
const ctx = createSidecarContext("/workspace/project");
// Never resolves: exercises the withSearchDeadline race timing out
// rather than the hub call rejecting.
const command = vi.fn(() => new Promise(() => {}));
const list = vi.fn(async () => [
{
sessionId: "session-1",
startedAt: "2026-08-27T12:00:00.000Z",
workspaceRoot: "/workspace/project",
prompt: "generate an image of a puppy",
metadata: { title: "generate an image of a puppy" },
},
]);
ctx.hubClient = { command } as never;
ctx.sessionManager = { list } as never;
const pending = handleCommand(ctx, "search_sessions", {
query: "generate",
}) as Promise<Array<{ sessionId: string; documentId: string }>>;
await vi.advanceTimersByTimeAsync(750);
const results = await pending;
expect(results).toEqual([
expect.objectContaining({
sessionId: "session-1",
documentId: "session-1:metadata",
}),
]);
expect(command).toHaveBeenCalledOnce();
expect(list).toHaveBeenCalledOnce();
} finally {
vi.useRealTimers();
}
});
it("forwards raw hub tool updates to attached desktop sessions", async () => {
const { createSidecarContext, handleHubLiveEvent } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() });
ctx.liveSessions.set("session-1", {
config: {},
messages: [],
promptsInQueue: [],
busy: true,
startedAt: Date.now(),
status: "running",
attachedViaHub: true,
});
handleHubLiveEvent(ctx, {
event: "tool.updated",
sessionId: "session-1",
payload: {
toolCallId: "call-1",
toolName: "run_commands",
update: { stream: "stdout", chunk: "live\n" },
},
});
const forwarded = readEvents(ctx).find(
(message) =>
message.event.name === "chat_event" &&
(message.event.payload as { stream?: string }).stream ===
"chat_tool_call_update",
);
expect(forwarded?.event.payload).toMatchObject({
sessionId: "session-1",
stream: "chat_tool_call_update",
});
expect(
JSON.parse(
String((forwarded?.event.payload as { chunk?: string }).chunk),
),
).toEqual({
toolCallId: "call-1",
toolName: "run_commands",
update: { stream: "stdout", chunk: "live\n" },
});
});
it("forwards proceed-while-running requests to the hub", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const { handleCommand } = await import("./commands");
hubCommandMock.mockResolvedValue({
ok: true,
payload: { detachedCount: 1 },
});
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
await expect(
handleCommand(ctx, "proceed_while_running", {
sessionId: "session-1",
toolCallId: "call-1",
}),
).resolves.toEqual({ detachedCount: 1 });
expect(hubCommandMock).toHaveBeenCalledWith(
"run.proceed_while_running",
{ sessionId: "session-1", toolCallId: "call-1" },
"session-1",
);
});
it("serializes queued image data when a queued prompt starts", async () => {
const { serializeQueuedPromptStart } = await import("./context");
expect(
JSON.parse(
serializeQueuedPromptStart({
promptId: "queued-prompt-1",
prompt: "Describe this",
attachmentCount: 1,
userImages: ["data:image/png;base64,AQID"],
}),
),
).toEqual({
promptId: "queued-prompt-1",
prompt: "Describe this",
attachmentCount: 1,
userImages: ["data:image/png;base64,AQID"],
});
});
it("announces a queued prompt start once when drain emits both queue events", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
let onEvent: ((event: unknown) => void) | undefined;
createCoreMock.mockResolvedValue({
runtimeAddress: "ws://127.0.0.1:25463/hub",
subscribe: vi.fn((handler: (event: unknown) => void) => {
onEvent = handler;
return () => {};
}),
dispose: vi.fn(),
});
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() });
await initializeSessionManager(ctx);
const session = {
config: {},
messages: [],
promptsInQueue: [
{
id: "prompt-1",
prompt: "hi there",
steer: false,
attachmentCount: 0,
},
],
busy: false,
startedAt: Date.now(),
status: "running",
} satisfies LiveSession;
ctx.liveSessions.set("session-1", session);
// PendingPromptService.drain() emits both events for the same prompt:
// a queue snapshot with the head removed, then the submitted event.
onEvent?.({
type: "pending_prompts",
payload: { sessionId: "session-1", prompts: [] },
});
onEvent?.({
type: "pending_prompt_submitted",
payload: {
sessionId: "session-1",
id: "prompt-1",
prompt: "hi there",
attachmentCount: 0,
},
});
const starts = readEvents(ctx).filter(
(message) =>
message.event.name === "chat_event" &&
(message.event.payload as { stream?: string }).stream ===
"chat_queued_prompt_start",
);
expect(starts).toHaveLength(1);
// A different prompt id must still be announced.
onEvent?.({
type: "pending_prompt_submitted",
payload: {
sessionId: "session-1",
id: "prompt-2",
prompt: "second",
attachmentCount: 0,
},
});
expect(
readEvents(ctx).filter(
(message) =>
message.event.name === "chat_event" &&
(message.event.payload as { stream?: string }).stream ===
"chat_queued_prompt_start",
),
).toHaveLength(2);
});
it("relays generated media for attach-only Hub sessions", async () => {
const { createSidecarContext, handleHubLiveEvent } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() });
ctx.liveSessions.set("session-image", {
config: {},
messages: [],
promptsInQueue: [],
busy: true,
startedAt: Date.now(),
status: "running",
attachedViaHub: true,
});
handleHubLiveEvent(ctx, {
event: "assistant.media",
sessionId: "session-image",
payload: {
media: {
id: "generated-1",
modality: "image",
mediaType: "image/png",
source: { type: "base64", data: "aGVsbG8=" },
},
},
});
expect(readEvents(ctx)).toEqual([
expect.objectContaining({
event: {
name: "chat_event",
payload: expect.objectContaining({
sessionId: "session-image",
stream: "chat_media",
chunk: JSON.stringify({
id: "generated-1",
modality: "image",
mediaType: "image/png",
source: { type: "base64", data: "aGVsbG8=" },
}),
}),
},
}),
]);
});
it("ignores raw assistant media for locally-owned sessions", async () => {
const { createSidecarContext, handleHubLiveEvent } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() });
ctx.liveSessions.set("session-image", {
config: {},
messages: [],
promptsInQueue: [],
busy: true,
startedAt: Date.now(),
status: "running",
attachedViaHub: false,
});
handleHubLiveEvent(ctx, {
event: "assistant.media",
sessionId: "session-image",
payload: {
media: {
id: "generated-1",
modality: "image",
mediaType: "image/png",
source: { type: "base64", data: "aGVsbG8=" },
},
},
});
expect(readEvents(ctx)).toEqual([]);
});
it("resolves askQuestion through the websocket request/response protocol", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const { handleCommand } = await import("./commands");
const ctx = createSidecarContext("/workspace/project");
const approvalClient = {
data: { canApproveTools: true },
send: vi.fn(),
};
ctx.wsClients.add(approvalClient);
await initializeSessionManager(ctx);
const capabilities = createCoreMock.mock.calls[0][0]
.capabilities as RuntimeCapabilities;
const answer = capabilities.toolExecutors?.askQuestion?.(
"Which branch?",
["Keep current", "Create new"],
{
sessionId: "session-1",
agentId: "agent-1",
conversationId: "conversation-1",
iteration: 3,
},
);
expect(answer).toBeInstanceOf(Promise);
const event = readEvents(ctx).find(
(item) => item.event.name === "ask_question_requested",
);
expect(event?.event.payload).toMatchObject({
sessionId: "session-1",
question: "Which branch?",
options: ["Keep current", "Create new"],
context: {
agentId: "agent-1",
conversationId: "conversation-1",
iteration: 3,
},
});
const requestId = String(event?.event.payload.requestId ?? "");
expect(requestId.length).toBeGreaterThan(0);
expect(
await handleCommand(ctx, "poll_ask_questions", {
sessionId: "session-1",
}),
).toEqual([
expect.objectContaining({
requestId,
sessionId: "session-1",
question: "Which branch?",
options: ["Keep current", "Create new"],
}),
]);
expect(
await handleCommand(ctx, "poll_ask_questions", {
sessionId: "another-session",
}),
).toEqual([]);
await handleCommand(ctx, "respond_ask_question", {
requestId,
answer: "Create new",
});
await expect(answer).resolves.toBe("Create new");
expect(ctx.pendingQuestions.size).toBe(0);
expect(
await handleCommand(ctx, "poll_ask_questions", {
sessionId: "session-1",
}),
).toEqual([]);
expect(readEvents(ctx)).toContainEqual(
expect.objectContaining({
event: expect.objectContaining({
name: "ask_question_answered",
payload: { requestId },
}),
}),
);
});
it("rejects askQuestion requests without an owning session", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() });
await initializeSessionManager(ctx);
const capabilities = createCoreMock.mock.calls[0][0]
.capabilities as RuntimeCapabilities;
const answer = capabilities.toolExecutors?.askQuestion?.(
"Which branch?",
["Keep current", "Create new"],
{ agentId: "agent-1", iteration: 3 },
);
await expect(answer).rejects.toThrow(
"ask_question requires an active session ID",
);
expect(
readEvents(ctx).some(
(message) => message.event.name === "ask_question_requested",
),
).toBe(false);
});
it("resolves approval through websocket state", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const { handleCommand } = await import("./commands");
const ctx = createSidecarContext("/workspace/project");
const approvalClient = {
data: { canApproveTools: true },
send: vi.fn(),
};
ctx.wsClients.add(approvalClient);
await initializeSessionManager(ctx);
expect(createCoreMock).toHaveBeenCalledWith(
expect.objectContaining({
backendMode: "hub",
capabilities: expect.objectContaining({
requestToolApproval: expect.any(Function),
}),
hub: expect.objectContaining({
strategy: "require-hub",
clientType: "code-sidecar",
displayName: "Cline Desktop sidecar",
}),
}),
);
const capabilities = createCoreMock.mock.calls[0][0]
.capabilities as RuntimeCapabilities;
const approval = capabilities.requestToolApproval?.({
sessionId: "sess-1",
agentId: "agent-1",
conversationId: "conversation-1",
iteration: 2,
toolCallId: "tool-call-1",
toolName: "run_commands",
input: { commands: ["echo hi"] },
policy: { autoApprove: false },
});
expect(approval).toBeInstanceOf(Promise);
const pending = await handleCommand(
ctx,
"poll_tool_approvals",
{ sessionId: "sess-1" },
{ connection: approvalClient },
);
expect(pending).toEqual([
expect.objectContaining({
sessionId: "sess-1",
toolCallId: "tool-call-1",
toolName: "run_commands",
input: { commands: ["echo hi"] },
agentId: "agent-1",
conversationId: "conversation-1",
}),
]);
expect(readEvents(ctx)).toContainEqual(
expect.objectContaining({
event: expect.objectContaining({
name: "tool_approval_state",
payload: expect.objectContaining({
sessionId: "sess-1",
items: expect.arrayContaining([
expect.objectContaining({ toolCallId: "tool-call-1" }),
]),
}),
}),
}),
);
const [{ requestId }] = pending as Array<{ requestId: string }>;
const untrustedClient = { send: vi.fn() };
ctx.wsClients.add(untrustedClient);
await expect(
handleCommand(
ctx,
"respond_tool_approval",
{ sessionId: "sess-1", requestId, approved: true },
{ connection: untrustedClient },
),
).rejects.toThrow("trusted desktop connection");
expect(ctx.pendingApprovals.size).toBe(1);
await handleCommand(
ctx,
"respond_tool_approval",
{ sessionId: "sess-1", requestId, approved: true },
{ connection: approvalClient },
);
await expect(approval).resolves.toEqual({ approved: true });
expect(
await handleCommand(
ctx,
"poll_tool_approvals",
{ sessionId: "sess-1" },
{ connection: approvalClient },
),
).toEqual([]);
});
it("rejects and removes an approval when initial delivery fails", async () => {
const { createSidecarContext, createSidecarRuntimeCapabilities } =
await import("./context");
const ctx = createSidecarContext("/workspace/project");
const failedClient = {
data: { canApproveTools: true },
send: vi.fn(() => {
throw new Error("socket closed");
}),
};
ctx.wsClients.add(failedClient);
const approval = createSidecarRuntimeCapabilities(
ctx,
).requestToolApproval?.({
sessionId: "sess-1",
agentId: "agent-1",
conversationId: "conversation-1",
iteration: 1,
toolCallId: "tool-call-1",
toolName: "run_commands",
input: { commands: ["echo hi"] },
policy: { autoApprove: false },
});
await expect(approval).resolves.toEqual({
approved: false,
reason: "Desktop approval surface disconnected",
});
expect(ctx.pendingApprovals.size).toBe(0);
expect(ctx.wsClients.has(failedClient)).toBe(false);
});
it("rejects an owned approval when a later broadcast fails", async () => {
const {
broadcastEvent,
createSidecarContext,
createSidecarRuntimeCapabilities,
} = await import("./context");
const ctx = createSidecarContext("/workspace/project");
const approvalClient = {
data: { canApproveTools: true },
send: vi
.fn()
.mockImplementationOnce(() => undefined)
.mockImplementationOnce(() => {
throw new Error("socket closed");
}),
};
ctx.wsClients.add(approvalClient);
const approval = createSidecarRuntimeCapabilities(
ctx,
).requestToolApproval?.({
sessionId: "sess-1",
agentId: "agent-1",
conversationId: "conversation-1",
iteration: 1,
toolCallId: "tool-call-1",
toolName: "run_commands",
input: { commands: ["echo hi"] },
policy: { autoApprove: false },
});
expect(ctx.pendingApprovals.size).toBe(1);
broadcastEvent(ctx, "task.updated", { taskId: "task-1" });
await expect(approval).resolves.toEqual({
approved: false,
reason: "Desktop approval surface disconnected",
});
expect(ctx.pendingApprovals.size).toBe(0);
expect(ctx.wsClients.has(approvalClient)).toBe(false);
});
it("rejects sibling approvals when a targeted state update fails", async () => {
const { createSidecarContext, createSidecarRuntimeCapabilities } =
await import("./context");
const { handleCommand } = await import("./commands");
const ctx = createSidecarContext("/workspace/project");
const approvalClient = {
data: { canApproveTools: true },
send: vi
.fn()
.mockImplementationOnce(() => undefined)
.mockImplementationOnce(() => undefined)
.mockImplementationOnce(() => {
throw new Error("socket closed");
}),
};
ctx.wsClients.add(approvalClient);
const capabilities = createSidecarRuntimeCapabilities(ctx);
const request = (toolCallId: string) =>
capabilities.requestToolApproval?.({
sessionId: "sess-1",
agentId: "agent-1",
conversationId: "conversation-1",
iteration: 1,
toolCallId,
toolName: "run_commands",
input: { commands: ["echo hi"] },
policy: { autoApprove: false },
});
const firstApproval = request("tool-call-1");
const siblingApproval = request("tool-call-2");
const [{ requestId }] = (await handleCommand(
ctx,
"poll_tool_approvals",
{ sessionId: "sess-1" },
{ connection: approvalClient },
)) as Array<{ requestId: string }>;
await handleCommand(
ctx,
"respond_tool_approval",
{ sessionId: "sess-1", requestId, approved: true },
{ connection: approvalClient },
);
await expect(firstApproval).resolves.toEqual({ approved: true });
await expect(siblingApproval).resolves.toEqual({
approved: false,
reason: "Desktop approval surface disconnected",
});
expect(ctx.pendingApprovals.size).toBe(0);
expect(ctx.wsClients.has(approvalClient)).toBe(false);
});
it("serializes approval readiness updates and publishes the latest state", async () => {
const {
createSidecarContext,
initializeSessionManager,
syncSidecarApprovalReadiness,
} = await import("./context");
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
updateCapabilitiesMock.mockReset();
let finishDisconnectedUpdate: (() => void) | undefined;
updateCapabilitiesMock
.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finishDisconnectedUpdate = resolve;
}),
)
.mockResolvedValue(undefined);
const disconnected = syncSidecarApprovalReadiness(ctx);
await vi.waitFor(() =>
expect(updateCapabilitiesMock).toHaveBeenCalledWith([]),
);
ctx.wsClients.add({
data: { canApproveTools: true },
send: vi.fn(),
});
const connected = syncSidecarApprovalReadiness(ctx);
expect(updateCapabilitiesMock).toHaveBeenCalledTimes(1);
finishDisconnectedUpdate?.();
await Promise.all([disconnected, connected]);
expect(updateCapabilitiesMock).toHaveBeenLastCalledWith([
expect.objectContaining({ name: "approval.respond" }),
]);
});
it("forwards Hub-owned task session approvals to the live desktop", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const { handleCommand } = await import("./commands");
let onHubEvent: ((event: Record<string, unknown>) => void) | undefined;
subscribeMock.mockImplementation((handler) => {
onHubEvent = handler;
return () => {};
});
const ctx = createSidecarContext("/workspace/project");
const approvalClient = {
data: { canApproveTools: true },
send: vi.fn(),
};
ctx.wsClients.add(approvalClient);
await initializeSessionManager(ctx);
expect(updateCapabilitiesMock).toHaveBeenCalledWith([
expect.objectContaining({ name: "approval.respond" }),
]);
onHubEvent?.({
event: "approval.requested",
sessionId: "task-session-1",
payload: {
approvalId: "hub-approval-1",
agendaTaskId: "task-1",
agentId: "task-agent-1",
conversationId: "task-conversation-1",
iteration: 2,
toolCallId: "tool-call-1",
toolName: "write_to_file",
inputJson: JSON.stringify({ path: "src/a.ts" }),
policy: { autoApprove: false },
},
});
await vi.waitFor(() => expect(ctx.pendingApprovals.size).toBe(1));
const pendingItems = (await handleCommand(
ctx,
"poll_tool_approvals",
{ sessionId: "task-session-1" },
{ connection: approvalClient },
)) as Array<{ requestId: string }>;
const pending = pendingItems[0];
if (!pending) throw new Error("expected a pending task approval");
await handleCommand(
ctx,
"respond_tool_approval",
{
sessionId: "task-session-1",
requestId: pending.requestId,
approved: true,
},
{ connection: approvalClient },
);
await vi.waitFor(() =>
expect(hubCommandMock).toHaveBeenCalledWith(
"approval.respond",
{
approvalId: "hub-approval-1",
approved: true,
reason: undefined,
},
"task-session-1",
),
);
});
it("routes routine commands through the connected shared Hub client", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const { handleCommand } = await import("./commands");
hubCommandMock.mockResolvedValue({
ok: true,
payload: { schedule: { scheduleId: "schedule-1", enabled: false } },
});
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
await expect(
handleCommand(ctx, "pause_routine_schedule", {
schedule_id: "schedule-1",
}),
).resolves.toEqual({
schedule: { scheduleId: "schedule-1", enabled: false },
});
expect(hubCommandMock).toHaveBeenCalledWith("schedule.disable", {
allWorkspaces: true,
scheduleId: "schedule-1",
});
});
it("proxies Agenda task commands through the connected shared Hub", async () => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const { handleCommand } = await import("./commands");
const task = {
taskId: "task-1",
title: "Review the PR",
status: "pending_approval",
};
hubCommandMock.mockResolvedValue({
ok: true,
payload: { tasks: [task] },
});
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
const approvalClient = {
data: { canApproveTools: true },
send: vi.fn(),
};
await expect(
handleCommand(ctx, "task.list", {
workspaceRoot: "/workspace/project",
statuses: ["pending_approval"],
}),
).resolves.toEqual({ tasks: [task] });
expect(hubCommandMock).toHaveBeenCalledWith("task.list", {
workspaceRoot: "/workspace/project",
statuses: ["pending_approval"],
});
hubCommandMock.mockResolvedValueOnce({
ok: true,
payload: { task: { ...task, status: "in_progress", revision: 4 } },
});
await expect(
handleCommand(
ctx,
"task.run",
{
taskId: "task-1",
expectedRevision: 4,
},
{ connection: approvalClient },
),
).resolves.toEqual({
task: { ...task, status: "in_progress", revision: 4 },
});
expect(hubCommandMock).toHaveBeenCalledWith("task.run", {
taskId: "task-1",
expectedRevision: 4,
});
});
it.each([
"task.create",
"task.approve",
"task.cancel",
"task.run",
"task.automation.set",
])("rejects untrusted %s commands before they reach the shared Hub", async (command) => {
const { createSidecarContext, initializeSessionManager } = await import(
"./context"
);
const { handleCommand } = await import("./commands");
const ctx = createSidecarContext("/workspace/project");
await initializeSessionManager(ctx);
const untrustedClient = {
data: { canApproveTools: false },
send: vi.fn(),
};
await expect(
handleCommand(ctx, command, {}, { connection: untrustedClient }),
).rejects.toThrow("task execution requires a trusted desktop connection");
expect(hubCommandMock).not.toHaveBeenCalled();
});
it("forwards Hub task events that do not have a session", async () => {
const { createSidecarContext, handleHubLiveEvent } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
ctx.wsClients.add({ send: vi.fn() } as never);
handleHubLiveEvent(ctx, {
event: "task.created",
payload: {
taskId: "task-1",
status: "pending_approval",
},
});
expect(readEvents(ctx)).toEqual([
{
type: "event",
event: {
name: "task.created",
payload: {
taskId: "task-1",
status: "pending_approval",
},
},
},
]);
});
});
describe("disposeSidecarContext attachment cleanup", () => {
let previousSessionDataDir: string | undefined;
let testSessionDataDir: string;
beforeEach(() => {
previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
testSessionDataDir = join(
tmpdir(),
`cline-desktop-dispose-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
process.env.CLINE_SESSION_DATA_DIR = testSessionDataDir;
});
afterEach(() => {
if (previousSessionDataDir === undefined) {
delete process.env.CLINE_SESSION_DATA_DIR;
} else {
process.env.CLINE_SESSION_DATA_DIR = previousSessionDataDir;
}
rmSync(testSessionDataDir, { recursive: true, force: true });
});
it("deletes tracked attachments for all live sessions on shutdown", async () => {
const { createSidecarContext, disposeSidecarContext } = await import(
"./context"
);
const ctx = createSidecarContext("/workspace/project");
const sessionId = "dispose-session";
const [queuedFile] = materializeUserFiles(sessionId, [
{ name: "queued.txt", content: "q" },
]) as string[];
const session: LiveSession = {
config: {},
messages: [],
promptsInQueue: [],
busy: false,
startedAt: Date.now(),
status: "idle",
queuedAttachmentFiles: new Map([["pending_1", [queuedFile]]]),
};
ctx.liveSessions.set(sessionId, session);
await disposeSidecarContext(ctx, "test_shutdown");
expect(existsSync(queuedFile)).toBe(false);
expect(ctx.liveSessions.size).toBe(0);
});
});