mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
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:
committed by
GitHub
parent
655858c142
commit
b6662bf157
@@ -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)[];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>) =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user