fix(ui): show sessions while remote projects clone (#130192)

* fix(ui): show sessions while projects prepare

Create remote-project sessions before cloning, expose workspace preparation progress, and retain private restart-safe project intent until the workspace binds.

* fix(ui): materialize remote projects before worktree sessions

* fix(protocol): refresh session create clients
This commit is contained in:
Peter Steinberger
2026-08-26 10:38:29 -07:00
committed by GitHub
parent 655858c142
commit b6662bf157
25 changed files with 1180 additions and 206 deletions
@@ -9189,6 +9189,7 @@ public struct SessionsCreateParams: Codable, Sendable {
public let message: String?
public let attachments: [[String: AnyCodable]]?
public let projectid: String?
public let projectgiturl: String?
public let worktree: Bool?
public let worktreebaseref: String?
public let worktreename: String?
@@ -9219,6 +9220,7 @@ public struct SessionsCreateParams: Codable, Sendable {
message: String? = nil,
attachments: [[String: AnyCodable]]? = nil,
projectid: String? = nil,
projectgiturl: String? = nil,
worktree: Bool? = nil,
worktreebaseref: String? = nil,
worktreename: String? = nil,
@@ -9248,6 +9250,7 @@ public struct SessionsCreateParams: Codable, Sendable {
self.message = message
self.attachments = attachments
self.projectid = projectid
self.projectgiturl = projectgiturl
self.worktree = worktree
self.worktreebaseref = worktreebaseref
self.worktreename = worktreename
@@ -9279,6 +9282,7 @@ public struct SessionsCreateParams: Codable, Sendable {
case message
case attachments
case projectid = "projectId"
case projectgiturl = "projectGitUrl"
case worktree
case worktreebaseref = "worktreeBaseRef"
case worktreename = "worktreeName"
@@ -117,8 +117,18 @@ describe("project protocol schemas", () => {
).toBe(false);
});
it("accepts projectId as an additive sessions.create parameter", () => {
it("accepts bounded project identity and remote URL as additive sessions.create parameters", () => {
expect(validateSessionsCreateParams({ agentId: "main", projectId: "openclaw" })).toBe(true);
expect(validateSessionsCreateParams({ agentId: "main", projectId: "" })).toBe(false);
expect(
validateSessionsCreateParams({
agentId: "main",
projectGitUrl: "https://github.com/openclaw/openclaw.git",
}),
).toBe(true);
expect(validateSessionsCreateParams({ agentId: "main", projectGitUrl: "" })).toBe(false);
expect(
validateSessionsCreateParams({ agentId: "main", projectGitUrl: "x".repeat(2_049) }),
).toBe(false);
});
});
@@ -56,6 +56,13 @@ export const SessionsCreateParamsSchema = closedObject({
description: "Start in a registered project; operator.write.",
}),
),
projectGitUrl: Type.Optional(
Type.String({
minLength: 1,
maxLength: 2048,
description: "Prepare a remote project before the initial agent turn; operator.write.",
}),
),
worktree: Type.Optional(Type.Boolean()),
worktreeBaseRef: Type.Optional(
Type.String({
@@ -7,8 +7,12 @@ import {
openOpenClawAgentDatabase,
} from "../../state/openclaw-agent-db.js";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import { upsertSessionEntryCore } from "./session-accessor.js";
import type { SessionEntry } from "./types.js";
import { loadSessionEntry, upsertSessionEntryCore } from "./session-accessor.js";
import {
projectPublicSessionEntry,
projectPublicSessionEntryPatch,
} from "./session-entry-projection.js";
import type { InternalSessionEntry } from "./types.js";
const tempDirs = createTempDirTracker();
@@ -19,7 +23,7 @@ afterEach(() => {
});
describe("SQLite session row persistence", () => {
it("keeps runtime-only resolved skills out of raw SQLite JSON without mutating the session", async () => {
it("persists pending remote projects but excludes runtime-only resolved skills from SQLite JSON", async () => {
const stateDir = fs.realpathSync(tempDirs.make("openclaw-sqlite-session-skills-"));
const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
const sessionKey = "agent:main:runtime-skills";
@@ -32,9 +36,10 @@ describe("SQLite session row persistence", () => {
source: "# Demo\n\n" + "runtime skill content ".repeat(100),
}),
];
const entry: SessionEntry = {
const entry: InternalSessionEntry = {
sessionId: "runtime-skills-session",
updatedAt: 42,
pendingProjectGitUrl: "https://github.com/openclaw/openclaw.git",
skillsSnapshot: {
prompt: "compact skill prompt",
skills: [{ name: "demo" }],
@@ -50,7 +55,13 @@ describe("SQLite session row persistence", () => {
const row = database.db
.prepare("SELECT entry_json FROM session_nodes WHERE session_key = ?")
.get(sessionKey) as { entry_json: string };
const persisted = JSON.parse(row.entry_json) as SessionEntry;
const persisted = JSON.parse(row.entry_json) as InternalSessionEntry;
expect(persisted.pendingProjectGitUrl).toBe("https://github.com/openclaw/openclaw.git");
expect(loadSessionEntry({ agentId: "main", env, sessionKey })?.pendingProjectGitUrl).toBe(
entry.pendingProjectGitUrl,
);
expect(projectPublicSessionEntry(entry)).not.toHaveProperty("pendingProjectGitUrl");
expect(projectPublicSessionEntryPatch(entry)).not.toHaveProperty("pendingProjectGitUrl");
expect(persisted.skillsSnapshot).toEqual({
prompt: "compact skill prompt",
skills: [{ name: "demo" }],
@@ -11,6 +11,7 @@ export const SESSION_ENTRY_PRIVATE_CLEAR_PATCH = {
lastRunId: undefined,
lifecycleRunId: undefined,
mainRestartRecovery: undefined,
pendingProjectGitUrl: undefined,
sessionDiffBaselineCapture: undefined,
} satisfies Partial<InternalSessionEntry>;
@@ -19,6 +20,7 @@ const PRIVATE_SESSION_ENTRY_KEYS = [
"lastRunId",
"lifecycleRunId",
"mainRestartRecovery",
"pendingProjectGitUrl",
"sessionDiffBaselineCapture",
] as const satisfies readonly (keyof InternalSessionEntry)[];
+2
View File
@@ -631,6 +631,8 @@ export type InternalSessionEntryCore = SessionEntryCore & {
lastRunId?: string;
/** Run admitted by the session lane; overwritten at admission and checked by transcript writes. */
activeWriterRunId?: string;
/** Canonical remote repository awaiting preparation by this exact session generation. */
pendingProjectGitUrl?: string;
/** Private per-generation ownership for the pre-runtime checkout baseline capture. */
sessionDiffBaselineCapture?: import("./session-diff-baseline-capture.js").SessionDiffBaselineCapture;
mainRestartRecovery?: MainRestartRecoveryState;
+27 -1
View File
@@ -22,6 +22,14 @@ export function updateChatRunProgressSnapshot(
const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId.trim() : "";
const review = asNullableRecord(data.review) ?? undefined;
const reviewId = typeof review?.id === "string" ? review.id.trim() : "";
const isStartupStatus =
event.stream === "run_status" &&
[
"preparing_workspace",
"provisioning_environment",
"preparing_context",
"starting_model",
].includes(phase);
const preambleItemId =
typeof data.itemId === "string" && data.itemId.trim()
? data.itemId.trim()
@@ -53,7 +61,14 @@ export function updateChatRunProgressSnapshot(
candidate.data.phase === "strict_review_required" &&
candidate.data.reviewId === data.reviewId,
);
if (!isTool && !isPreamble && !isStandaloneGuardian && !isNotice && !resolvesStrictReview) {
if (
!isTool &&
!isPreamble &&
!isStartupStatus &&
!isStandaloneGuardian &&
!isNotice &&
!resolvesStrictReview
) {
return snapshot;
}
@@ -75,6 +90,17 @@ export function updateChatRunProgressSnapshot(
next.byteLength = next.events.reduce((total, candidate) => total + jsonUtf8Bytes(candidate), 0);
};
if (isStartupStatus) {
if (
next.events.some((candidate) => candidate.stream === "tool" || candidate.stream === "item")
) {
return next;
}
removeWhere((candidate) => candidate.stream === "run_status");
} else if (isTool || isPreamble) {
removeWhere((candidate) => candidate.stream === "run_status");
}
if (isTool) {
removeWhere((candidate) => {
if (candidate.stream !== "tool" || candidate.data?.toolCallId !== toolCallId) {
+25
View File
@@ -52,6 +52,31 @@ describe("createChatRunState", () => {
expect(state.registry.shift("run-b")?.clientRunId).toBe("client-b-2");
});
it("retains only the latest startup status until observable run activity begins", () => {
const state = createChatRunState();
const event = (seq: number, stream: string, data: Record<string, unknown>) =>
state.recordProgressEvent("run-1", {
runId: "run-1",
seq,
stream,
ts: 1_000 + seq,
sessionKey: "main",
data,
});
event(1, "run_status", { phase: "preparing_workspace" });
event(2, "run_status", { phase: "preparing_context" });
expect(state.runs.get("run-1")?.progressSnapshot?.events).toMatchObject([
{ seq: 2, stream: "run_status", data: { phase: "preparing_context" } },
]);
event(3, "tool", { phase: "start", name: "read", toolCallId: "read-1" });
event(4, "run_status", { phase: "starting_model" });
expect(state.runs.get("run-1")?.progressSnapshot?.events).toMatchObject([
{ seq: 3, stream: "tool", data: { phase: "start", toolCallId: "read-1" } },
]);
});
it("keeps completed owners and standalone notices reconstructable until bounded eviction", () => {
const state = createChatRunState();
const event = (seq: number, stream: string, data: Record<string, unknown>) =>
+1
View File
@@ -1419,6 +1419,7 @@ export function createAgentEventHandler({
!isAborted &&
((isToolEvent && !suppressHeartbeatToolEvents) ||
isItemEvent ||
evt.stream === "run_status" ||
evt.stream === "notice" ||
typeof evt.data?.reviewId === "string" ||
evt.data?.phase === "started" ||
@@ -45,6 +45,7 @@ import {
} from "./chat-server-timing.js";
import type { createGatewayChatUserTurnController } from "./chat-user-turn-recorder.js";
import { emitSessionsChanged } from "./session-change-event.js";
import { prepareSessionProjectWorkspace } from "./session-create-project.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
type PreparedChatSendAttachments = Extract<
@@ -220,22 +221,36 @@ export function startChatDispatch(params: StartChatDispatchParams): void {
measureDiagnosticsTimelineSpan(
"gateway.chat_send.dispatch_inbound",
async () => {
// Preparation stays after the ACK but inside admitted dispatch, so the
// same visible run owns workspace progress, cancellation, and errors.
let assertWorkspaceRunOwnership: (() => void) | undefined;
if (entry && Object.hasOwn(entry, "pendingProjectGitUrl")) {
assertWorkspaceRunOwnership = await prepareSessionProjectWorkspace({
admission,
client,
context,
session,
});
assertWorkspaceRunOwnership();
}
if (replyContextFieldsPromise && !preAckReplyContextPromise) {
applyChatSendReplyContextFields(ctx, await replyContextFieldsPromise);
const replyContextFields = await replyContextFieldsPromise;
assertWorkspaceRunOwnership?.();
applyChatSendReplyContextFields(ctx, replyContextFields);
messageInjectionAttempt = beginCapturedMessageInjection();
}
if (messageInjectionAttempt) {
if (
await finalizeAcceptedChatSendMessageInjection({
attempt: messageInjectionAttempt,
context,
ctx,
persistUserTurnTranscriptBestEffort: persistGatewayUserTurnTranscriptBestEffort,
session,
startedAt: admissionStartedAt,
target: messageInjectionTarget!,
})
) {
const injected = await finalizeAcceptedChatSendMessageInjection({
attempt: messageInjectionAttempt,
context,
ctx,
persistUserTurnTranscriptBestEffort: persistGatewayUserTurnTranscriptBestEffort,
session,
startedAt: admissionStartedAt,
target: messageInjectionTarget!,
});
assertWorkspaceRunOwnership?.();
if (injected) {
acceptedMessageInjection = true;
return {
queuedFinal: false,
@@ -243,9 +258,12 @@ export function startChatDispatch(params: StartChatDispatchParams): void {
};
}
}
applyChatSendManagedMedia(ctx, await pluginBoundMediaPromise);
const dispatchInbound = () =>
dispatchInboundMessageWithProjectedDispatcher({
const pluginBoundMedia = await pluginBoundMediaPromise;
assertWorkspaceRunOwnership?.();
applyChatSendManagedMedia(ctx, pluginBoundMedia);
const dispatchInbound = () => {
assertWorkspaceRunOwnership?.();
return dispatchInboundMessageWithProjectedDispatcher({
ctx,
cfg,
toolsAllow,
@@ -369,6 +387,7 @@ export function startChatDispatch(params: StartChatDispatchParams): void {
},
},
});
};
const dispatchResult = await (cronCreatorAuthority && externalAuthorityAdmission
? externalAuthorityAdmission.run(
cronCreatorAuthority,
@@ -0,0 +1,154 @@
import {
ErrorCodes,
errorShape,
type ErrorShape,
} from "../../../packages/gateway-protocol/src/index.js";
import { patchSessionEntryCore } from "../../config/sessions/session-accessor.js";
import { assertAgentRunLifecycleGenerationCurrent } from "../../infra/agent-events.js";
import { emitAgentRunStatusEvent } from "../../infra/agent-run-status-events.js";
import { materializeProjectClone } from "../../projects/project-clone.js";
import { parseProjectGitUrl } from "../../projects/project-git-url.js";
import { resolveProjectDirectory } from "../../projects/project-registry.js";
import { githubApiToken } from "../control-ui-github-api.js";
import { hasActiveAgentRuntimeAuthority } from "./agent-runtime-authority.js";
import type { AdmittedChatSend } from "./chat-send-admission.js";
import type { PreparedChatSendSession } from "./chat-send-session.js";
import { emitSessionsChanged } from "./session-change-event.js";
import { prepareSessionCreateFilesystemRoot } from "./session-create-root.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
const SESSION_PROJECT_OWNERSHIP_ERROR =
"Session changed while preparing its project; retry the task.";
export function normalizeSessionProjectGitUrl(value: unknown): string | undefined {
return typeof value === "string" && value.length <= 2048
? parseProjectGitUrl(value)?.url
: undefined;
}
export function validateSessionProjectPreparation(params: {
cwd?: string;
execNode?: string;
gitUrl?: string;
hasInitialTurn: boolean;
projectId?: string;
worktree: boolean;
}): ErrorShape | undefined {
if (!params.gitUrl) {
return params.projectId && (params.cwd || params.execNode)
? errorShape(
ErrorCodes.INVALID_REQUEST,
"sessions.create projectId cannot be combined with cwd or execNode",
)
: undefined;
}
if (!normalizeSessionProjectGitUrl(params.gitUrl)) {
return errorShape(
ErrorCodes.INVALID_REQUEST,
"Use a GitHub HTTPS or git@github.com repository URL. Local paths and file URLs are not accepted.",
);
}
if (params.projectId || params.cwd || params.execNode || params.worktree) {
return errorShape(
ErrorCodes.INVALID_REQUEST,
"sessions.create projectGitUrl cannot be combined with projectId, cwd, execNode, or worktree",
);
}
return params.hasInitialTurn
? undefined
: errorShape(
ErrorCodes.INVALID_REQUEST,
"sessions.create projectGitUrl requires an initial turn",
);
}
/** Bind a persisted remote intent only while its exact admitted run remains authoritative. */
export async function prepareSessionProjectWorkspace(params: {
admission: AdmittedChatSend;
client: GatewayRequestHandlerOptions["client"];
context: GatewayRequestHandlerOptions["context"];
session: PreparedChatSendSession;
}): Promise<() => void> {
const { admission, client, context, session } = params;
const { entry, cfg, agentId, clientRunId, sessionKey, storePath } = session;
const gitUrl = normalizeSessionProjectGitUrl(entry?.pendingProjectGitUrl);
if (!entry || !gitUrl || gitUrl !== entry.pendingProjectGitUrl) {
throw new Error("Saved project repository is invalid; select the repository and retry.");
}
const { controller } = admission.activeRunAbort;
const signal = controller.signal;
const assertRunOwnership = () => {
signal.throwIfAborted();
const activeRun = context.chatAbortControllers.get(clientRunId);
if (
!activeRun ||
activeRun !== admission.activeRunAbort.entry ||
activeRun.controller !== controller ||
activeRun.sessionKey !== sessionKey ||
activeRun.sessionId !== entry.sessionId ||
entry.sessionId !== admission.admittedSessionId ||
activeRun.lifecycleGeneration !== admission.lifecycleGeneration ||
activeRun.projectSessionActive === false ||
activeRun.projectSessionTerminalPending === true ||
activeRun.projectSessionTerminalPersisted === true ||
!hasActiveAgentRuntimeAuthority(client, context)
) {
throw new Error(SESSION_PROJECT_OWNERSHIP_ERROR);
}
assertAgentRunLifecycleGenerationCurrent(admission.lifecycleGeneration);
};
assertRunOwnership();
emitAgentRunStatusEvent({
runId: clientRunId,
sessionKey,
agentId,
phase: "preparing_workspace",
});
const project = await materializeProjectClone(
{ cfg, gitUrl },
{ signal, token: githubApiToken(process.env, cfg) },
);
assertRunOwnership();
const directory = await resolveProjectDirectory(project.repoRoot);
assertRunOwnership();
const prepared = prepareSessionCreateFilesystemRoot({
cfg,
enforceSandboxContainment: true,
requestedProjectId: project.id,
sessionCwd: directory,
sessionKey,
targetAgentId: agentId,
});
if (!prepared.ok) {
throw new Error(prepared.error.message);
}
const bound = await patchSessionEntryCore(
{ agentId, sessionKey, storePath },
(current) => {
assertRunOwnership();
if (
current.sessionId !== entry.sessionId ||
current.pendingProjectGitUrl !== gitUrl ||
(current.projectId && current.projectId !== project.id)
) {
throw new Error(SESSION_PROJECT_OWNERSHIP_ERROR);
}
return {
projectId: project.id,
sessionRoot: prepared.value.sessionRoot,
spawnedCwd: prepared.value.sessionCwd,
pendingProjectGitUrl: undefined,
};
},
{ assertCommitAllowed: assertRunOwnership, requireWriteSuccess: true, skipMaintenance: true },
);
assertRunOwnership();
if (!bound) {
throw new Error("Session disappeared while preparing its project; start a new session.");
}
Object.assign(entry, bound);
// JSON omits the cleared key, so assigning the bound entry alone would retain stale intent.
delete entry.pendingProjectGitUrl;
emitSessionsChanged(context, { sessionKey, agentId, reason: "project" });
return assertRunOwnership;
}
+16 -9
View File
@@ -48,6 +48,10 @@ import {
resolveSessionCreateInitialTurn,
shouldAttachPendingMessageSeq,
} from "./session-create-initial-turn.js";
import {
normalizeSessionProjectGitUrl,
validateSessionProjectPreparation,
} from "./session-create-project.js";
import { prepareSessionCreateFilesystemRoot } from "./session-create-root.js";
import { resolveOperatorSessionCreation } from "./session-creation-provenance.js";
import { sessionLog } from "./sessions-shared.js";
@@ -151,15 +155,17 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
let requestedCwd = normalizeOptionalString(p.cwd);
const requestedExecNode = normalizeOptionalString(p.execNode);
const requestedProjectId = normalizeOptionalString(p.projectId);
if (requestedProjectId && (requestedCwd || requestedExecNode)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"sessions.create projectId cannot be combined with cwd or execNode",
),
);
const requestedProjectGitUrl = p.projectGitUrl;
const projectPreparationError = validateSessionProjectPreparation({
cwd: requestedCwd,
execNode: requestedExecNode,
gitUrl: requestedProjectGitUrl,
hasInitialTurn,
projectId: requestedProjectId,
worktree: p.worktree === true,
});
if (projectPreparationError) {
respond(false, undefined, projectPreparationError);
return;
}
// Agent tools expand `~` before RPC; the Gateway contract stays absolute-only.
@@ -517,6 +523,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
contextWindow: p.contextWindow,
thinkingLevel: p.thinkingLevel,
projectId: requestedProjectId,
pendingProjectGitUrl: normalizeSessionProjectGitUrl(requestedProjectGitUrl),
incognito: p.incognito,
...(client?.connect ? { requestingOperatorScopes: clientScopes } : {}),
...(client?.authenticatedUserProfile
@@ -2,7 +2,7 @@ import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, expect, test } from "vitest";
import { afterEach, expect, test, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { runWithCanonicalSkillWorkspace } from "../agents/skill-workshop-workspace-context.js";
import { createConfiguredSkillWorkshopTool } from "../agents/tools/skill-workshop-tool-factory.js";
@@ -10,20 +10,48 @@ import { managedWorktrees } from "../agents/worktrees/service.js";
import { getRuntimeConfig } from "../config/io.js";
import { loadSessionEntry, replaceSessionEntry } from "../config/sessions/session-accessor.js";
import { migrateManagedWorktreeCanonicalWorkspaces } from "../config/sessions/worktree-workspace-migration.js";
import { onAgentEvent, type AgentEventPayload } from "../infra/agent-events.js";
import { ProjectCloneError } from "../projects/project-clone-runtime.js";
import { registerProjectRegistry } from "../projects/project-registry.js";
import { createDeferredCore } from "../shared/deferred.js";
import { inspectSkillProposal } from "../skills/workshop/service.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { testState } from "./test-helpers.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import type { ChatAbortControllerEntry } from "./chat-abort.js";
import { createChatRunState } from "./server-chat-state.js";
import { dispatchInboundMessageMock, testState } from "./test-helpers.js";
import {
directSessionReq,
setupGatewaySessionsHandlerTestHarness,
} from "./test/server-sessions.test-helpers.js";
const projectCloneMocks = vi.hoisted(() => ({ materialize: vi.fn() }));
vi.mock("../projects/project-clone.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../projects/project-clone.js")>();
return { ...actual, materializeProjectClone: projectCloneMocks.materialize };
});
const execFileAsync = promisify(execFile);
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const { createSessionStoreDir } = setupGatewaySessionsHandlerTestHarness();
const controlUiClient = {
client: {
connect: {
scopes: ["operator.write"],
client: {
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: "dev",
platform: "web",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
},
} as never,
};
afterEach(() => {
projectCloneMocks.materialize.mockReset();
dispatchInboundMessageMock.mockReset();
closeOpenClawStateDatabaseForTest();
testState.agentConfig = undefined;
});
@@ -40,6 +68,437 @@ async function initializeRepository(root: string, name: string): Promise<string>
return await fs.realpath(repo);
}
test("sessions.create admits remote project work before materialization and dispatches only after authoritative binding", async () => {
const root = tempDirs.make("openclaw-session-remote-project-startup-");
const workspace = await initializeRepository(root, "workspace");
const projectRoot = await initializeRepository(root, "project");
testState.agentConfig = { workspace };
const { storePath } = await createSessionStoreDir();
const project = await registerProjectRegistry({ path: projectRoot, name: "Project" });
const materialization = createDeferredCore<typeof project>();
projectCloneMocks.materialize.mockReturnValueOnce(materialization.promise);
dispatchInboundMessageMock.mockResolvedValue({
queuedFinal: false,
counts: { block: 0, final: 0, tool: 0 },
});
const broadcast = vi.fn();
const events: AgentEventPayload[] = [];
const unsubscribe = onAgentEvent((event) => events.push(event));
try {
const created = await directSessionReq<{
entry: { sessionId: string };
key: string;
runId: string;
runStarted: boolean;
sessionId: string;
}>(
"sessions.create",
{
agentId: "main",
message: "Inspect the remote project",
projectGitUrl: "git@github.com:OpenClaw/OpenClaw.git",
},
{ ...controlUiClient, context: { broadcast } },
);
expect(created.ok, JSON.stringify(created.error)).toBe(true);
expect(created.payload).toMatchObject({
key: expect.any(String),
runId: expect.any(String),
runStarted: true,
sessionId: expect.any(String),
});
expect(created.payload?.entry).not.toHaveProperty("pendingProjectGitUrl");
const { key, runId, sessionId } = created.payload!;
expect(loadSessionEntry({ agentId: "main", sessionKey: key, storePath })).toMatchObject({
sessionId,
pendingProjectGitUrl: "https://github.com/openclaw/openclaw.git",
});
await vi.waitFor(() => expect(projectCloneMocks.materialize).toHaveBeenCalledOnce());
expect(projectCloneMocks.materialize).toHaveBeenCalledWith(
expect.objectContaining({ gitUrl: "https://github.com/openclaw/openclaw.git" }),
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
expect(events).toContainEqual(
expect.objectContaining({
runId,
stream: "run_status",
data: expect.objectContaining({ phase: "preparing_workspace" }),
}),
);
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
materialization.resolve(project);
await vi.waitFor(() => {
const error = broadcast.mock.calls.find(
([event, payload]) => event === "chat" && payload.state === "error",
);
expect(error?.[1]).toBeUndefined();
expect(dispatchInboundMessageMock).toHaveBeenCalledOnce();
});
expect(loadSessionEntry({ agentId: "main", sessionKey: key, storePath })).toMatchObject({
sessionId,
projectId: project.id,
spawnedCwd: projectRoot,
sessionRoot: projectRoot,
});
} finally {
materialization.resolve(project);
unsubscribe();
}
});
test("chat.abort cancels remote project preparation without late binding or agent dispatch", async () => {
const root = tempDirs.make("openclaw-session-remote-project-abort-");
const workspace = await initializeRepository(root, "workspace");
const projectRoot = await initializeRepository(root, "project");
testState.agentConfig = { workspace };
const { storePath } = await createSessionStoreDir();
const project = await registerProjectRegistry({ path: projectRoot, name: "Project" });
const materialization = createDeferredCore<typeof project>();
projectCloneMocks.materialize.mockReturnValueOnce(materialization.promise);
dispatchInboundMessageMock.mockResolvedValue({
queuedFinal: false,
counts: { block: 0, final: 0, tool: 0 },
});
const broadcast = vi.fn();
const chatAbortControllers = new Map<string, ChatAbortControllerEntry>();
const context = {
broadcast,
chatAbortControllers,
chatRunState: createChatRunState(),
dedupe: new Map(),
};
try {
const created = await directSessionReq<{
key: string;
runId: string;
runStarted: boolean;
sessionId: string;
}>(
"sessions.create",
{
agentId: "main",
message: "Cancel the remote project",
projectGitUrl: "https://github.com/openclaw/openclaw.git",
},
{ ...controlUiClient, context },
);
expect(created.ok, JSON.stringify(created.error)).toBe(true);
expect(created.payload?.runStarted).toBe(true);
const { key, runId, sessionId } = created.payload!;
await vi.waitFor(() => expect(projectCloneMocks.materialize).toHaveBeenCalledOnce());
const signal = chatAbortControllers.get(runId)?.controller.signal;
expect(signal).toBeInstanceOf(AbortSignal);
expect(projectCloneMocks.materialize).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ signal }),
);
expect(signal?.aborted).toBe(false);
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
const aborted = await directSessionReq<{ aborted: boolean; runIds: string[] }>(
"chat.abort",
{ agentId: "main", sessionKey: key, runId },
{ ...controlUiClient, context },
);
expect(aborted.ok, JSON.stringify(aborted.error)).toBe(true);
expect(aborted.payload).toMatchObject({ aborted: true, runIds: [runId] });
expect(signal?.aborted).toBe(true);
expect(broadcast).toHaveBeenCalledWith(
"chat",
expect.objectContaining({ runId, sessionKey: key, state: "aborted" }),
expect.anything(),
);
materialization.resolve(project);
await vi.waitFor(() =>
expect(context.dedupe.get(`chat:${runId}`)).toMatchObject({
payload: { runId, summary: "aborted" },
}),
);
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
expect(loadSessionEntry({ agentId: "main", sessionKey: key, storePath })).toMatchObject({
sessionId,
pendingProjectGitUrl: "https://github.com/openclaw/openclaw.git",
});
expect(loadSessionEntry({ agentId: "main", sessionKey: key, storePath })?.projectId).toBe(
undefined,
);
expect(context.chatRunState.runs.get(runId)?.abortMarker).toBeDefined();
} finally {
materialization.resolve(project);
}
});
test("sessions.create survives Gateway restart after remote project failure and retries preparation on the same session", async () => {
const root = tempDirs.make("openclaw-session-remote-project-failure-");
const workspace = await initializeRepository(root, "workspace");
const projectRoot = await initializeRepository(root, "project");
testState.agentConfig = { workspace };
const { storePath } = await createSessionStoreDir();
const project = await registerProjectRegistry({ path: projectRoot, name: "Project" });
const materialization = createDeferredCore<never>();
projectCloneMocks.materialize.mockReturnValueOnce(materialization.promise);
dispatchInboundMessageMock.mockResolvedValue({
queuedFinal: false,
counts: { block: 0, final: 0, tool: 0 },
});
const broadcast = vi.fn();
const context = { broadcast, chatAbortControllers: new Map(), dedupe: new Map() };
const created = await directSessionReq<{
key: string;
runId: string;
runStarted: boolean;
sessionId: string;
}>(
"sessions.create",
{
agentId: "main",
message: "Inspect the unavailable project",
projectGitUrl: "https://github.com/openclaw/openclaw.git",
},
{ ...controlUiClient, context },
);
expect(created.ok, JSON.stringify(created.error)).toBe(true);
expect(created.payload).toMatchObject({ runStarted: true, runId: expect.any(String) });
const { key, runId, sessionId } = created.payload!;
const entryAfterCreation = loadSessionEntry({ agentId: "main", sessionKey: key, storePath });
const failureMessage =
"Git clone could not reach GitHub. Check the Gateway network connection and retry.";
materialization.reject(new ProjectCloneError("network", failureMessage));
await vi.waitFor(() =>
expect(broadcast).toHaveBeenCalledWith(
"chat",
expect.objectContaining({
runId,
sessionKey: key,
state: "error",
errorMessage: expect.stringContaining(failureMessage),
}),
expect.anything(),
),
);
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
expect(loadSessionEntry({ agentId: "main", sessionKey: key, storePath })).toMatchObject({
sessionId,
});
await vi.waitFor(() =>
expect(loadSessionEntry({ agentId: "main", sessionKey: key, storePath })?.status).not.toBe(
"running",
),
);
const entryAfterFailure = loadSessionEntry({ agentId: "main", sessionKey: key, storePath });
const retriedMaterialization = createDeferredCore<typeof project>();
projectCloneMocks.materialize.mockReturnValueOnce(retriedMaterialization.promise);
const restartedContext = { broadcast, chatAbortControllers: new Map(), dedupe: new Map() };
try {
const retried = await directSessionReq<{ runId: string; status: string }>(
"chat.send",
{
sessionKey: key,
agentId: "main",
message: "Retry the remote project",
idempotencyKey: "remote-project-retry",
},
{ ...controlUiClient, context: restartedContext },
);
expect(retried.ok, JSON.stringify(retried.error)).toBe(true);
expect(retried.payload).toMatchObject({
runId: "remote-project-retry",
status: "started",
});
await vi.waitFor(() => expect(projectCloneMocks.materialize).toHaveBeenCalledTimes(2));
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
expect(entryAfterCreation).toMatchObject({
sessionId,
pendingProjectGitUrl: "https://github.com/openclaw/openclaw.git",
});
expect(entryAfterFailure).toMatchObject({
sessionId,
pendingProjectGitUrl: "https://github.com/openclaw/openclaw.git",
});
expect(loadSessionEntry({ agentId: "main", sessionKey: key, storePath })).toMatchObject({
sessionId,
pendingProjectGitUrl: "https://github.com/openclaw/openclaw.git",
});
retriedMaterialization.resolve(project);
await vi.waitFor(() => {
const error = broadcast.mock.calls.find(
([event, payload]) =>
event === "chat" && payload.runId === "remote-project-retry" && payload.state === "error",
);
expect(error?.[1]).toBeUndefined();
expect(dispatchInboundMessageMock).toHaveBeenCalledOnce();
});
const preparedEntry = loadSessionEntry({ agentId: "main", sessionKey: key, storePath });
expect(preparedEntry).toMatchObject({
sessionId,
projectId: project.id,
spawnedCwd: projectRoot,
sessionRoot: projectRoot,
});
expect(preparedEntry).not.toHaveProperty("pendingProjectGitUrl");
} finally {
retriedMaterialization.resolve(project);
}
});
test("sessions.create rejects conflicting, unsupported, and invalid remote project preparation before admission", async () => {
const root = tempDirs.make("openclaw-session-remote-project-invalid-");
const workspace = await initializeRepository(root, "workspace");
testState.agentConfig = { workspace };
await createSessionStoreDir();
const validRemote = "https://github.com/openclaw/openclaw.git";
const existing = await directSessionReq<{ key: string }>(
"sessions.create",
{ agentId: "main", key: "agent:main:existing-project-session" },
controlUiClient,
);
expect(existing.ok, JSON.stringify(existing.error)).toBe(true);
for (const params of [
{ message: "Start", projectGitUrl: validRemote, projectId: "workspace:main" },
{ message: "Start", projectGitUrl: validRemote, cwd: workspace },
{ message: "Start", projectGitUrl: validRemote, worktree: true },
{ projectGitUrl: validRemote },
{ message: "Start", projectGitUrl: " " },
{ message: "Start", projectGitUrl: "file:///tmp/untrusted-project" },
{ message: "Start", projectGitUrl: "https://token@github.com/openclaw/openclaw.git" },
{ key: existing.payload?.key, message: "Start", projectGitUrl: validRemote },
]) {
const created = await directSessionReq(
"sessions.create",
{ agentId: "main", ...params },
controlUiClient,
);
expect(created, JSON.stringify(params)).toMatchObject({
ok: false,
error: { code: "INVALID_REQUEST" },
});
}
expect(projectCloneMocks.materialize).not.toHaveBeenCalled();
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
});
test("chat.send visibly rejects corrupt persisted project intent without default-workspace dispatch", async () => {
const root = tempDirs.make("openclaw-session-remote-project-corrupt-");
testState.agentConfig = { workspace: await initializeRepository(root, "workspace") };
const { storePath } = await createSessionStoreDir();
const created = await directSessionReq<{ key: string }>(
"sessions.create",
{ agentId: "main", key: "agent:main:corrupt-project-session" },
controlUiClient,
);
expect(created.ok, JSON.stringify(created.error)).toBe(true);
const sessionKey = created.payload!.key;
const entry = loadSessionEntry({ agentId: "main", sessionKey, storePath });
expect(entry).toBeDefined();
await replaceSessionEntry(
{ agentId: "main", sessionKey, storePath },
{ ...entry!, pendingProjectGitUrl: "https://token@github.com/openclaw/openclaw.git" },
);
const broadcast = vi.fn();
const sent = await directSessionReq(
"chat.send",
{
agentId: "main",
sessionKey,
message: "Do not use the default workspace",
idempotencyKey: "corrupt-project-intent",
},
{ ...controlUiClient, context: { broadcast } },
);
expect(sent.ok, JSON.stringify(sent.error)).toBe(true);
await vi.waitFor(() =>
expect(broadcast).toHaveBeenCalledWith(
"chat",
expect.objectContaining({
runId: "corrupt-project-intent",
sessionKey,
state: "error",
errorMessage: expect.stringContaining("Saved project repository is invalid"),
}),
expect.anything(),
),
);
expect(projectCloneMocks.materialize).not.toHaveBeenCalled();
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
});
test("sessions.create terminalizes remote project preparation outside a sandboxed agent workspace", async () => {
const root = tempDirs.make("openclaw-session-remote-project-sandbox-");
const workspace = await initializeRepository(root, "workspace");
const outside = await initializeRepository(root, "outside");
testState.agentConfig = { workspace, sandbox: { mode: "all" } };
const { storePath } = await createSessionStoreDir();
const project = await registerProjectRegistry({ path: outside, name: "Outside" });
const materialization = createDeferredCore<typeof project>();
projectCloneMocks.materialize.mockReturnValueOnce(materialization.promise);
const broadcast = vi.fn();
try {
const created = await directSessionReq<{ key: string; runId: string; runStarted: boolean }>(
"sessions.create",
{
agentId: "main",
message: "Inspect the sandboxed project",
projectGitUrl: "https://github.com/openclaw/openclaw.git",
},
{ ...controlUiClient, context: { broadcast } },
);
expect(created.ok, JSON.stringify(created.error)).toBe(true);
expect(created.payload?.runStarted).toBe(true);
const { key, runId } = created.payload!;
await vi.waitFor(() => expect(projectCloneMocks.materialize).toHaveBeenCalledOnce());
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
materialization.resolve(project);
await vi.waitFor(() =>
expect(broadcast).toHaveBeenCalledWith(
"chat",
expect.objectContaining({
runId,
sessionKey: key,
state: "error",
errorMessage: expect.stringMatching(/outside the sandboxed agent workspace/u),
}),
expect.anything(),
),
);
expect(dispatchInboundMessageMock).not.toHaveBeenCalled();
expect(loadSessionEntry({ agentId: "main", sessionKey: key, storePath })?.projectId).toBe(
undefined,
);
await vi.waitFor(() =>
expect(loadSessionEntry({ agentId: "main", sessionKey: key, storePath })?.status).not.toBe(
"running",
),
);
} finally {
materialization.resolve(project);
}
});
test("sessions.create starts directly in a synthesized non-Git workspace project", async () => {
const root = tempDirs.make("openclaw-session-workspace-project-");
const workspace = path.join(root, "workspace");
+12
View File
@@ -298,6 +298,7 @@ export async function createGatewaySession(params: {
thinkingLevel?: string;
/** Registry identity recorded only when this request creates a logical session node. */
projectId?: string;
pendingProjectGitUrl?: string;
incognito?: boolean;
visibility?: SessionVisibility;
/** Trusted catalog-owned model/runtime pair, persisted and locked together. */
@@ -366,6 +367,7 @@ export async function createGatewaySession(params: {
const requestedKey = normalizeOptionalString(params.key);
const parentSessionKey = normalizeOptionalString(params.parentSessionKey);
const projectId = normalizeOptionalString(params.projectId);
const pendingProjectGitUrl = normalizeOptionalString(params.pendingProjectGitUrl);
const requestedToolOverrides = params.toolOverrides !== undefined;
const explicitAgentId = params.agentId;
const normalizedExplicitAgentId = normalizeOptionalString(explicitAgentId);
@@ -978,6 +980,15 @@ export async function createGatewaySession(params: {
),
};
}
if (pendingProjectGitUrl && existingEntry !== undefined) {
return {
ok: false,
error: errorShape(
ErrorCodes.INVALID_REQUEST,
"remote project preparation requires a new session",
),
};
}
if (spawnToolPolicy && existingEntry !== undefined) {
return {
ok: false,
@@ -1148,6 +1159,7 @@ export async function createGatewaySession(params: {
: {}),
...(params.visibility && createdNewEntry ? { visibility: params.visibility } : {}),
...(projectId && createdNewEntry ? { projectId } : {}),
...(pendingProjectGitUrl && createdNewEntry ? { pendingProjectGitUrl } : {}),
...(catalogResolvedModel && catalogAgentRuntime
? {
providerOverride: catalogResolvedModel.provider,
+2 -1
View File
@@ -7,6 +7,7 @@ import type { SubagentRunReadIndex } from "../agents/subagents/registry/subagent
import type { SubagentRunReadRecord } from "../agents/subagents/registry/subagent-registry.types.js";
import type { ThinkLevel, listThinkingLevelOptions } from "../auto-reply/thinking.js";
import type { SessionAcpMeta, SessionEntry } from "../config/sessions.js";
import type { InternalSessionEntry } from "../config/sessions/types.js";
import type { ModelCostConfig } from "../utils/usage-format.js";
export type GatewayModelThinkingProfile = {
@@ -41,7 +42,7 @@ export type GatewaySessionStoreTarget = {
export type GatewaySessionStoreTargetWithStore = GatewaySessionStoreTarget & {
canonicalValidationError?: Error;
store: Record<string, SessionEntry>;
store: Record<string, InternalSessionEntry>;
};
export function createSessionRowModelCacheKey(
+4 -4
View File
@@ -185,11 +185,11 @@ export function loadGatewaySessionEntryReadOnly(
}
/** Returns the one canonical entry and the exact persisted key that owns it. */
export function resolveCanonicalSessionStoreMatchFromStoreKeys(
store: Record<string, SessionEntry>,
export function resolveCanonicalSessionStoreMatchFromStoreKeys<TEntry extends SessionEntry>(
store: Record<string, TEntry>,
storeKeys: string[],
): { key: string; entry: SessionEntry } | undefined {
let selected: { key: string; entry: SessionEntry } | undefined;
): { key: string; entry: TEntry } | undefined {
let selected: { key: string; entry: TEntry } | undefined;
for (const key of storeKeys) {
const entry = store[key];
if (!entry) {
@@ -76,6 +76,9 @@ export function generationValidPrivateFieldsForSameSession(
...(existingEntry.lifecycleRunId !== undefined
? { lifecycleRunId: existingEntry.lifecycleRunId }
: {}),
...(existingEntry.pendingProjectGitUrl !== undefined
? { pendingProjectGitUrl: existingEntry.pendingProjectGitUrl }
: {}),
...(existingEntry.sessionDiffBaselineCapture
? { sessionDiffBaselineCapture: existingEntry.sessionDiffBaselineCapture }
: {}),
@@ -1,14 +1,29 @@
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { loadSessionEntry as loadInternalSessionEntry } from "../config/sessions/session-accessor.js";
import {
loadSessionEntry as loadInternalSessionEntry,
replaceSessionEntry as replaceInternalSessionEntry,
} from "../config/sessions/session-accessor.js";
import type { SessionEntry as ConfigSessionEntry } from "../config/sessions/types.js";
import {
getSessionEntry,
listSessionEntries,
patchSessionEntry,
updateSessionStoreEntry,
upsertSessionEntry,
type SessionEntry,
} from "./session-store-runtime.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const publicPendingProjectIsPrivate: "pendingProjectGitUrl" extends keyof SessionEntry
? false
: true = true;
const configPendingProjectIsPrivate: "pendingProjectGitUrl" extends keyof ConfigSessionEntry
? false
: true = true;
void publicPendingProjectIsPrivate;
void configPendingProjectIsPrivate;
describe("session-store-runtime recovery boundary", () => {
let tempDir: string;
@@ -47,6 +62,57 @@ describe("session-store-runtime recovery boundary", () => {
});
});
it("keeps pending remote-project recovery private across public session mutations", async () => {
const sessionKey = "agent:main:pending-project";
const pendingProjectGitUrl = "https://github.com/openclaw/openclaw.git";
await replaceInternalSessionEntry(
{ sessionKey, storePath },
{ pendingProjectGitUrl, sessionId: "project-session", updatedAt: 10 },
);
expect(getSessionEntry({ sessionKey, storePath })).not.toHaveProperty("pendingProjectGitUrl");
expect(listSessionEntries({ storePath })[0]?.entry).not.toHaveProperty("pendingProjectGitUrl");
await patchSessionEntry({
sessionKey,
storePath,
update: () => ({ model: "gpt-5.5" }),
});
expect(loadInternalSessionEntry({ sessionKey, storePath })).toMatchObject({
model: "gpt-5.5",
pendingProjectGitUrl,
});
await updateSessionStoreEntry({
sessionKey,
storePath,
update: () => ({ model: "gpt-5.6" }),
});
expect(loadInternalSessionEntry({ sessionKey, storePath })).toMatchObject({
model: "gpt-5.6",
pendingProjectGitUrl,
});
await upsertSessionEntry({
entry: { sessionId: "project-session", updatedAt: 20 },
sessionKey,
storePath,
});
expect(loadInternalSessionEntry({ sessionKey, storePath })?.pendingProjectGitUrl).toBe(
pendingProjectGitUrl,
);
await patchSessionEntry({
replaceEntry: true,
sessionKey,
storePath,
update: () => ({ sessionId: "replacement-session", updatedAt: 30 }),
});
expect(loadInternalSessionEntry({ sessionKey, storePath })).not.toHaveProperty(
"pendingProjectGitUrl",
);
});
it("rejects core recovery state from runtime-escaped creation inputs", async () => {
const mainRestartRecovery = {
chargedAttempts: 1,
@@ -57,6 +123,7 @@ describe("session-store-runtime recovery boundary", () => {
await patchSessionEntry({
fallbackEntry: {
mainRestartRecovery,
pendingProjectGitUrl: "https://github.com/openclaw/injected.git",
sessionId: "patch-created",
updatedAt: 10,
} as unknown as SessionEntry,
@@ -67,11 +134,15 @@ describe("session-store-runtime recovery boundary", () => {
expect(loadInternalSessionEntry({ sessionKey: patchSessionKey, storePath })).not.toHaveProperty(
"mainRestartRecovery",
);
expect(loadInternalSessionEntry({ sessionKey: patchSessionKey, storePath })).not.toHaveProperty(
"pendingProjectGitUrl",
);
const upsertSessionKey = "agent:main:upsert-created";
await upsertSessionEntry({
entry: {
mainRestartRecovery,
pendingProjectGitUrl: "https://github.com/openclaw/injected.git",
sessionId: "upsert-created",
updatedAt: 10,
} as unknown as SessionEntry,
@@ -81,5 +152,8 @@ describe("session-store-runtime recovery boundary", () => {
expect(
loadInternalSessionEntry({ sessionKey: upsertSessionKey, storePath }),
).not.toHaveProperty("mainRestartRecovery");
expect(
loadInternalSessionEntry({ sessionKey: upsertSessionKey, storePath }),
).not.toHaveProperty("pendingProjectGitUrl");
});
});
+1
View File
@@ -37,6 +37,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
"sessionDiffBaselineCapture",
"worktree",
"projectId",
"pendingProjectGitUrl",
"parentSessionKey",
"parentSessionId",
"createdVia",
@@ -3,17 +3,25 @@ import {
WORKSPACE,
captureProjectUiProof,
captureUiProofEnabled,
controlUiSessionPath,
createNewSessionPageE2eSuite,
installMockGateway,
pollLocatorText,
prepareProjectUiProof,
projectProofArtifactDir,
waitForCommittedChatRoute,
} from "./new-session-page.test-support.ts";
const suite = createNewSessionPageE2eSuite();
suite.define(() => {
it("keeps GitHub selection inert and clones only when the session starts", async () => {
it.each([
{ name: "shows workspace preparation in the admitted session", failure: null },
{
name: "keeps a project preparation failure actionable in the admitted session",
failure: "Repository clone failed; verify repository access and try again.",
},
])("keeps GitHub selection inert and $name", async ({ failure }) => {
await prepareProjectUiProof();
const context = await suite.browser.newContext({
locale: "en-US",
@@ -29,19 +37,58 @@ suite.define(() => {
: {}),
});
const page = await context.newPage();
const clonedProject = {
id: "openclaw",
displayName: "OpenClaw",
repoRoot: "/state/projects/fingerprint/openclaw",
originUrl: "https://github.com/openclaw/openclaw.git",
source: "cloned",
};
const sessionKey = "agent:main:cloned-project-e2e";
const runId = "run-cloned-project-e2e";
const message = "inspect the cloned project";
let releaseChatModule!: () => void;
let chatModuleRequested = false;
const chatModuleBlocked = new Promise<void>((resolve) => {
releaseChatModule = resolve;
});
await page.route("**/assets/chat-page-*.js*", async (route) => {
chatModuleRequested = true;
await chatModuleBlocked;
await route.continue();
});
const gateway = await installMockGateway(page, {
workspace: WORKSPACE,
workspaceGit: true,
deferredMethods: ["projects.add"],
historyMessages: [
{
role: "user",
content: [{ type: "text", text: message }],
timestamp: Date.now(),
__openclaw: {
id: "persisted-remote-project-prompt",
idempotencyKey: `${runId}:user`,
seq: 1,
},
},
],
inFlightRun: {
runId,
startedAt: Date.now(),
events: [
{
runId,
sessionKey,
seq: 1,
stream: "run_status",
ts: Date.now(),
data: { phase: "preparing_workspace" },
},
],
},
sessionInfo: {
hasActiveRun: true,
activeRunIds: [runId],
key: sessionKey,
status: "running",
},
featureMethods: [
"chat.abort",
"chat.metadata",
"chat.send",
"chat.startup",
"projects.add",
"projects.list",
@@ -69,7 +116,7 @@ suite.define(() => {
defaultBranch: "main",
repositoryStatus: "git",
},
"sessions.create": { key: "agent:main:cloned-project-e2e" },
"sessions.create": { key: sessionKey, runStarted: true, runId, messageSeq: 1 },
},
});
@@ -102,24 +149,74 @@ suite.define(() => {
const permission = page.locator('[data-chat-permission-select="true"]');
await permission.click();
await page.locator('[data-chat-permission-option="read-only"]').click();
await page.locator(".new-session-page__message").fill("inspect the cloned project");
await page.locator(".new-session-page__message").fill(message);
await page.getByRole("button", { name: "Start session" }).click();
const addRequest = await gateway.waitForRequest("projects.add");
expect(addRequest.params).toEqual({ gitUrl: "https://github.com/openclaw/openclaw.git" });
await captureProjectUiProof(page, "project-cloning.png");
expect(await gateway.getRequests("sessions.create")).toHaveLength(0);
expect(await permission.isDisabled()).toBe(true);
await gateway.resolveDeferred("projects.add", clonedProject);
const create = await gateway.waitForRequest("sessions.create");
expect(create.params).toMatchObject({
agentId: "main",
message: "inspect the cloned project",
message,
permissionMode: "read-only",
projectId: "openclaw",
projectGitUrl: "https://github.com/openclaw/openclaw.git",
});
expect(create.params).not.toHaveProperty("cwd");
expect(create.params).not.toHaveProperty("projectId");
expect(await gateway.getRequests("projects.add")).toHaveLength(0);
await expect.poll(() => chatModuleRequested).toBe(true);
expect(new URL(page.url()).pathname).toBe(controlUiSessionPath(sessionKey));
expect(await gateway.getRequests("chat.startup")).toHaveLength(0);
await gateway.emitGatewayEvent("chat", {
runId,
sessionKey,
seq: 1,
state: "status",
phase: "preparing_workspace",
});
releaseChatModule();
await waitForCommittedChatRoute(page);
expect(new URL(page.url()).pathname).toBe(controlUiSessionPath(sessionKey));
await gateway.waitForRequest("chat.startup");
const working = page.locator('.chat-working-indicator[role="status"]');
await pollLocatorText(working).toContain("Preparing workspace…");
await expect.poll(() => page.locator(".chat-group.user").count()).toBe(1);
expect(await working.locator(".chat-reading-indicator").count()).toBe(1);
expect(await gateway.getRequests("chat.send")).toHaveLength(0);
await captureProjectUiProof(page, "project-cloning.png");
if (!failure) {
await gateway.emitChatFinal({ runId, sessionKey, text: "Project workspace is ready." });
await page
.getByRole("paragraph")
.filter({ hasText: "Project workspace is ready." })
.waitFor();
await expect.poll(() => working.count()).toBe(0);
expect(await gateway.getRequests("sessions.create")).toHaveLength(1);
return;
}
await gateway.emitGatewayEvent("chat", {
runId,
sessionKey,
seq: 2,
state: "error",
errorMessage: failure,
});
const alert = page.locator('.chat-error[role="alert"]');
await pollLocatorText(alert).toContain(failure);
await expect.poll(() => working.count()).toBe(0);
const composer = page.locator(".agent-chat__composer-combobox textarea");
await expect.poll(() => composer.isEnabled()).toBe(true);
await captureProjectUiProof(page, "project-cloning-failed.png");
await composer.fill(message);
await page.getByRole("button", { name: "Send message" }).click();
const retry = await gateway.waitForRequest("chat.send");
expect(retry.params).toMatchObject({ sessionKey, message });
expect(await gateway.getRequests("sessions.create")).toHaveLength(1);
expect(await gateway.getRequests("projects.add")).toHaveLength(0);
} finally {
releaseChatModule();
await context.close();
}
});
@@ -77,6 +77,37 @@ function activeHistory(runId: string): ChatHistoryResult {
}
describe("chat history in-flight assistant recovery", () => {
it.each([
{
name: "restores workspace preparation before visible activity",
text: "",
startup: { state: "status", runId: "run-live", phase: "preparing_workspace" },
},
{
name: "keeps actual assistant activity ahead of an older startup status",
text: "The assistant already started responding.",
startup: { state: "activity", runId: "run-live" },
},
])("$name", async ({ text, startup }) => {
const history = activeHistory("run-live");
history.inFlightRun!.text = text;
history.inFlightRun!.events = [
{
runId: "run-live",
seq: 1,
stream: "run_status",
ts: 900,
sessionKey: "main",
data: { phase: "preparing_workspace" },
},
];
const state = createState(history);
await loadChatHistory(state);
expect(state.chatRunStartup).toEqual(startup);
});
it("restores active tool state and authoritative preamble time from the in-flight run snapshot", async () => {
const history = activeHistory("run-live");
(history.inFlightRun as { events?: unknown[] }).events = [
+10 -1
View File
@@ -538,7 +538,16 @@ function applyInFlightRunSnapshot(params: {
timestamp: state.chatStreamStartedAt,
});
}
state.chatRunStartup = { state: "activity", runId: inFlightRunId };
const startupPhase = run.events?.findLast((event) => event.stream === "run_status")?.data.phase;
const hasStartupStatus =
startupPhase === "preparing_workspace" ||
startupPhase === "provisioning_environment" ||
startupPhase === "preparing_context" ||
startupPhase === "starting_model";
state.chatRunStartup =
hasStartupStatus && !tail && !(sameRunContinued && state.chatRunStartup?.state === "activity")
? { state: "status", runId: inFlightRunId, phase: startupPhase }
: { state: "activity", runId: inFlightRunId };
// Disconnect cleanup intentionally removes transient activity rows while
// retaining the owned run. Replay fills that gap; per-identity sequence
// fences keep a delayed snapshot from replacing newer live progress.
+7 -1
View File
@@ -37,6 +37,7 @@ export function buildDraftSessionCreateParams(draft: {
visibility?: NewSessionVisibility;
attachments?: SessionCreateParams["attachments"];
projectId?: string;
projectGitUrl?: string;
worktree: boolean;
baseRef?: string;
worktreeName?: string;
@@ -53,7 +54,11 @@ export function buildDraftSessionCreateParams(draft: {
const contextWindow = normalizeOptionalString(draft.contextWindow);
const thinkingLevel = normalizeOptionalString(draft.thinkingLevel);
const projectId = normalizeOptionalString(draft.projectId);
const customFolder = !projectId && cwd && cwd !== workspace ? cwd : undefined;
const projectGitUrl =
!projectId && (draft.message.trim() || draft.attachments?.length)
? normalizeOptionalString(draft.projectGitUrl)
: undefined;
const customFolder = !projectId && !projectGitUrl && cwd && cwd !== workspace ? cwd : undefined;
return {
...(normalizeOptionalString(draft.key) ? { key: normalizeOptionalString(draft.key) } : {}),
agentId: normalizeAgentId(draft.agentId),
@@ -69,6 +74,7 @@ export function buildDraftSessionCreateParams(draft: {
...(draft.toolOverrides ? { toolOverrides: draft.toolOverrides } : {}),
...(draft.permissionMode ? { permissionMode: draft.permissionMode } : {}),
...(projectId ? { projectId } : {}),
...(projectGitUrl ? { projectGitUrl } : {}),
...(customFolder ? { cwd: customFolder } : {}),
...(draft.worktree
? {
@@ -675,125 +675,131 @@ describe("DraftSubmissionFlow", () => {
flow.attachmentDraft.reset({ release: true });
});
it("deduplicates remote materialization and preserves the draft when cloning fails", async () => {
let rejectClone!: (error: Error) => void;
const cloneResult = new Promise<never>((_resolve, reject) => {
rejectClone = reject;
});
const request = vi.fn((method: string) => {
if (method === "projects.add") {
return cloneResult;
}
return Promise.resolve({});
});
const client = { recoveryScope: "principal-a", recoveryScopeReady: true, request };
const context = {
gateway: {
connection: { gatewayUrl: "ws://gateway.example" },
snapshot: {
phase: "connected",
client,
hello: {
auth: { role: "operator", scopes: ["operator.read", "operator.write"] },
features: { methods: ["projects.add", "sessions.create"] },
},
},
},
agents: {
state: {
agentsList: {
defaultId: "main",
agents: [
{
id: "main",
workspace: "/workspace",
workspaceGit: false,
model: { primary: "openai/gpt-5.6-luna" },
},
],
},
},
},
sessions: { state: { result: null }, createResult: vi.fn() },
config: { current: {} },
} as unknown as ApplicationContext;
const host = new TestReactiveControllerHost();
const gateway = new DraftGatewayState(
host,
() => ({
context,
data: undefined,
isConnected: true,
isAdmin: place?.isAdmin() ?? false,
canStartAsDraft: flow?.capabilities.canStartAsDraft(context) ?? false,
visibility: flow?.visibility ?? "normal",
cloudProfileId: place?.cloudProfileId ?? "",
pendingPlacement: flow?.pendingPlacement ?? {
sessionKey: "",
gatewayUrl: "",
recoveryScope: "",
},
agentsHydrated: place?.agentsHydrated ?? false,
}),
{
requestUpdate: vi.fn(),
updateComplete: () => Promise.resolve(),
onInvalidate: vi.fn(),
onVisibilityRetired: () => flow?.setVisibility("normal"),
onCloudProfileCleared: () => place?.clearCloudProfile(),
onCloudState: (error) => flow?.setError(error),
onPendingPlacementReset: () => flow?.releasePendingPlacementOwner(),
onRecoveryReady: (gatewayUrl, recoveryScope) =>
flow?.restorePendingPlacementRecovery(gatewayUrl, recoveryScope),
onAdoptAgentDefaults: () => place?.adoptAgentDefaults(),
},
);
const browser = new DraftPlaceBrowser(
host,
gateway,
() => ({
context,
isAdmin: place?.isAdmin() ?? false,
}),
{
requestUpdate: vi.fn(),
onProjectMissing: () => place?.clearProjectSelection(),
onSelectProject: (projectId) => place?.selectProjectId(projectId),
onApprovedListing: (listing) => place?.recordGatewayApprovedListing(listing),
querySelector: () => null,
activeElement: () => null,
body: () => null,
},
);
const place = new DraftPlaceState(
gateway,
browser,
() => ({
context,
data: undefined,
submitting: flow?.submitting ?? false,
pendingPlacementSessionKey: flow?.pendingPlacement.sessionKey ?? "",
}),
{
requestUpdate: vi.fn(),
onError: (error) => flow?.setError(error),
onClearError: (error) => flow?.clearErrorIf(error),
},
);
const flow = new DraftSubmissionFlow(
gateway,
place,
() => ({ context, data: undefined, isConnected: true }),
{ requestUpdate: vi.fn(), closeTransientUi: vi.fn() },
);
gateway.synchronize(context.gateway);
place.setAgentsHydrated(true);
place.adoptAgentDefaults();
it.each([
{ methods: ["sessions.create"], allowed: false, worktree: false },
{ methods: ["projects.add"], allowed: false, worktree: false },
{ methods: ["projects.add", "sessions.create"], allowed: true, worktree: false },
{ methods: ["sessions.create"], allowed: false, worktree: true },
])("checks remote-project access with worktree=$worktree", ({ methods, allowed, worktree }) => {
const { flow, place } = createDraftFixture({ methods });
place.selectRemoteProject({
identity: "openclaw/openclaw",
cloneUrl: "https://github.com/openclaw/openclaw.git",
});
flow.setMessage("keep this prompt");
if (worktree) {
place.toggleWorktree();
flow.setMessage("start in a worktree");
}
expect(flow.submissionAccess().allowed).toBe(allowed);
});
it.each([
{ scenario: "an empty session", message: "", worktree: false },
{ scenario: "a prompted worktree", message: "inspect the project", worktree: true },
{ scenario: "an attachment-only worktree", message: "", worktree: true },
])("materializes a remote project before $scenario", async ({ message, worktree }) => {
let materializeProject!: (project: { id: string }) => void;
const materializedProject = new Promise<{ id: string }>((resolve) => {
materializeProject = resolve;
});
const { context, flow, place, request } = createDraftFixture({
methods: ["projects.add", "sessions.create"],
request: async (method) => (method === "projects.add" ? materializedProject : {}),
});
vi.mocked(context.sessions.createResult).mockResolvedValue({
key: "agent:main:empty-remote-project",
initialRun: { status: "idle" },
});
vi.mocked(context.navigateAndWait).mockImplementation(async () => {
queueMicrotask(() => document.dispatchEvent(new Event(CHAT_ROUTE_READY_EVENT)));
});
place.selectRemoteProject({
identity: "openclaw/openclaw",
cloneUrl: "https://github.com/openclaw/openclaw.git",
});
if (worktree) {
place.toggleWorktree();
vi.spyOn(place, "worktreeAvailable").mockReturnValue(true);
}
flow.setMessage(message);
if (worktree && !message) {
flow.attachmentDraft.replace([
{
id: "attachment-1",
dataUrl: "data:text/plain;base64,SGk=",
mimeType: "text/plain",
fileName: "note.txt",
},
]);
} else if (!message) {
// Empty-draft button gating is independent from the remote-project submission contract.
vi.spyOn(flow, "canSubmit").mockReturnValue(true);
}
const submitted = flow.submit();
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith(
"projects.add",
{ gitUrl: "https://github.com/openclaw/openclaw.git" },
{ timeoutMs: null },
),
);
expect(context.sessions.createResult).not.toHaveBeenCalled();
materializeProject({ id: "openclaw" });
await submitted;
const createParams = vi.mocked(context.sessions.createResult).mock.calls[0]?.[0];
expect(createParams).toMatchObject({ agentId: "main", message, projectId: "openclaw" });
expect(createParams?.worktree).toBe(worktree || undefined);
expect(createParams).not.toHaveProperty("projectGitUrl");
expect(createParams).not.toHaveProperty("cwd");
expect(request.mock.invocationCallOrder[0]).toBeLessThan(
vi.mocked(context.sessions.createResult).mock.invocationCallOrder[0] ??
Number.POSITIVE_INFINITY,
);
});
it("retains an empty remote-project selection when pre-session materialization fails", async () => {
const { context, flow, place } = createDraftFixture({
methods: ["projects.add", "sessions.create"],
request: async () => {
throw new Error("clone failed");
},
});
place.selectRemoteProject({
identity: "openclaw/openclaw",
cloneUrl: "https://github.com/openclaw/openclaw.git",
});
vi.spyOn(flow, "canSubmit").mockReturnValue(true);
await flow.submit();
expect(flow.error).toBe("clone failed");
expect(place.browser.remoteProject?.identity).toBe("openclaw/openclaw");
expect(context.sessions.createResult).not.toHaveBeenCalled();
});
it.each([
{ scenario: "an initial prompt and attachments", message: "keep this prompt" },
{ scenario: "attachments without an initial prompt", message: "" },
])("admits a remote project once with $scenario", async ({ message }) => {
const { context, flow, place, request } = createDraftFixture();
let admitSession!: (value: { key: string; initialRun: { status: "idle" } }) => void;
vi.mocked(context.sessions.createResult).mockImplementationOnce(
() =>
new Promise((resolve) => {
admitSession = resolve;
}),
);
vi.mocked(context.navigateAndWait).mockImplementation(async () => {
queueMicrotask(() => document.dispatchEvent(new Event(CHAT_ROUTE_READY_EVENT)));
});
place.selectRemoteProject({
identity: "openclaw/openclaw",
cloneUrl: "https://github.com/openclaw/openclaw.git",
});
flow.setMessage(message);
flow.attachmentDraft.replace([
{
id: "attachment-1",
@@ -803,22 +809,25 @@ describe("DraftSubmissionFlow", () => {
},
]);
const first = flow.submit();
const submitted = flow.submit();
const duplicate = flow.submit();
await vi.waitFor(() =>
expect(request.mock.calls.filter(([method]) => method === "projects.add")).toHaveLength(1),
await vi.waitFor(() => expect(context.sessions.createResult).toHaveBeenCalledOnce());
expect(context.sessions.createResult).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "main",
message,
projectGitUrl: "https://github.com/openclaw/openclaw.git",
attachments: [expect.objectContaining({ fileName: "note.txt", mimeType: "text/plain" })],
}),
{ reconciliation: "background" },
);
rejectClone(new Error("clone failed"));
await Promise.all([first, duplicate]);
expect(request).not.toHaveBeenCalledWith("projects.add", expect.anything(), expect.anything());
expect(flow.error).toBe("clone failed");
expect(flow.message).toBe("keep this prompt");
expect(flow.attachmentDraft.attachments).toHaveLength(1);
expect(place.browser.remoteProject).toMatchObject({
identity: "openclaw/openclaw",
cloneUrl: "https://github.com/openclaw/openclaw.git",
});
expect(context.sessions.createResult).not.toHaveBeenCalled();
admitSession({ key: "agent:main:remote-project", initialRun: { status: "idle" } });
await Promise.all([submitted, duplicate]);
expect(context.sessions.createResult).toHaveBeenCalledOnce();
expect(context.navigateAndWait).toHaveBeenCalledOnce();
});
it.each([
@@ -166,9 +166,7 @@ export class DraftSubmissionFlow {
}
setError(error: string | null) {
if (error === null && this.error === t("newSession.cloudRecoveryUnavailable")) {
this.error = null;
} else if (error !== null) {
if (error !== null || this.error === t("newSession.cloudRecoveryUnavailable")) {
this.error = error;
}
this.callbacks.requestUpdate();
@@ -200,10 +198,9 @@ export class DraftSubmissionFlow {
/** Attempt-bound reason that retires when its transient gate lifts. */
blockedSubmitNotice(): string | undefined {
const block = this.blockedSubmitGate ? this.submitBlock() : undefined;
if (!block?.reason || block.gate !== this.blockedSubmitGate) {
return undefined;
}
return PAGE_RENDERED_GATES.has(block.gate) ? undefined : block.reason;
return block?.gate === this.blockedSubmitGate && !PAGE_RENDERED_GATES.has(block.gate)
? block.reason
: undefined;
}
showStartInTerminal(): boolean {
@@ -222,13 +219,10 @@ export class DraftSubmissionFlow {
}
private buildDraftSessionCreateParams(
options: {
message?: string;
attachments?: SessionCreateParams["attachments"];
options: Partial<Pick<SessionCreateParams, "message" | "attachments">> & {
visibility?: NewSessionVisibility;
} = {},
): SessionCreateParams {
const snapshot = this.read();
return assembleDraftSessionCreateParams({
agentId: this.place.agentId,
message: options.message ?? "",
@@ -240,12 +234,13 @@ export class DraftSubmissionFlow {
visibility: options.visibility ?? this.visibilityValue,
attachments: options.attachments,
projectId: this.place.browser.remoteProject?.projectId ?? this.place.browser.projectId,
projectGitUrl: this.place.browser.remoteProject?.cloneUrl,
worktree: this.place.worktree,
baseRef: this.place.baseRef,
worktreeName: this.place.worktreeName,
cwd: this.place.folder,
workspace: this.place.workspacePath(),
catalogId: snapshot.data?.catalogId,
catalogId: this.read().data?.catalogId,
category: this.gateway.resolvedGroupCategory(),
});
}
@@ -256,14 +251,19 @@ export class DraftSubmissionFlow {
): SessionMethodAccess {
const gateway = this.read().context?.gateway.snapshot;
const pendingPlacement = Boolean(this.pendingPlacement.sessionKey);
const remoteProject = this.place.browser.remoteProject;
const target = this.placement().target;
const hasInitialTurn = this.messageValue.trim() || this.attachmentDraft.attachments.length;
const remoteProject =
target || this.place.worktree || !hasInitialTurn ? this.place.browser.remoteProject : null;
if (!pendingPlacement && remoteProject && !remoteProject.projectId) {
return readSessionMethodAccess(gateway, {
const projectAccess = readSessionMethodAccess(gateway, {
method: "projects.add",
requiredScope: "operator.write",
});
if (!projectAccess.allowed) {
return projectAccess;
}
}
const target = this.placement().target;
if (!target || !pendingPlacement || this.pendingPlacement.phase === "creating") {
const createAccess = readSessionMethodAccess(gateway, {
method: "sessions.create",
@@ -462,7 +462,12 @@ export class DraftSubmissionFlow {
return;
}
this.startedSession.current = null;
const remoteProject = pendingPlacement || startup ? null : this.place.browser.remoteProject;
const placementTarget = startup ? null : this.placement().target;
const hasInitialTurn = message || apiAttachments?.length;
const remoteProject =
!startup && !pendingPlacement && (placementTarget || this.place.worktree || !hasInitialTurn)
? this.place.browser.remoteProject
: null;
if (remoteProject && !remoteProject.projectId && !this.place.browser.projectId) {
const project = await submissionClient.request<ProjectsAddResult>(
"projects.add",
@@ -474,7 +479,6 @@ export class DraftSubmissionFlow {
}
this.place.browser.recordRemoteProjectId(remoteProject.cloneUrl, project.id);
}
const placementTarget = startup ? null : this.placement().target;
const createParams =
startup?.params ??
this.buildDraftSessionCreateParams({