diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 60478af968c0..2bc3f898eadc 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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" diff --git a/packages/gateway-protocol/src/schema/projects.test.ts b/packages/gateway-protocol/src/schema/projects.test.ts index 26024f679724..8ac141967a6e 100644 --- a/packages/gateway-protocol/src/schema/projects.test.ts +++ b/packages/gateway-protocol/src/schema/projects.test.ts @@ -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); }); }); diff --git a/packages/gateway-protocol/src/schema/sessions-create.ts b/packages/gateway-protocol/src/schema/sessions-create.ts index 8b04cd697e63..198bf2e80835 100644 --- a/packages/gateway-protocol/src/schema/sessions-create.ts +++ b/packages/gateway-protocol/src/schema/sessions-create.ts @@ -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({ diff --git a/src/config/sessions/session-accessor.sqlite-session-row.test.ts b/src/config/sessions/session-accessor.sqlite-session-row.test.ts index 4e9d0c04fd1d..08277fc0cbd0 100644 --- a/src/config/sessions/session-accessor.sqlite-session-row.test.ts +++ b/src/config/sessions/session-accessor.sqlite-session-row.test.ts @@ -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" }], diff --git a/src/config/sessions/session-entry-projection.ts b/src/config/sessions/session-entry-projection.ts index ca84dcada6f3..0509c0ed314c 100644 --- a/src/config/sessions/session-entry-projection.ts +++ b/src/config/sessions/session-entry-projection.ts @@ -11,6 +11,7 @@ export const SESSION_ENTRY_PRIVATE_CLEAR_PATCH = { lastRunId: undefined, lifecycleRunId: undefined, mainRestartRecovery: undefined, + pendingProjectGitUrl: undefined, sessionDiffBaselineCapture: undefined, } satisfies Partial; @@ -19,6 +20,7 @@ const PRIVATE_SESSION_ENTRY_KEYS = [ "lastRunId", "lifecycleRunId", "mainRestartRecovery", + "pendingProjectGitUrl", "sessionDiffBaselineCapture", ] as const satisfies readonly (keyof InternalSessionEntry)[]; diff --git a/src/config/sessions/types.ts b/src/config/sessions/types.ts index c373f6a8ce42..646d1582604d 100644 --- a/src/config/sessions/types.ts +++ b/src/config/sessions/types.ts @@ -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; diff --git a/src/gateway/server-chat-progress-snapshot.ts b/src/gateway/server-chat-progress-snapshot.ts index 19e3361b9c88..2f3762e0162e 100644 --- a/src/gateway/server-chat-progress-snapshot.ts +++ b/src/gateway/server-chat-progress-snapshot.ts @@ -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) { diff --git a/src/gateway/server-chat-state.test.ts b/src/gateway/server-chat-state.test.ts index bc2c8a5bcbeb..8f7929d56293 100644 --- a/src/gateway/server-chat-state.test.ts +++ b/src/gateway/server-chat-state.test.ts @@ -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) => + 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) => diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 2c2f3aee58f0..2100153fcf69 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -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" || diff --git a/src/gateway/server-methods/chat-send-agent-dispatch.ts b/src/gateway/server-methods/chat-send-agent-dispatch.ts index 6bab5248bbec..8ba51afb5d67 100644 --- a/src/gateway/server-methods/chat-send-agent-dispatch.ts +++ b/src/gateway/server-methods/chat-send-agent-dispatch.ts @@ -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, diff --git a/src/gateway/server-methods/session-create-project.ts b/src/gateway/server-methods/session-create-project.ts new file mode 100644 index 000000000000..ffa52344326a --- /dev/null +++ b/src/gateway/server-methods/session-create-project.ts @@ -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; +} diff --git a/src/gateway/server-methods/sessions-create.ts b/src/gateway/server-methods/sessions-create.ts index 6619244a6019..c33134dfe240 100644 --- a/src/gateway/server-methods/sessions-create.ts +++ b/src/gateway/server-methods/sessions-create.ts @@ -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 diff --git a/src/gateway/server.sessions.create.projects.test.ts b/src/gateway/server.sessions.create.projects.test.ts index 650a53dee018..72910243f539 100644 --- a/src/gateway/server.sessions.create.projects.test.ts +++ b/src/gateway/server.sessions.create.projects.test.ts @@ -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(); + 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 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(); + 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(); + projectCloneMocks.materialize.mockReturnValueOnce(materialization.promise); + dispatchInboundMessageMock.mockResolvedValue({ + queuedFinal: false, + counts: { block: 0, final: 0, tool: 0 }, + }); + const broadcast = vi.fn(); + const chatAbortControllers = new Map(); + 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(); + 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(); + 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(); + 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"); diff --git a/src/gateway/session-create-service.ts b/src/gateway/session-create-service.ts index c3aa5f83ba00..56cc8eaa9984 100644 --- a/src/gateway/session-create-service.ts +++ b/src/gateway/session-create-service.ts @@ -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, diff --git a/src/gateway/session-utils-contracts.ts b/src/gateway/session-utils-contracts.ts index 56854304547e..4cd951ae7f51 100644 --- a/src/gateway/session-utils-contracts.ts +++ b/src/gateway/session-utils-contracts.ts @@ -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; + store: Record; }; export function createSessionRowModelCacheKey( diff --git a/src/gateway/session-utils-store.ts b/src/gateway/session-utils-store.ts index 4d574361c5a3..b9b41321ac0d 100644 --- a/src/gateway/session-utils-store.ts +++ b/src/gateway/session-utils-store.ts @@ -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, +export function resolveCanonicalSessionStoreMatchFromStoreKeys( + store: Record, 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) { diff --git a/src/plugin-sdk/session-store-runtime-internal.ts b/src/plugin-sdk/session-store-runtime-internal.ts index 1e1fbd87771a..0b6d3788f681 100644 --- a/src/plugin-sdk/session-store-runtime-internal.ts +++ b/src/plugin-sdk/session-store-runtime-internal.ts @@ -76,6 +76,9 @@ export function generationValidPrivateFieldsForSameSession( ...(existingEntry.lifecycleRunId !== undefined ? { lifecycleRunId: existingEntry.lifecycleRunId } : {}), + ...(existingEntry.pendingProjectGitUrl !== undefined + ? { pendingProjectGitUrl: existingEntry.pendingProjectGitUrl } + : {}), ...(existingEntry.sessionDiffBaselineCapture ? { sessionDiffBaselineCapture: existingEntry.sessionDiffBaselineCapture } : {}), diff --git a/src/plugin-sdk/session-store-runtime.recovery.test.ts b/src/plugin-sdk/session-store-runtime.recovery.test.ts index d68081680ffe..bfaba36c8255 100644 --- a/src/plugin-sdk/session-store-runtime.recovery.test.ts +++ b/src/plugin-sdk/session-store-runtime.recovery.test.ts @@ -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"); }); }); diff --git a/src/plugins/session-entry-slot-keys.ts b/src/plugins/session-entry-slot-keys.ts index 53b969df7d2b..6059e7dd3191 100644 --- a/src/plugins/session-entry-slot-keys.ts +++ b/src/plugins/session-entry-slot-keys.ts @@ -37,6 +37,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [ "sessionDiffBaselineCapture", "worktree", "projectId", + "pendingProjectGitUrl", "parentSessionKey", "parentSessionId", "createdVia", diff --git a/ui/src/e2e/new-session-page.github-projects.e2e.test.ts b/ui/src/e2e/new-session-page.github-projects.e2e.test.ts index 7bb797cbe8aa..dc78ae8e0332 100644 --- a/ui/src/e2e/new-session-page.github-projects.e2e.test.ts +++ b/ui/src/e2e/new-session-page.github-projects.e2e.test.ts @@ -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((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(); } }); diff --git a/ui/src/pages/chat/chat-history.inflight.test.ts b/ui/src/pages/chat/chat-history.inflight.test.ts index 6464316817e5..75d7d5a1d6c7 100644 --- a/ui/src/pages/chat/chat-history.inflight.test.ts +++ b/ui/src/pages/chat/chat-history.inflight.test.ts @@ -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 = [ diff --git a/ui/src/pages/chat/chat-history.ts b/ui/src/pages/chat/chat-history.ts index 1eb2c2941a25..6476738b9b50 100644 --- a/ui/src/pages/chat/chat-history.ts +++ b/ui/src/pages/chat/chat-history.ts @@ -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. diff --git a/ui/src/pages/new-session/create-params.ts b/ui/src/pages/new-session/create-params.ts index 647d2a4b3a58..b8f7ea3767c2 100644 --- a/ui/src/pages/new-session/create-params.ts +++ b/ui/src/pages/new-session/create-params.ts @@ -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 ? { diff --git a/ui/src/pages/new-session/draft-submission-flow.test.ts b/ui/src/pages/new-session/draft-submission-flow.test.ts index 1a4adbdec717..4cde0b2d1ba2 100644 --- a/ui/src/pages/new-session/draft-submission-flow.test.ts +++ b/ui/src/pages/new-session/draft-submission-flow.test.ts @@ -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((_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([ diff --git a/ui/src/pages/new-session/draft-submission-flow.ts b/ui/src/pages/new-session/draft-submission-flow.ts index 315ca0a7cc97..9fbaeef67b1f 100644 --- a/ui/src/pages/new-session/draft-submission-flow.ts +++ b/ui/src/pages/new-session/draft-submission-flow.ts @@ -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> & { 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( "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({