mirror of
https://github.com/cline/cline.git
synced 2026-10-24 02:02:27 +08:00
refactor(desktop): trim handoff scope and redundant state
This commit is contained in:
@@ -181,28 +181,6 @@ describe("starting SSH sessions", () => {
|
||||
expect(ctx.liveSessions.get(sessionId)?.messages).toEqual(transcript);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"before",
|
||||
"during",
|
||||
])("blocks a start when a parent workspace restore begins %s preparation", async (when) => {
|
||||
const { ctx, config, readMessages, start } = setup();
|
||||
if (when === "before") ctx.restoringWorkspacePaths.add("/remote");
|
||||
else
|
||||
readMessages.mockImplementationOnce(async () => {
|
||||
ctx.restoringWorkspacePaths.add("/remote");
|
||||
return transcript;
|
||||
});
|
||||
await expect(
|
||||
handleChatSessionCommand(ctx, {
|
||||
action: "start",
|
||||
config: { ...config, cwd: "/remote/project" },
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Cannot send a prompt while the session workspace is being restored",
|
||||
);
|
||||
expect(start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates other read failures without creating a session", async () => {
|
||||
const { ctx, config, readMessages, start } = setup();
|
||||
const error = Object.assign(new Error("Hub disconnected"), {
|
||||
@@ -691,16 +669,6 @@ describe("session forks", () => {
|
||||
"Cannot send a prompt while the session workspace is being restored",
|
||||
);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
for (const cwd of ["/workspace", "/workspace/project/child"]) {
|
||||
await expect(
|
||||
handleChatSessionCommand(ctx, {
|
||||
action: "restore_checkpoint",
|
||||
sessionId: siblingSessionId,
|
||||
checkpointRunCount: 1,
|
||||
config: { cwd },
|
||||
}),
|
||||
).rejects.toThrow("Wait for all turns in this workspace to finish");
|
||||
}
|
||||
} finally {
|
||||
releaseRestore();
|
||||
}
|
||||
@@ -951,12 +919,7 @@ describe("session forks", () => {
|
||||
expect(ctx.restoringWorkspacePaths.size).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/workspace/project/.",
|
||||
"/workspace/project/child",
|
||||
"/workspace",
|
||||
"/",
|
||||
])("rejects an edit fork while a session in %s is running", async (workspaceRoot) => {
|
||||
it("rejects an edit fork while a sibling session in the workspace is running", async () => {
|
||||
const sourceSessionId = "idle-source-session";
|
||||
const siblingSessionId = "busy-sibling-session";
|
||||
const restore = vi.fn();
|
||||
@@ -976,7 +939,7 @@ describe("session forks", () => {
|
||||
[
|
||||
siblingSessionId,
|
||||
{
|
||||
config: { workspaceRoot },
|
||||
config: { workspaceRoot: "/workspace/project/." },
|
||||
messages: [],
|
||||
promptsInQueue: [],
|
||||
busy: true,
|
||||
@@ -1091,12 +1054,7 @@ describe("session forks", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/workspace/project/.",
|
||||
"/workspace/project/child",
|
||||
"/workspace",
|
||||
"/",
|
||||
])("blocks sends from %s while an overlapping workspace is restored", async (workspaceRoot) => {
|
||||
it("blocks sends from sibling sessions while their workspace is restored", async () => {
|
||||
const send = vi.fn();
|
||||
const sessionId = "workspace-sibling-session";
|
||||
const ctx = {
|
||||
@@ -1104,7 +1062,7 @@ describe("session forks", () => {
|
||||
[
|
||||
sessionId,
|
||||
{
|
||||
config: { workspaceRoot },
|
||||
config: { workspaceRoot: "/workspace/project/." },
|
||||
messages: [{ role: "user", content: "prompt" }],
|
||||
promptsInQueue: [],
|
||||
busy: false,
|
||||
@@ -1312,10 +1270,8 @@ describe("first-send connection updates", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/workspace/project",
|
||||
"/workspace/project-sibling",
|
||||
])("rechecks a restore of %s after preparing a prompt", async (locked) => {
|
||||
it("blocks handoff while a local prompt is still being prepared", async () => {
|
||||
enableCloudHandoffGates();
|
||||
const { ctx, sessionId, send } = createContext({
|
||||
config: { ...baseConfig, cwd: "/workspace/project/child" },
|
||||
});
|
||||
@@ -1335,20 +1291,21 @@ describe("first-send connection updates", () => {
|
||||
prompt: "/custom",
|
||||
});
|
||||
await preparing.promise;
|
||||
ctx.restoringWorkspacePaths.add(locked);
|
||||
await expect(
|
||||
handleChatSessionCommand(ctx, {
|
||||
action: "prepare_handoff",
|
||||
sessionId,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Wait for the current send to finish before using /cloud.",
|
||||
);
|
||||
prepared.resolve();
|
||||
if (locked === "/workspace/project") {
|
||||
await expect(result).rejects.toThrow(
|
||||
"Cannot send a prompt while the session workspace is being restored",
|
||||
);
|
||||
expect(send).not.toHaveBeenCalled();
|
||||
} else {
|
||||
await result;
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId, prompt: "expanded prompt" }),
|
||||
);
|
||||
}
|
||||
await result;
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sessionId, prompt: "expanded prompt" }),
|
||||
);
|
||||
} finally {
|
||||
prepared.resolve();
|
||||
command.mockRestore();
|
||||
}
|
||||
});
|
||||
@@ -1538,7 +1495,7 @@ describe("first-send connection updates", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("removes queued and consumed attachments when resetting", async () => {
|
||||
it("deletes tracked attachments when a session is reset", async () => {
|
||||
const { ctx, sessionId } = createContext();
|
||||
const previousSessionDataDir = process.env.CLINE_SESSION_DATA_DIR;
|
||||
const testSessionDataDir = join(
|
||||
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
beginActiveSessionSend,
|
||||
handleHandoff,
|
||||
handlePrepareHandoff,
|
||||
hasActiveSessionSend,
|
||||
} from "./cloud-handoff";
|
||||
import { sendWithCloudHandoffFollowUp } from "./cloud-handoff-follow-up";
|
||||
import {
|
||||
@@ -76,12 +75,8 @@ import {
|
||||
readReasoningEffort,
|
||||
readWorkspacePath,
|
||||
workspacePathKey,
|
||||
workspacePathsOverlap,
|
||||
} from "./session-config";
|
||||
import {
|
||||
derivePromptFromMessages,
|
||||
readSessionMetadata,
|
||||
} from "./session-data/common";
|
||||
import { derivePromptFromMessages } from "./session-data/common";
|
||||
import { persistSessionMessages } from "./session-data/messages";
|
||||
import type {
|
||||
ChatSessionCommandRequest,
|
||||
@@ -323,45 +318,19 @@ function hasActiveWorkspaceTurn(session: LiveSession): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function assertWorkspaceNotRestoring(
|
||||
ctx: SidecarContext,
|
||||
workspace: string | undefined,
|
||||
): void {
|
||||
if (
|
||||
workspace &&
|
||||
[...ctx.restoringWorkspacePaths].some((locked) =>
|
||||
workspacePathsOverlap(
|
||||
workspace,
|
||||
locked,
|
||||
getSessionRuntimeBinding(ctx).kind,
|
||||
),
|
||||
)
|
||||
) {
|
||||
throw new Error(WORKSPACE_RESTORE_SEND_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
async function withWorkspaceRestoreLock<T>(
|
||||
ctx: SidecarContext,
|
||||
workspacePath: string,
|
||||
work: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const key = resolve(workspacePath);
|
||||
const environment = getSessionRuntimeBinding(ctx).kind;
|
||||
if (
|
||||
[...ctx.restoringWorkspacePaths].some((locked) =>
|
||||
workspacePathsOverlap(key, locked, environment),
|
||||
)
|
||||
) {
|
||||
if (ctx.restoringWorkspacePaths.has(key)) {
|
||||
throw new Error(WORKSPACE_RESTORE_BUSY_ERROR);
|
||||
}
|
||||
for (const [sessionId, session] of ctx.liveSessions) {
|
||||
const sessionWorkspace = workspacePathKey(session.config);
|
||||
for (const session of ctx.liveSessions.values()) {
|
||||
if (
|
||||
sessionWorkspace &&
|
||||
(hasActiveWorkspaceTurn(session) ||
|
||||
hasActiveSessionSend(ctx, sessionId)) &&
|
||||
workspacePathsOverlap(sessionWorkspace, key, environment)
|
||||
workspacePathKey(session.config) === key &&
|
||||
hasActiveWorkspaceTurn(session)
|
||||
) {
|
||||
throw new Error(WORKSPACE_RESTORE_BUSY_ERROR);
|
||||
}
|
||||
@@ -461,6 +430,13 @@ function readSessionMetadataTitle(sessionId: string): string | undefined {
|
||||
return typeof title === "string" ? title.trim() || undefined : undefined;
|
||||
}
|
||||
|
||||
function readSessionMetadata(sessionId: string): JsonRecord | undefined {
|
||||
const manifest = readSessionManifest(sessionId);
|
||||
return manifest?.metadata && typeof manifest.metadata === "object"
|
||||
? (manifest.metadata as JsonRecord)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Live session factory
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1044,8 +1020,6 @@ async function handleStart(
|
||||
request: ChatSessionCommandRequest,
|
||||
): Promise<unknown> {
|
||||
if (!request.config) throw new Error("config is required");
|
||||
const workspace = workspacePathKey(request.config) ?? ctx.localWorkspaceRoot;
|
||||
assertWorkspaceNotRestoring(ctx, workspace);
|
||||
const binding = getSessionRuntimeBinding(
|
||||
ctx,
|
||||
undefined,
|
||||
@@ -1102,7 +1076,6 @@ async function handleStart(
|
||||
providerId: String(coreConfig.providerId ?? ""),
|
||||
modelId: String(coreConfig.modelId ?? ""),
|
||||
});
|
||||
assertWorkspaceNotRestoring(ctx, workspace);
|
||||
const startResult = await manager.start({
|
||||
...splitCoreSessionConfig(coreConfig as unknown as ClineCoreStartConfig),
|
||||
source: SessionSource.DESKTOP,
|
||||
@@ -1680,7 +1653,12 @@ async function handleSendOnce(
|
||||
const lockedWorkspaceKey = workspacePathKey(
|
||||
session?.config ?? request.config,
|
||||
);
|
||||
assertWorkspaceNotRestoring(ctx, lockedWorkspaceKey);
|
||||
if (
|
||||
lockedWorkspaceKey &&
|
||||
ctx.restoringWorkspacePaths.has(lockedWorkspaceKey)
|
||||
) {
|
||||
throw new Error(WORKSPACE_RESTORE_SEND_ERROR);
|
||||
}
|
||||
if (session?.transitioningProvider) {
|
||||
throw new Error("A provider switch is already in progress");
|
||||
}
|
||||
@@ -1744,7 +1722,6 @@ async function handleSendOnce(
|
||||
prompt,
|
||||
request.config?.mode ?? session?.config?.mode,
|
||||
)));
|
||||
assertWorkspaceNotRestoring(ctx, lockedWorkspaceKey);
|
||||
let delivery = request.delivery;
|
||||
if (!delivery && session?.busy) {
|
||||
delivery = "queue";
|
||||
@@ -2059,12 +2036,7 @@ async function handleFork(
|
||||
) {
|
||||
throw new Error("forkBeforeRunCount must be a positive integer");
|
||||
}
|
||||
const binding = getSessionRuntimeBinding(
|
||||
ctx,
|
||||
sourceSessionId,
|
||||
readEnvironmentId(request.config),
|
||||
);
|
||||
const manager = binding.sessionManager;
|
||||
const manager = getSessionManager(ctx, sourceSessionId, request.config);
|
||||
const liveSourceSession = ctx.liveSessions.get(sourceSessionId);
|
||||
if (
|
||||
forkBeforeRunCount !== undefined &&
|
||||
@@ -2096,16 +2068,16 @@ async function handleFork(
|
||||
if (!restoreWorkspacePath) {
|
||||
throw new Error("cwd or workspaceRoot is required to edit a message");
|
||||
}
|
||||
return withWorkspaceRestoreLock(ctx, restoreWorkspacePath, () => {
|
||||
return handleForkUnlocked(
|
||||
return withWorkspaceRestoreLock(ctx, restoreWorkspacePath, () =>
|
||||
handleForkUnlocked(
|
||||
ctx,
|
||||
request,
|
||||
sourceSessionId,
|
||||
forkBeforeRunCount,
|
||||
sourceSession,
|
||||
restoreWorkspacePath,
|
||||
);
|
||||
});
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function handleForkUnlocked(
|
||||
@@ -2298,12 +2270,6 @@ async function handleReset(
|
||||
): Promise<unknown> {
|
||||
const sessionId = request.sessionId?.trim();
|
||||
if (sessionId) {
|
||||
const binding = getSessionRuntimeBinding(
|
||||
ctx,
|
||||
sessionId,
|
||||
readEnvironmentId(request.config),
|
||||
);
|
||||
const manager = binding.sessionManager;
|
||||
cancelHubReconnect(sessionId);
|
||||
cancelSidecarMistakeQuestions(ctx, sessionId, "Session reset");
|
||||
const session = ctx.liveSessions.get(sessionId);
|
||||
@@ -2313,7 +2279,7 @@ async function handleReset(
|
||||
session?.status === "running" ||
|
||||
session?.status === "stopping"
|
||||
) {
|
||||
await manager.stop(sessionId);
|
||||
await getSessionManager(ctx, sessionId, request.config).stop(sessionId);
|
||||
}
|
||||
discardAllTrackedAttachments(sessionId, session);
|
||||
ctx.liveSessions.delete(sessionId);
|
||||
@@ -2339,18 +2305,18 @@ async function handleRestoreCheckpoint(
|
||||
const requestedConfig = request.config;
|
||||
if (!requestedConfig)
|
||||
throw new Error("config is required to restore a checkpoint");
|
||||
const binding = getSessionRuntimeBinding(
|
||||
ctx,
|
||||
sourceSessionId,
|
||||
readEnvironmentId(requestedConfig),
|
||||
);
|
||||
const manager = binding.sessionManager;
|
||||
const cwd =
|
||||
(typeof requestedConfig.cwd === "string" && requestedConfig.cwd.trim()) ||
|
||||
(typeof requestedConfig.workspaceRoot === "string" &&
|
||||
requestedConfig.workspaceRoot.trim()) ||
|
||||
"";
|
||||
if (!cwd) throw new Error("config.cwd or config.workspaceRoot is required");
|
||||
const binding = getSessionRuntimeBinding(
|
||||
ctx,
|
||||
sourceSessionId,
|
||||
readEnvironmentId(requestedConfig),
|
||||
);
|
||||
const manager = binding.sessionManager;
|
||||
const config =
|
||||
binding.kind === "ssh"
|
||||
? await withRemoteProviderCredentials(requestedConfig)
|
||||
@@ -2622,12 +2588,6 @@ export async function handleChatSessionCommand(
|
||||
if (!repoUrl || !modelId) {
|
||||
throw new Error("repoUrl and model are required for a cloud session");
|
||||
}
|
||||
const models = await cloud.listModels();
|
||||
if (!models.some((model) => model.id === modelId)) {
|
||||
throw new Error(
|
||||
`The selected model ${modelId} is not available in Cline Cloud for this account. Select a supported model before starting a cloud session.`,
|
||||
);
|
||||
}
|
||||
const branch = String(request.config?.branch ?? "").trim();
|
||||
const initialPrompt = request.prompt?.trim();
|
||||
const reasoningEffort = readReasoningEffort(
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { summarizeConversation } from "@cline/core";
|
||||
import { loadCloudModels } from "@cline/core/cloud";
|
||||
import { DEFAULT_MAX_IMAGE_DECODED_BYTES } from "@cline/shared";
|
||||
@@ -40,17 +37,10 @@ vi.mock("@cline/core/cloud", async (importOriginal) => {
|
||||
|
||||
const image =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jT1sAAAAASUVORK5CYII=";
|
||||
let dataDir: string;
|
||||
beforeEach(() => {
|
||||
dataDir = mkdtempSync(join(tmpdir(), "cloud-summary-test-"));
|
||||
vi.stubEnv("CLINE_DATA_DIR", dataDir);
|
||||
enableCloudHandoffGates();
|
||||
});
|
||||
beforeEach(enableCloudHandoffGates);
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
cleanupCloudHandoffGates();
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function fixture() {
|
||||
|
||||
@@ -80,7 +80,7 @@ async function assertHandoffIdle(
|
||||
if (
|
||||
workspaceKey &&
|
||||
[...ctx.restoringWorkspacePaths].some((locked) =>
|
||||
workspacePathsOverlap(workspaceKey, locked, "local"),
|
||||
workspacePathsOverlap(workspaceKey, locked),
|
||||
)
|
||||
)
|
||||
throw new Error(
|
||||
@@ -407,10 +407,7 @@ export function beginActiveSessionSend(
|
||||
};
|
||||
}
|
||||
|
||||
export function hasActiveSessionSend(
|
||||
ctx: SidecarContext,
|
||||
sessionId: string,
|
||||
): boolean {
|
||||
function hasActiveSessionSend(ctx: SidecarContext, sessionId: string): boolean {
|
||||
return (
|
||||
activeSendRequests
|
||||
.get(getEnvironmentContext(ctx, ctx.activeEnvironmentId ?? "local"))
|
||||
|
||||
@@ -652,9 +652,6 @@ describe("Cloud sessions sidecar wiring", () => {
|
||||
create,
|
||||
} as unknown as CloudSessionApi,
|
||||
});
|
||||
vi.spyOn(manager, "listModels").mockResolvedValue([
|
||||
{ id: "anthropic/claude-sonnet-5", name: "Sonnet", catalogId: "cline" },
|
||||
]);
|
||||
|
||||
const created = await handleChatSessionCommand(ctx, {
|
||||
action: "start",
|
||||
@@ -694,36 +691,6 @@ describe("Cloud sessions sidecar wiring", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ models: [] },
|
||||
{
|
||||
models: [
|
||||
{ id: "different-model", name: "Other", catalogId: "cline" as const },
|
||||
],
|
||||
},
|
||||
])("rejects an unavailable selected model before provisioning with catalog $models", async ({
|
||||
models,
|
||||
}) => {
|
||||
const create = vi.fn();
|
||||
const { ctx, hub, manager } = createFixture({
|
||||
api: { list: async () => [], create } as unknown as CloudSessionApi,
|
||||
});
|
||||
vi.spyOn(manager, "listModels").mockResolvedValue(models);
|
||||
await expect(
|
||||
handleChatSessionCommand(ctx, {
|
||||
action: "start",
|
||||
config: {
|
||||
executionTarget: "cloud",
|
||||
repoUrl: "https://github.com/cline/test",
|
||||
model: "selected-model",
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("selected model selected-model is not available");
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
expect(hub.commands).toEqual([]);
|
||||
expect(ctx.liveSessions.size).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
false,
|
||||
true,
|
||||
@@ -885,7 +852,7 @@ describe("Cloud sessions sidecar wiring", () => {
|
||||
});
|
||||
|
||||
it("surfaces run.failed errors as a visible error message", async () => {
|
||||
const { ctx, events, hub, manager } = createFixture();
|
||||
const { events, hub, manager } = createFixture();
|
||||
await manager.list();
|
||||
await manager.attach("ses-outer");
|
||||
|
||||
@@ -917,15 +884,12 @@ describe("Cloud sessions sidecar wiring", () => {
|
||||
] as const)("%s attaches an existing outer id with a cold registry", async (action) => {
|
||||
const create = vi.fn();
|
||||
const outerId = "ses-01H9XKYHEC1YFBXMJ8ZBES772P";
|
||||
const { ctx, hub, manager } = createFixture({
|
||||
const { ctx, hub } = createFixture({
|
||||
api: {
|
||||
list: async () => [{ ...REMOTE_SESSION, id: outerId }],
|
||||
create,
|
||||
} as unknown as CloudSessionApi,
|
||||
});
|
||||
const listModels = vi
|
||||
.spyOn(manager, "listModels")
|
||||
.mockRejectedValue(new Error("catalog unavailable"));
|
||||
|
||||
const attached = await handleChatSessionCommand(
|
||||
ctx,
|
||||
@@ -943,7 +907,6 @@ describe("Cloud sessions sidecar wiring", () => {
|
||||
);
|
||||
|
||||
expect(attached).toMatchObject({ sessionId: outerId, origin: "cloud" });
|
||||
expect(listModels).not.toHaveBeenCalled();
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
expect(
|
||||
hub.commands.some((entry) => entry.command === "session.attach"),
|
||||
|
||||
@@ -1885,14 +1885,10 @@ export async function handleCommand(
|
||||
options?: { connection?: SidecarWebSocketClient },
|
||||
): Promise<unknown> {
|
||||
const explicitEnvironment = requestedEnvironmentId(args);
|
||||
const requestedSession = args?.sessionId ?? args?.session_id;
|
||||
if (explicitEnvironment) {
|
||||
ctx = getEnvironmentContext(ctx, explicitEnvironment);
|
||||
} else if (typeof requestedSession === "string" && requestedSession.trim()) {
|
||||
const binding = await findSessionRuntimeBinding(
|
||||
ctx,
|
||||
requestedSession.trim(),
|
||||
);
|
||||
} else if (typeof args?.sessionId === "string" && args.sessionId.trim()) {
|
||||
const binding = await findSessionRuntimeBinding(ctx, args.sessionId.trim());
|
||||
if (binding) ctx = getEnvironmentContext(ctx, binding.environmentId);
|
||||
}
|
||||
|
||||
@@ -2512,8 +2508,6 @@ export async function handleCommand(
|
||||
null
|
||||
);
|
||||
} catch {
|
||||
// Preserve offline access, but never let a successful fresh list be
|
||||
// shadowed forever by a session deleted on another device.
|
||||
return cloud.getCachedDiscoveryRecord(sessionId) ?? null;
|
||||
}
|
||||
}
|
||||
@@ -2604,13 +2598,13 @@ export async function handleCommand(
|
||||
// updateSession replaces metadata wholesale in both the session row and
|
||||
// the manifest, so merge over what each already holds. A null value
|
||||
// removes the key, which is how callers clear a flag.
|
||||
const binding = await getCommandSessionBinding(ctx, sessionId, args);
|
||||
if (!binding) throw new Error(`Session ${sessionId} not found`);
|
||||
const store = new SqliteSessionStore();
|
||||
const asRecord = (value: unknown): JsonRecord =>
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as JsonRecord)
|
||||
: {};
|
||||
const binding = await getCommandSessionBinding(ctx, sessionId, args);
|
||||
if (!binding) throw new Error(`Session ${sessionId} not found`);
|
||||
const existingSession = await binding.sessionManager.get(sessionId);
|
||||
const existing =
|
||||
binding.kind === "local" ? store.get(sessionId) : undefined;
|
||||
@@ -2656,11 +2650,6 @@ export async function handleCommand(
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const binding = await getCommandSessionBinding(ctx, sessionId, args);
|
||||
ctx = getEnvironmentContext(
|
||||
ctx,
|
||||
binding?.environmentId ?? LOCAL_ENVIRONMENT_ID,
|
||||
);
|
||||
ctx.logger?.log("Deleting desktop chat session", { command, sessionId });
|
||||
const store = new SqliteSessionStore();
|
||||
const row = store.get(sessionId);
|
||||
@@ -2668,6 +2657,7 @@ export async function handleCommand(
|
||||
const sessionCwd =
|
||||
row?.cwd?.trim() ||
|
||||
(typeof manifest?.cwd === "string" ? manifest.cwd.trim() : "");
|
||||
const binding = await getCommandSessionBinding(ctx, sessionId, args);
|
||||
let deleted = false;
|
||||
let deleteError: Error | null = null;
|
||||
try {
|
||||
@@ -2722,10 +2712,14 @@ export async function handleCommand(
|
||||
}
|
||||
}
|
||||
for (const path of [sessionLogPath(sessionId)]) {
|
||||
if (removePathIfExists(path, { recursive: true })) deleted = true;
|
||||
if (removePathIfExists(path, { recursive: true })) {
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
for (const dir of directoryCandidates) {
|
||||
if (removePathIfExists(dir, { recursive: true })) deleted = true;
|
||||
if (removePathIfExists(dir, { recursive: true })) {
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
for (const path of [
|
||||
row?.messagesPath,
|
||||
@@ -2734,15 +2728,20 @@ export async function handleCommand(
|
||||
: null,
|
||||
join(sharedSessionDataDir(), sessionId, `${sessionId}.json`),
|
||||
].filter((v): v is string => typeof v === "string" && v.length > 0)) {
|
||||
if (removePathIfExists(path)) deleted = true;
|
||||
if (removePathIfExists(path)) {
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
for (const suffix of ["messages.json"]) {
|
||||
const fileName = `${sessionId}.${suffix}`;
|
||||
const found = findArtifactUnderDir(
|
||||
join(sharedSessionDataDir(), rootSessionIdFrom(sessionId)),
|
||||
`${sessionId}.${suffix}`,
|
||||
fileName,
|
||||
4,
|
||||
);
|
||||
if (found && removePathIfExists(found)) deleted = true;
|
||||
if (found && removePathIfExists(found)) {
|
||||
deleted = true;
|
||||
}
|
||||
}
|
||||
if (!deleted && deleteError) {
|
||||
ctx.logger?.error?.("Failed to delete desktop chat session", {
|
||||
|
||||
@@ -902,7 +902,6 @@ describe("remote environment command routing", () => {
|
||||
const update = vi.fn(async () => ({ updated: true }));
|
||||
const deleteSession = vi.fn(async () => true);
|
||||
const sessionManager = {
|
||||
get: vi.fn(async () => ({ sessionId: "remote-session", metadata: {} })),
|
||||
readMessages,
|
||||
update,
|
||||
delete: deleteSession,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { workspacePathsOverlap } from "./session-config";
|
||||
|
||||
it("resolves local aliases without resolving SSH paths or combining distinct workspaces", () => {
|
||||
it("resolves local aliases without combining distinct workspaces", () => {
|
||||
const directory = mkdtempSync(join(tmpdir(), "workspace-paths-"));
|
||||
const root = join(directory, "repo");
|
||||
const child = join(root, "child");
|
||||
@@ -14,14 +14,10 @@ it("resolves local aliases without resolving SSH paths or combining distinct wor
|
||||
mkdirSync(child, { recursive: true });
|
||||
mkdirSync(other);
|
||||
symlinkSync(root, alias, "junction");
|
||||
expect(workspacePathsOverlap(alias, root, "local")).toBe(true);
|
||||
expect(workspacePathsOverlap(alias, child, "local")).toBe(true);
|
||||
expect(workspacePathsOverlap(child, alias, "local")).toBe(true);
|
||||
expect(workspacePathsOverlap(alias, other, "local")).toBe(false);
|
||||
expect(workspacePathsOverlap(alias, root, "ssh")).toBe(false);
|
||||
expect(workspacePathsOverlap(join(alias, "child"), alias, "ssh")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(workspacePathsOverlap(alias, root)).toBe(true);
|
||||
expect(workspacePathsOverlap(alias, child)).toBe(true);
|
||||
expect(workspacePathsOverlap(child, alias)).toBe(true);
|
||||
expect(workspacePathsOverlap(alias, other)).toBe(false);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { realpathSync } from "node:fs";
|
||||
import { resolve, sep } from "node:path";
|
||||
|
||||
function resolveWorkspacePath(
|
||||
path: string,
|
||||
environment: "local" | "ssh",
|
||||
): string {
|
||||
if (environment === "ssh") return resolve(path);
|
||||
function resolveWorkspacePath(path: string): string {
|
||||
try {
|
||||
return realpathSync.native(path);
|
||||
} catch (error) {
|
||||
@@ -15,14 +11,9 @@ function resolveWorkspacePath(
|
||||
}
|
||||
}
|
||||
|
||||
export function workspacePathsOverlap(
|
||||
path: string | undefined,
|
||||
root: string,
|
||||
environment: "local" | "ssh",
|
||||
): boolean {
|
||||
if (!path) return false;
|
||||
const resolved = resolveWorkspacePath(path, environment);
|
||||
const parent = resolveWorkspacePath(root, environment);
|
||||
export function workspacePathsOverlap(path: string, root: string): boolean {
|
||||
const resolved = resolveWorkspacePath(path);
|
||||
const parent = resolveWorkspacePath(root);
|
||||
return (
|
||||
resolved === parent ||
|
||||
resolved.startsWith(parent.endsWith(sep) ? parent : `${parent}${sep}`) ||
|
||||
|
||||
@@ -158,10 +158,3 @@ export function normalizeChatFinishStatus(status?: string): string {
|
||||
}
|
||||
return "idle";
|
||||
}
|
||||
|
||||
export function readSessionMetadata(sessionId: string): JsonRecord | undefined {
|
||||
const manifest = readSessionManifest(sessionId);
|
||||
return manifest?.metadata && typeof manifest.metadata === "object"
|
||||
? (manifest.metadata as JsonRecord)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -1361,7 +1361,6 @@ function ChatThreadPane({
|
||||
environmentProfilesLoading: boolean;
|
||||
historySession?: SessionHistoryItem;
|
||||
liveHistorySession?: SessionHistoryItem;
|
||||
/** Attachments to restore into the composer alongside initialPromptDraft. */
|
||||
initialAttachments?: File[];
|
||||
initialPromptDraft?: string;
|
||||
initialHandoffFollowUpId?: string;
|
||||
@@ -1475,7 +1474,6 @@ function ChatThreadPane({
|
||||
const cloudAgentsEnabled =
|
||||
cloudAgentsFlagEnabled === true &&
|
||||
environmentId === LOCAL_WORKSPACE_ENVIRONMENT_ID;
|
||||
const cloudHandoffAvailable = cloudAgentsEnabled;
|
||||
const handoffStartingRef = useRef(false);
|
||||
const sourceSessionId = sessionId ?? historySession?.sessionId;
|
||||
// Handoff state is keyed by local session id; another environment may reuse the id.
|
||||
@@ -1483,15 +1481,6 @@ function ChatThreadPane({
|
||||
sourceSessionId && environmentId === LOCAL_WORKSPACE_ENVIRONMENT_ID
|
||||
? handoffUiState[sourceSessionId]
|
||||
: undefined;
|
||||
const handoffRetry =
|
||||
handoffUi?.status === "failed" || handoffUi?.status === "retry_restored"
|
||||
? {
|
||||
draft: handoffUi.retryDraft
|
||||
? `/cloud ${handoffUi.retryDraft}`
|
||||
: "/cloud",
|
||||
attachments: handoffUi.retryAttachments,
|
||||
}
|
||||
: null;
|
||||
const { user: accountUser, activeOrganization } = useAccount();
|
||||
const accountUserId = accountUser?.id ?? null;
|
||||
const openGitHubConnect = useCallback(
|
||||
@@ -1600,8 +1589,6 @@ function ChatThreadPane({
|
||||
const followUpSessionRef = useRef(sessionId);
|
||||
followUpSessionRef.current = sessionId;
|
||||
const followUpReadRef = useRef(0);
|
||||
const attachmentCountRef = useRef(pendingAttachments.length);
|
||||
attachmentCountRef.current = pendingAttachments.length;
|
||||
const refreshFollowUp = useCallback(async () => {
|
||||
if (!isCloudSession || !sessionId) return null;
|
||||
const request = ++followUpReadRef.current;
|
||||
@@ -1647,7 +1634,7 @@ function ChatThreadPane({
|
||||
followUpSessionRef.current === sessionId &&
|
||||
(isThreadActive?.() ?? true) &&
|
||||
!promptInputRef.current.trim() &&
|
||||
attachmentCountRef.current === 0;
|
||||
draftRef.current.attachments.length === 0;
|
||||
setUpdatingFollowUp(true);
|
||||
try {
|
||||
if (action === "restore")
|
||||
@@ -2195,7 +2182,7 @@ function ChatThreadPane({
|
||||
historySession.origin === "cloud" &&
|
||||
shouldPreserveCloudComposer(
|
||||
promptInputRef.current,
|
||||
attachmentCountRef.current,
|
||||
draftRef.current.attachments.length,
|
||||
draftRef.current.lastRestoredFollowUpId,
|
||||
initialHandoffFollowUpId,
|
||||
);
|
||||
@@ -2280,8 +2267,7 @@ function ChatThreadPane({
|
||||
);
|
||||
|
||||
const handleRestoreHandoffDraft = useCallback(() => {
|
||||
if (!sourceSessionId || !handoffRetry || handoffUi?.status !== "failed")
|
||||
return;
|
||||
if (!sourceSessionId || handoffUi?.status !== "failed") return;
|
||||
if (draftRef.current.text.trim() || draftRef.current.attachments.length) {
|
||||
toast({
|
||||
title: "Your current draft is unchanged",
|
||||
@@ -2289,14 +2275,14 @@ function ChatThreadPane({
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (handoffRetry.draft) setPromptInput(handoffRetry.draft);
|
||||
if (handoffRetry.attachments?.length)
|
||||
setPendingAttachments([...handoffRetry.attachments]);
|
||||
setPromptInput(
|
||||
handoffUi.retryDraft ? `/cloud ${handoffUi.retryDraft}` : "/cloud",
|
||||
);
|
||||
setPendingAttachments([...handoffUi.retryAttachments]);
|
||||
onHandoffUiAction({ type: "retry_restored", sourceSessionId });
|
||||
}, [
|
||||
draftRef,
|
||||
handoffRetry,
|
||||
handoffUi?.status,
|
||||
handoffUi,
|
||||
onHandoffUiAction,
|
||||
setPromptInput,
|
||||
setPendingAttachments,
|
||||
@@ -2448,7 +2434,7 @@ function ChatThreadPane({
|
||||
async (prompt: string) => {
|
||||
const trimmed = prompt.trim();
|
||||
const handoff = parseHandoffCommand(trimmed);
|
||||
if (handoff && cloudHandoffAvailable) {
|
||||
if (handoff && cloudAgentsEnabled) {
|
||||
await prepareHandoff(handoff.nextCommand);
|
||||
return;
|
||||
}
|
||||
@@ -2513,7 +2499,7 @@ function ChatThreadPane({
|
||||
setPendingAttachments,
|
||||
sourceSessionId,
|
||||
threadId,
|
||||
cloudHandoffAvailable,
|
||||
cloudAgentsEnabled,
|
||||
workIn,
|
||||
],
|
||||
);
|
||||
@@ -3112,7 +3098,7 @@ function ChatThreadPane({
|
||||
<ChatInputBar
|
||||
readOnly={isCloudSessionExpired}
|
||||
attachments={attachmentList}
|
||||
cloudHandoffAvailable={cloudHandoffAvailable}
|
||||
cloudHandoffAvailable={cloudAgentsEnabled}
|
||||
environmentId={environmentId}
|
||||
hasRunningAgents={agentActivity.running > 0}
|
||||
onAbort={handleAbort}
|
||||
|
||||
@@ -73,22 +73,6 @@ describe("resolveLiveHistorySession", () => {
|
||||
expect(resolveLiveHistorySession(snapshot, [remote])).toBe(snapshot);
|
||||
expect(resolveLiveHistorySession(remote, [local, remote])).toBe(remote);
|
||||
});
|
||||
|
||||
it("uses refreshed metadata for an already-open session", () => {
|
||||
const snapshot = sessionRow("handoff-source");
|
||||
const refreshed = {
|
||||
...snapshot,
|
||||
metadata: {
|
||||
handoff: {
|
||||
status: "complete",
|
||||
toCloudSessionId: "cloud-1",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveLiveHistorySession(snapshot, [refreshed])).toBe(refreshed);
|
||||
expect(resolveLiveHistorySession(snapshot, [])).toBe(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
let container: HTMLDivElement;
|
||||
|
||||
@@ -35,7 +35,7 @@ function harness() {
|
||||
}
|
||||
|
||||
describe("summary handoff lifecycle", () => {
|
||||
it("keeps the source open and retains the target link through sending and duplicate completion", async () => {
|
||||
it("retains the target link and ignores repeated completion", async () => {
|
||||
const h = harness();
|
||||
const handoffAttemptId = h.start();
|
||||
await h.lifecycle.onEvent({
|
||||
@@ -53,17 +53,19 @@ describe("summary handoff lifecycle", () => {
|
||||
handoffAttemptId,
|
||||
phase: "complete",
|
||||
});
|
||||
const completed = h.state();
|
||||
await h.lifecycle.onRpcResolved("local", {
|
||||
handoffAttemptId,
|
||||
result,
|
||||
});
|
||||
expect(h.state()).toBe(completed);
|
||||
expect(h.state().local).toMatchObject({
|
||||
status: "complete",
|
||||
sessionId: "cloud",
|
||||
dashboardUrl: result.dashboardUrl,
|
||||
});
|
||||
});
|
||||
it("restores preparation failures with images, but never restores /cloud after a known target", async () => {
|
||||
it("retains failed input with images, but never offers /cloud again after a known target", async () => {
|
||||
const h = harness();
|
||||
const attachments = [
|
||||
new File(["image"], "shot.png", { type: "image/png" }),
|
||||
@@ -122,7 +124,7 @@ describe("summary handoff lifecycle", () => {
|
||||
sessionId: "cloud-2",
|
||||
});
|
||||
});
|
||||
it("keeps navigation in the current view and surfaces delivery uncertainty", async () => {
|
||||
it("shows delivery uncertainty with the cloud link", async () => {
|
||||
const h = harness();
|
||||
await h.lifecycle.onRpcResolved("local", {
|
||||
handoffAttemptId: h.start(),
|
||||
|
||||
@@ -32,7 +32,6 @@ export const HANDOFF_PROGRESS_LABELS: Record<HandoffProgressPhase, string> = {
|
||||
complete: "Cloud handoff complete.",
|
||||
};
|
||||
|
||||
/** Returns null for ordinary prompts and slash-command lookalikes. */
|
||||
export function parseHandoffCommand(
|
||||
input: string,
|
||||
): ParsedHandoffCommand | null {
|
||||
@@ -70,9 +69,6 @@ type HandoffWarningToast = {
|
||||
variant?: "destructive";
|
||||
};
|
||||
|
||||
/**
|
||||
* Do not offer an unconfirmed command for resending. It may already be queued.
|
||||
*/
|
||||
export function buildHandoffWarningToast(fields: {
|
||||
warning?: unknown;
|
||||
warningKind?: unknown;
|
||||
|
||||
@@ -69,7 +69,7 @@ describe("parseCloudSessionError", () => {
|
||||
it("replaces malformed environment mismatch details with actionable copy", () => {
|
||||
expect(
|
||||
humanizeCloudSessionError(
|
||||
"The session belongs to environment undefined, not [object Object].",
|
||||
"The session belongs to environment undefined, not local.",
|
||||
),
|
||||
).toBe(
|
||||
"Cline couldn’t identify this cloud session’s environment. Open it from its dashboard link or retry where it was created.",
|
||||
|
||||
@@ -68,7 +68,7 @@ export function humanizeCloudSessionError(value: string): string {
|
||||
const message = parseCloudSessionError(value)?.message ?? value;
|
||||
if (
|
||||
/session belongs to environment/i.test(message) &&
|
||||
(/\bundefined\b/i.test(message) || message.includes("[object Object]"))
|
||||
/\bundefined\b/i.test(message)
|
||||
) {
|
||||
return "Cline couldn’t identify this cloud session’s environment. Open it from its dashboard link or retry where it was created.";
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@ export type DesktopThread = {
|
||||
sessionId?: string;
|
||||
hasStarted?: boolean;
|
||||
initialPromptDraft?: string;
|
||||
/** Attachments restored into the composer alongside initialPromptDraft. */
|
||||
initialAttachments?: File[];
|
||||
initialHandoffFollowUpId?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user