mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
fix(ui): resume session starts after Gateway reconnects (#128661)
* fix(ui): resume session creation after reconnects * improve(ui): remove redundant appearance reset buttons * fix(ui): align session protocol and appearance validation * fix(ui): retain promoted placement session ownership * fix(gateway): isolate session creation capacity by owner * test(ui): stabilize hovercard bridge pointer movement
This commit is contained in:
committed by
GitHub
parent
fb576e3518
commit
b145e25fea
@@ -8971,6 +8971,7 @@ public struct SessionWorktreeInfo: Codable, Sendable {
|
||||
|
||||
public struct SessionsCreateParams: Codable, Sendable {
|
||||
public let key: String?
|
||||
public let idempotencykey: String?
|
||||
public let agentid: String?
|
||||
public let label: String?
|
||||
public let category: String?
|
||||
@@ -8999,6 +9000,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
|
||||
public init(
|
||||
key: String? = nil,
|
||||
idempotencykey: String? = nil,
|
||||
agentid: String? = nil,
|
||||
label: String? = nil,
|
||||
category: String? = nil,
|
||||
@@ -9026,6 +9028,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
cwd: String? = nil)
|
||||
{
|
||||
self.key = key
|
||||
self.idempotencykey = idempotencykey
|
||||
self.agentid = agentid
|
||||
self.label = label
|
||||
self.category = category
|
||||
@@ -9055,6 +9058,7 @@ public struct SessionsCreateParams: Codable, Sendable {
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case key
|
||||
case idempotencykey = "idempotencyKey"
|
||||
case agentid = "agentId"
|
||||
case label
|
||||
case category
|
||||
|
||||
@@ -29,6 +29,10 @@ export {
|
||||
export * from "./schema/session-classification.js";
|
||||
export * from "./schema/sessions-suggestions.js";
|
||||
export * from "./schema/sessions-delete.js";
|
||||
export {
|
||||
SESSION_CREATE_IDEMPOTENCY_RETENTION_MS,
|
||||
SESSION_CREATE_RETRY_WINDOW_MS,
|
||||
} from "./schema/sessions-create.js";
|
||||
export * from "./schema/projects.js";
|
||||
export * from "./migration-api.js";
|
||||
export type * from "./public-session-catalog.js";
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { validateSessionsCreateParams } from "../index.js";
|
||||
import {
|
||||
SESSION_CREATE_IDEMPOTENCY_RETENTION_MS,
|
||||
SESSION_CREATE_RETRY_WINDOW_MS,
|
||||
validateSessionsCreateParams,
|
||||
} from "../index.js";
|
||||
|
||||
describe("sessions.create schema", () => {
|
||||
it("retains successful creates beyond the client's bounded retry window", () => {
|
||||
expect(SESSION_CREATE_RETRY_WINDOW_MS).toBe(4 * 60_000);
|
||||
expect(SESSION_CREATE_IDEMPOTENCY_RETENTION_MS).toBeGreaterThan(SESSION_CREATE_RETRY_WINDOW_MS);
|
||||
});
|
||||
|
||||
it.each(["read-only", "guarded", "workspace", "full"])(
|
||||
"accepts the closed permission mode %s",
|
||||
(permissionMode) => {
|
||||
@@ -24,4 +33,11 @@ describe("sessions.create schema", () => {
|
||||
it("rejects unknown visibility values", () => {
|
||||
expect(validateSessionsCreateParams({ agentId: "main", visibility: "private" })).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a nonempty creation idempotency key", () => {
|
||||
expect(validateSessionsCreateParams({ agentId: "main", idempotencyKey: "start-once" })).toBe(
|
||||
true,
|
||||
);
|
||||
expect(validateSessionsCreateParams({ agentId: "main", idempotencyKey: "" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,9 +5,13 @@ import { NonEmptyString, SessionLabelString } from "./primitives.js";
|
||||
import { SessionPermissionModeSchema } from "./sessions-row.js";
|
||||
import { SessionVisibilitySchema } from "./sessions-sharing-values.js";
|
||||
|
||||
export const SESSION_CREATE_RETRY_WINDOW_MS = 4 * 60_000;
|
||||
export const SESSION_CREATE_IDEMPOTENCY_RETENTION_MS = 5 * 60_000;
|
||||
|
||||
/** Creates or adopts a session with optional model, thinking, label, and parent linkage. */
|
||||
export const SessionsCreateParamsSchema = closedObject({
|
||||
key: Type.Optional(NonEmptyString),
|
||||
idempotencyKey: Type.Optional(NonEmptyString),
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
label: Type.Optional(SessionLabelString),
|
||||
category: Type.Optional(SessionLabelString),
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SESSION_CREATE_IDEMPOTENCY_RETENTION_MS } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { createDeferredCore } from "../../shared/deferred.js";
|
||||
import { DEDUPE_MAX } from "../server-constants.js";
|
||||
import { idempotentSessionCreate } from "./session-create-idempotency.js";
|
||||
import type {
|
||||
GatewayRequestContext,
|
||||
GatewayRequestHandler,
|
||||
GatewayRequestHandlerOptions,
|
||||
RespondFn,
|
||||
} from "./types.js";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createFixture(handler?: GatewayRequestHandler) {
|
||||
const context = { dedupe: new Map() } as GatewayRequestContext;
|
||||
const execute = vi.fn<GatewayRequestHandler>(
|
||||
handler ??
|
||||
((request) => {
|
||||
request.respond(true, { key: `agent:main:${String(request.params.idempotencyKey)}` });
|
||||
}),
|
||||
);
|
||||
const wrapped = idempotentSessionCreate(execute);
|
||||
const client = {
|
||||
authenticatedUserId: "owner",
|
||||
connect: {
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
client: { id: "test", version: "1", platform: "test", mode: "test" },
|
||||
device: {
|
||||
id: "device",
|
||||
publicKey: "key",
|
||||
signature: "signature",
|
||||
signedAt: 1,
|
||||
nonce: "nonce",
|
||||
},
|
||||
role: "operator",
|
||||
scopes: ["operator.admin", "operator.write"],
|
||||
},
|
||||
} satisfies NonNullable<GatewayRequestHandlerOptions["client"]>;
|
||||
|
||||
function invoke(
|
||||
params: Record<string, unknown> = { agentId: "main", idempotencyKey: "create-once" },
|
||||
connection: typeof client = client,
|
||||
) {
|
||||
const respond = vi.fn<RespondFn>();
|
||||
const request: GatewayRequestHandlerOptions = {
|
||||
req: { type: "req", id: "1", method: "sessions.create" },
|
||||
params,
|
||||
client: connection,
|
||||
context,
|
||||
respond,
|
||||
isWebchatConnect: () => false,
|
||||
};
|
||||
return { done: Promise.resolve(wrapped(request)), respond };
|
||||
}
|
||||
|
||||
return { client, context, execute, invoke };
|
||||
}
|
||||
|
||||
describe("sessions.create process-lifetime idempotency", () => {
|
||||
it("replays reordered requests and added authorization without accepting downgraded scopes", async () => {
|
||||
const { client, context, execute, invoke } = createFixture();
|
||||
const first = invoke({ agentId: "main", idempotencyKey: "create-once", message: "hello" });
|
||||
await first.done;
|
||||
context.dedupe.clear();
|
||||
|
||||
const added = invoke(
|
||||
{ message: "hello", idempotencyKey: "create-once", agentId: "main" },
|
||||
{
|
||||
...client,
|
||||
connect: {
|
||||
...client.connect,
|
||||
scopes: ["operator.write", "operator.read", "operator.admin"],
|
||||
},
|
||||
},
|
||||
);
|
||||
await added.done;
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
expect(added.respond).toHaveBeenCalledWith(true, { key: "agent:main:create-once" }, undefined, {
|
||||
cached: true,
|
||||
});
|
||||
|
||||
const downgraded = invoke(
|
||||
{ agentId: "main", idempotencyKey: "create-once", message: "hello" },
|
||||
{ ...client, connect: { ...client.connect, scopes: ["operator.write"] } },
|
||||
);
|
||||
await downgraded.done;
|
||||
expect(downgraded.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: "FORBIDDEN", message: "missing scope: operator.admin" }),
|
||||
);
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
|
||||
const changedRole = invoke(
|
||||
{ agentId: "main", idempotencyKey: "create-once", message: "hello" },
|
||||
{ ...client, connect: { ...client.connect, role: "node" } },
|
||||
);
|
||||
await changedRole.done;
|
||||
expect(changedRole.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: "FORBIDDEN" }),
|
||||
);
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
|
||||
const conflict = invoke({
|
||||
agentId: "main",
|
||||
idempotencyKey: "create-once",
|
||||
message: "different",
|
||||
});
|
||||
await conflict.done;
|
||||
expect(conflict.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: "INVALID_REQUEST" }),
|
||||
);
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
|
||||
const differentDevice = invoke(
|
||||
{ agentId: "main", idempotencyKey: "create-once", message: "hello" },
|
||||
{
|
||||
...client,
|
||||
connect: {
|
||||
...client.connect,
|
||||
device: { ...client.connect.device, id: "other-device" },
|
||||
},
|
||||
},
|
||||
);
|
||||
await differentDevice.done;
|
||||
expect(differentDevice.respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{ key: "agent:main:create-once" },
|
||||
undefined,
|
||||
{ cached: true },
|
||||
);
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects idempotent creation without a principal or device namespace", async () => {
|
||||
const { execute, invoke } = createFixture();
|
||||
const anonymous = invoke({ agentId: "main", idempotencyKey: "create-once" }, null as never);
|
||||
|
||||
await anonymous.done;
|
||||
|
||||
expect(anonymous.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({
|
||||
code: "INVALID_REQUEST",
|
||||
message: expect.stringContaining("identity"),
|
||||
}),
|
||||
);
|
||||
expect(execute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("pins in-flight request identity beyond retention and independent of global dedupe pruning", async () => {
|
||||
let now = 1_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const release = createDeferredCore();
|
||||
const { context, execute, invoke } = createFixture(async (request) => {
|
||||
await release.promise;
|
||||
request.respond(true, { key: "agent:main:finished" });
|
||||
});
|
||||
const original = invoke({ agentId: "main", idempotencyKey: "long-create", message: "hello" });
|
||||
await vi.waitFor(() => expect(execute).toHaveBeenCalledOnce());
|
||||
now += SESSION_CREATE_IDEMPOTENCY_RETENTION_MS + 1;
|
||||
context.dedupe.clear();
|
||||
|
||||
const conflicting = invoke({
|
||||
agentId: "main",
|
||||
idempotencyKey: "long-create",
|
||||
message: "different",
|
||||
});
|
||||
await conflicting.done;
|
||||
expect(conflicting.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: "INVALID_REQUEST" }),
|
||||
);
|
||||
|
||||
const joined = invoke({ agentId: "main", idempotencyKey: "long-create", message: "hello" });
|
||||
release.resolve();
|
||||
await Promise.all([original.done, joined.done]);
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
expect(joined.respond).toHaveBeenCalledWith(true, { key: "agent:main:finished" }, undefined, {
|
||||
cached: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("expires only settled successful results and immediately releases failed creates", async () => {
|
||||
let now = 1_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const { execute, invoke } = createFixture();
|
||||
await invoke().done;
|
||||
now += SESSION_CREATE_IDEMPOTENCY_RETENTION_MS - 1;
|
||||
await invoke().done;
|
||||
expect(execute).toHaveBeenCalledOnce();
|
||||
now += 1;
|
||||
await invoke().done;
|
||||
expect(execute).toHaveBeenCalledTimes(2);
|
||||
|
||||
let fail = true;
|
||||
const failed = createFixture((request) => {
|
||||
request.respond(!fail, fail ? undefined : { key: "recovered" });
|
||||
fail = false;
|
||||
});
|
||||
await failed.invoke().done;
|
||||
await failed.invoke().done;
|
||||
expect(failed.execute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("enforces capacity per owner without evicting retained successful creations", async () => {
|
||||
const { client, execute, invoke } = createFixture();
|
||||
for (let index = 0; index < DEDUPE_MAX; index += 1) {
|
||||
await invoke({ agentId: "main", idempotencyKey: `create-${index}` }).done;
|
||||
}
|
||||
const overflow = invoke({ agentId: "main", idempotencyKey: "overflow" });
|
||||
await overflow.done;
|
||||
expect(overflow.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: "UNAVAILABLE", message: expect.stringContaining("retry") }),
|
||||
);
|
||||
expect(execute).toHaveBeenCalledTimes(DEDUPE_MAX);
|
||||
|
||||
const retained = invoke({ agentId: "main", idempotencyKey: "create-0" });
|
||||
await retained.done;
|
||||
expect(retained.respond).toHaveBeenCalledWith(true, { key: "agent:main:create-0" }, undefined, {
|
||||
cached: true,
|
||||
});
|
||||
expect(execute).toHaveBeenCalledTimes(DEDUPE_MAX);
|
||||
|
||||
const otherOwner = { ...client, authenticatedUserId: "other-owner" };
|
||||
const otherCreation = invoke({ agentId: "main", idempotencyKey: "create-0" }, otherOwner);
|
||||
await otherCreation.done;
|
||||
expect(otherCreation.respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{ key: "agent:main:create-0" },
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(execute).toHaveBeenCalledTimes(DEDUPE_MAX + 1);
|
||||
|
||||
const otherReplay = invoke({ agentId: "main", idempotencyKey: "create-0" }, otherOwner);
|
||||
await otherReplay.done;
|
||||
expect(otherReplay.respond).toHaveBeenCalledWith(
|
||||
true,
|
||||
{ key: "agent:main:create-0" },
|
||||
undefined,
|
||||
{ cached: true },
|
||||
);
|
||||
expect(execute).toHaveBeenCalledTimes(DEDUPE_MAX + 1);
|
||||
|
||||
for (let index = 1; index < DEDUPE_MAX; index += 1) {
|
||||
await invoke({ agentId: "main", idempotencyKey: `create-${index}` }, otherOwner).done;
|
||||
}
|
||||
const thirdOwner = { ...client, authenticatedUserId: "third-owner" };
|
||||
const processOverflow = invoke({ agentId: "main", idempotencyKey: "create-0" }, thirdOwner);
|
||||
await processOverflow.done;
|
||||
expect(processOverflow.respond).toHaveBeenCalledWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: "UNAVAILABLE" }),
|
||||
);
|
||||
expect(execute).toHaveBeenCalledTimes(DEDUPE_MAX * 2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { stableStringify } from "@openclaw/normalization-core";
|
||||
import {
|
||||
ErrorCodes,
|
||||
SESSION_CREATE_IDEMPOTENCY_RETENTION_MS,
|
||||
errorShape,
|
||||
missingScopeErrorShape,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { DEDUPE_MAX } from "../server-constants.js";
|
||||
import type { GatewayInflightResult } from "./inflight.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandler } from "./types.js";
|
||||
|
||||
type SessionCreateAuthorization = { role: string | null; scopes: readonly string[] };
|
||||
type SessionCreateEntry = {
|
||||
requestIdentity: string;
|
||||
authorization: SessionCreateAuthorization;
|
||||
expiresAt: number;
|
||||
state:
|
||||
| { kind: "inflight"; work: Promise<GatewayInflightResult> }
|
||||
| { kind: "completed"; result: GatewayInflightResult };
|
||||
};
|
||||
|
||||
const sessionCreatesByContext = new WeakMap<
|
||||
GatewayRequestContext,
|
||||
Map<string, Map<string, SessionCreateEntry>>
|
||||
>();
|
||||
|
||||
export function idempotentSessionCreate(handler: GatewayRequestHandler): GatewayRequestHandler {
|
||||
return async (request) => {
|
||||
const idempotencyKey = request.params.idempotencyKey;
|
||||
if (typeof idempotencyKey !== "string" || !idempotencyKey) {
|
||||
await handler(request);
|
||||
return;
|
||||
}
|
||||
const principal =
|
||||
request.client?.authenticatedUserProfile?.profileId ?? request.client?.authenticatedUserId;
|
||||
const deviceId = request.client?.connect.device?.id?.trim();
|
||||
if (!principal && !deviceId) {
|
||||
request.respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"idempotent session creation requires an authenticated principal or device identity",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const owner = principal ? `principal:${principal}` : `device:${deviceId}`;
|
||||
let entriesByOwner = sessionCreatesByContext.get(request.context);
|
||||
if (!entriesByOwner) {
|
||||
entriesByOwner = new Map();
|
||||
sessionCreatesByContext.set(request.context, entriesByOwner);
|
||||
}
|
||||
const now = Date.now();
|
||||
let retainedEntryCount = 0;
|
||||
for (const [entryOwner, ownerEntries] of entriesByOwner) {
|
||||
for (const [key, entry] of ownerEntries) {
|
||||
if (entry.state.kind === "completed" && entry.expiresAt <= now) {
|
||||
ownerEntries.delete(key);
|
||||
}
|
||||
}
|
||||
if (ownerEntries.size === 0) {
|
||||
entriesByOwner.delete(entryOwner);
|
||||
} else {
|
||||
retainedEntryCount += ownerEntries.size;
|
||||
}
|
||||
}
|
||||
let entries = entriesByOwner.get(owner);
|
||||
const requestIdentity = createHash("sha256")
|
||||
.update(stableStringify(request.params))
|
||||
.digest("hex");
|
||||
const authorization: SessionCreateAuthorization = {
|
||||
role: request.client?.connect.role ?? null,
|
||||
scopes: request.client?.connect.scopes?.toSorted() ?? [],
|
||||
};
|
||||
const existing = entries?.get(idempotencyKey);
|
||||
if (existing) {
|
||||
if (existing.requestIdentity !== requestIdentity) {
|
||||
request.respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
"session creation idempotency key was reused with different parameters",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (existing.authorization.role !== authorization.role) {
|
||||
request.respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.FORBIDDEN, "session creation authorization changed; start again"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const missingScope = existing.authorization.scopes.find(
|
||||
(scope) => !authorization.scopes.includes(scope),
|
||||
);
|
||||
if (missingScope) {
|
||||
request.respond(
|
||||
false,
|
||||
undefined,
|
||||
missingScopeErrorShape({
|
||||
missingScope,
|
||||
requiredScopes: existing.authorization.scopes,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const result =
|
||||
existing.state.kind === "completed" ? existing.state.result : await existing.state.work;
|
||||
request.respond(result.ok, result.payload, result.error, {
|
||||
...result.meta,
|
||||
cached: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Reserve a full owner's capacity for other principals while bounding process-wide state.
|
||||
if ((entries?.size ?? 0) >= DEDUPE_MAX || retainedEntryCount >= DEDUPE_MAX * 2) {
|
||||
request.respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.UNAVAILABLE, "session creation capacity is full; retry later"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!entries) {
|
||||
entries = new Map();
|
||||
entriesByOwner.set(owner, entries);
|
||||
}
|
||||
const releaseEntry = () => {
|
||||
entries.delete(idempotencyKey);
|
||||
if (entries.size === 0) {
|
||||
entriesByOwner.delete(owner);
|
||||
}
|
||||
};
|
||||
// The entry is installed before work begins; in-flight identity is never TTL/cap-evictable.
|
||||
const work = Promise.resolve().then(async (): Promise<GatewayInflightResult> => {
|
||||
try {
|
||||
let result: GatewayInflightResult | undefined;
|
||||
await handler({
|
||||
...request,
|
||||
respond: (ok, payload, error, meta) => {
|
||||
result = { ok, payload, error, meta };
|
||||
},
|
||||
});
|
||||
result ??= {
|
||||
ok: false,
|
||||
error: errorShape(ErrorCodes.UNAVAILABLE, "session creation was interrupted"),
|
||||
};
|
||||
if (result.ok) {
|
||||
entry.expiresAt = Date.now() + SESSION_CREATE_IDEMPOTENCY_RETENTION_MS;
|
||||
entry.state = { kind: "completed", result };
|
||||
} else {
|
||||
releaseEntry();
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
releaseEntry();
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
const entry: SessionCreateEntry = {
|
||||
requestIdentity,
|
||||
authorization,
|
||||
expiresAt: now + SESSION_CREATE_IDEMPOTENCY_RETENTION_MS,
|
||||
state: { kind: "inflight", work },
|
||||
};
|
||||
entries.set(idempotencyKey, entry);
|
||||
const result = await work;
|
||||
request.respond(result.ok, result.payload, result.error, result.meta);
|
||||
};
|
||||
}
|
||||
@@ -43,6 +43,7 @@ import { chatHandlers } from "./chat.js";
|
||||
import { resolveRegisteredCatalogCreateTarget } from "./session-catalog.js";
|
||||
import { emitSessionsChanged } from "./session-change-event.js";
|
||||
import { registerCreatedSessionCategory } from "./session-create-category.js";
|
||||
import { idempotentSessionCreate } from "./session-create-idempotency.js";
|
||||
import {
|
||||
resolveSessionCreateInitialTurn,
|
||||
shouldAttachPendingMessageSeq,
|
||||
@@ -689,3 +690,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
sessionCreateHandlers["sessions.create"] = idempotentSessionCreate(
|
||||
expectDefined(sessionCreateHandlers["sessions.create"], "sessions.create handler"),
|
||||
);
|
||||
|
||||
@@ -4276,6 +4276,121 @@ test("sessions.create adopting an existing key does not restamp node provenance"
|
||||
}
|
||||
});
|
||||
|
||||
test("sessions.create replays an identical creation once and rejects conflicting intent", async () => {
|
||||
await createSessionStoreDir();
|
||||
const { chatHandlers } = await import("./server-methods/chat.js");
|
||||
const { sessionCreateHandlers } = await import("./server-methods/sessions-create.js");
|
||||
let sharedContext:
|
||||
| Parameters<NonNullable<(typeof chatHandlers)["chat.send"]>>[0]["context"]
|
||||
| undefined;
|
||||
const chatSend = vi
|
||||
.spyOn(chatHandlers, "chat.send")
|
||||
.mockImplementation(async ({ context, respond }) => {
|
||||
sharedContext ??= context;
|
||||
respond(true, { runId: "create-once", status: "started" });
|
||||
});
|
||||
const dedupe = new Map();
|
||||
const client = {
|
||||
connect: {
|
||||
role: "operator",
|
||||
scopes: ["operator.write", "operator.admin"],
|
||||
device: { id: "control-ui-device" },
|
||||
},
|
||||
authenticatedUserProfile: { profileId: "profile-owner" },
|
||||
};
|
||||
const params = {
|
||||
agentId: "main",
|
||||
idempotencyKey: "create-once",
|
||||
message: "start this task exactly once",
|
||||
permissionMode: "full",
|
||||
};
|
||||
const request = async (nextParams = params, nextClient = client) => {
|
||||
if (!sharedContext) {
|
||||
return await directSessionReq<{ key: string }>("sessions.create", nextParams, {
|
||||
client: nextClient as never,
|
||||
context: { dedupe },
|
||||
});
|
||||
}
|
||||
let result:
|
||||
| { ok: boolean; payload?: { key: string }; error?: { code?: string; message?: string } }
|
||||
| undefined;
|
||||
await sessionCreateHandlers["sessions.create"]?.({
|
||||
req: {} as never,
|
||||
params: nextParams,
|
||||
client: nextClient as never,
|
||||
context: sharedContext,
|
||||
isWebchatConnect: () => false,
|
||||
respond: (ok, payload, error) => {
|
||||
result = { ok, payload: payload as { key: string } | undefined, error };
|
||||
},
|
||||
});
|
||||
if (!result) {
|
||||
throw new Error("sessions.create did not respond");
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
try {
|
||||
const first = await request();
|
||||
const replay = await request(
|
||||
{
|
||||
message: params.message,
|
||||
permissionMode: params.permissionMode,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
agentId: params.agentId,
|
||||
},
|
||||
{
|
||||
...client,
|
||||
connect: {
|
||||
...client.connect,
|
||||
scopes: ["operator.admin", "operator.read", "operator.write"],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(first.ok).toBe(true);
|
||||
expect(replay).toEqual(first);
|
||||
expect(chatSend).toHaveBeenCalledOnce();
|
||||
expect(chatSend.mock.calls[0]?.[0].params).toMatchObject({
|
||||
idempotencyKey: expect.any(String),
|
||||
message: "start this task exactly once",
|
||||
});
|
||||
|
||||
const conflict = await request({ ...params, message: "start a different task" });
|
||||
expect(conflict).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
code: "INVALID_REQUEST",
|
||||
message: "session creation idempotency key was reused with different parameters",
|
||||
},
|
||||
});
|
||||
expect(chatSend).toHaveBeenCalledOnce();
|
||||
|
||||
const downgraded = await request(params, {
|
||||
...client,
|
||||
connect: { ...client.connect, scopes: ["operator.write"] },
|
||||
});
|
||||
expect(downgraded).toMatchObject({
|
||||
ok: false,
|
||||
error: { message: "missing scope: operator.admin" },
|
||||
});
|
||||
expect(chatSend).toHaveBeenCalledOnce();
|
||||
|
||||
const differentOwner = await request(params, {
|
||||
...client,
|
||||
authenticatedUserProfile: { profileId: "profile-other" },
|
||||
});
|
||||
expect(differentOwner.ok).toBe(true);
|
||||
expect(differentOwner.payload?.key).not.toBe(first.payload?.key);
|
||||
expect(chatSend).toHaveBeenCalledTimes(2);
|
||||
expect(chatSend.mock.calls[1]?.[0].params.idempotencyKey).not.toBe(
|
||||
chatSend.mock.calls[0]?.[0].params.idempotencyKey,
|
||||
);
|
||||
} finally {
|
||||
chatSend.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("sessions.create scopes the main alias to the requested agent", async () => {
|
||||
const { storePath } = await createSessionStoreDir();
|
||||
testState.agentsConfig = { list: [{ id: "main", default: true }, { id: "longmemeval" }] };
|
||||
|
||||
@@ -224,6 +224,10 @@ export function renderSettingsToggleRow(props: {
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderSettingsDefaultDescription(value: string, overridden: boolean) {
|
||||
return html`${t(overridden ? "configForm.defaultValue" : "configForm.usingDefault", { value })}`;
|
||||
}
|
||||
|
||||
export function renderSettingsDefaultState(props: {
|
||||
value: string;
|
||||
overridden: boolean;
|
||||
@@ -234,10 +238,7 @@ export function renderSettingsDefaultState(props: {
|
||||
action: TemplateResult | typeof nothing;
|
||||
} {
|
||||
return {
|
||||
description: html`${t(
|
||||
props.overridden ? "configForm.defaultValue" : "configForm.usingDefault",
|
||||
{ value: props.value },
|
||||
)}`,
|
||||
description: renderSettingsDefaultDescription(props.value, props.overridden),
|
||||
action: props.overridden
|
||||
? html`
|
||||
<button
|
||||
@@ -263,6 +264,8 @@ export function renderSettingsSegmented<T extends string>(props: {
|
||||
options: ReadonlyArray<{ value: T; label: unknown; title?: string; testId?: string }>;
|
||||
/** The selected radio is passed so callers can anchor visual transitions. */
|
||||
onChange: (value: T, element: HTMLElement) => void;
|
||||
/** Optional activation for an already-selected value, such as clearing an explicit default. */
|
||||
onReselect?: (value: T, element: HTMLElement) => void;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
@@ -299,6 +302,11 @@ export function renderSettingsSegmented<T extends string>(props: {
|
||||
.checked=${live(option.value === props.value)}
|
||||
title=${option.title ?? nothing}
|
||||
data-test-id=${option.testId ?? nothing}
|
||||
@click=${(event: Event) => {
|
||||
if (option.value === props.value && event.currentTarget instanceof HTMLElement) {
|
||||
props.onReselect?.(option.value, event.currentTarget);
|
||||
}
|
||||
}}
|
||||
>
|
||||
${option.label}
|
||||
</wa-radio>
|
||||
|
||||
@@ -274,8 +274,7 @@ suite.define(() => {
|
||||
await resetSyncedPreference({
|
||||
click: () =>
|
||||
themeSection
|
||||
.locator(":scope > .settings-section__header")
|
||||
.getByRole("button", { name: "Reset to default" })
|
||||
.locator(".settings-theme-card--claw")
|
||||
.click()
|
||||
.then(() => undefined),
|
||||
expectedKey: "theme",
|
||||
@@ -289,7 +288,7 @@ suite.define(() => {
|
||||
await resetSyncedPreference({
|
||||
click: () =>
|
||||
colorModeRow
|
||||
.getByRole("button", { name: "Reset to default" })
|
||||
.locator('wa-radio[value="system"]')
|
||||
.click()
|
||||
.then(() => undefined),
|
||||
expectedKey: "themeMode",
|
||||
@@ -298,10 +297,7 @@ suite.define(() => {
|
||||
remainingPrefs: withoutThemeMode,
|
||||
});
|
||||
|
||||
await textSizeSection
|
||||
.locator(":scope > .settings-section__header")
|
||||
.getByRole("button", { name: "Reset to default" })
|
||||
.click();
|
||||
await textSizeSection.locator(".settings-text-scale__btn", { hasText: "100%" }).click();
|
||||
await expect.poll(() => readPersistedSettings(page)).not.toHaveProperty("textScale");
|
||||
|
||||
await resetSyncedPreference({
|
||||
@@ -480,14 +476,16 @@ suite.define(() => {
|
||||
}
|
||||
await captureViewport(page, "09-accent-mint-selected.png");
|
||||
|
||||
await gateway.setMethodResponse(
|
||||
"config.get",
|
||||
configResponse({ accent: mintAccent, theme: "claw" }, "appearance-accent-3"),
|
||||
);
|
||||
await page.locator("#settings-appearance-theme .settings-theme-card--claw").click();
|
||||
await waitForRequestCount(gateway, "config.patch", 2);
|
||||
expect(patchPrefs((await gateway.getRequests("config.patch"))[1]!)).toEqual({
|
||||
theme: "claw",
|
||||
await resetSyncedPreference({
|
||||
click: () =>
|
||||
page
|
||||
.locator("#settings-appearance-theme .settings-theme-card--claw")
|
||||
.click()
|
||||
.then(() => undefined),
|
||||
expectedKey: "theme",
|
||||
gateway,
|
||||
hash: "appearance-accent-3",
|
||||
remainingPrefs: { accent: mintAccent },
|
||||
});
|
||||
await expect.poll(() => page.locator("html").getAttribute("data-theme")).toBe("dark");
|
||||
await expect.poll(() => readAccentPresentation(page)).toMatchObject({ accent: mintAccent });
|
||||
@@ -495,7 +493,7 @@ suite.define(() => {
|
||||
|
||||
await gateway.setMethodResponse(
|
||||
"config.get",
|
||||
configResponse({ accent: customAccent, theme: "claw" }, "appearance-accent-4"),
|
||||
configResponse({ accent: customAccent }, "appearance-accent-4"),
|
||||
);
|
||||
await accentSection.locator('input[type="color"][data-accent-custom]').fill(customAccent);
|
||||
await waitForRequestCount(gateway, "config.patch", 3);
|
||||
@@ -514,14 +512,13 @@ suite.define(() => {
|
||||
await resetSyncedPreference({
|
||||
click: () =>
|
||||
accentSection
|
||||
.locator(":scope > .settings-section__header")
|
||||
.getByRole("button", { name: "Reset to default" })
|
||||
.locator('[data-accent-preset="default"]')
|
||||
.click()
|
||||
.then(() => undefined),
|
||||
expectedKey: "accent",
|
||||
gateway,
|
||||
hash: "appearance-accent-5",
|
||||
remainingPrefs: { theme: "claw" },
|
||||
remainingPrefs: {},
|
||||
});
|
||||
await expect
|
||||
.poll(() =>
|
||||
@@ -639,10 +636,7 @@ suite.define(() => {
|
||||
.poll(() => followUpRow.textContent())
|
||||
.toContain("Stocké uniquement dans ce navigateur");
|
||||
|
||||
await themeSection
|
||||
.locator(":scope > .settings-section__header")
|
||||
.locator("button.btn--icon")
|
||||
.click();
|
||||
await themeSection.locator(".settings-theme-card--claw").click();
|
||||
await languageRow.locator("button.btn--icon").click();
|
||||
await followUpRow.locator("button.btn--sm").click();
|
||||
|
||||
@@ -705,10 +699,7 @@ suite.define(() => {
|
||||
await expect.poll(() => readPersistedSettings(page)).toMatchObject({ theme: "claw" });
|
||||
expect(await gateway.getRequests("config.patch")).toHaveLength(0);
|
||||
|
||||
await themeSection
|
||||
.locator(":scope > .settings-section__header")
|
||||
.locator("button.btn--icon")
|
||||
.click();
|
||||
await themeSection.locator(".settings-theme-card--knot").click();
|
||||
await expect
|
||||
.poll(() => themeSection.locator(".settings-theme-card--knot").getAttribute("aria-pressed"))
|
||||
.toBe("true");
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
SOURCE_REPO,
|
||||
TARGET_REPO,
|
||||
WORKSPACE,
|
||||
controlUiSessionPath,
|
||||
createNewSessionPageE2eSuite,
|
||||
installMockGateway,
|
||||
pollLocatorText,
|
||||
@@ -400,9 +401,9 @@ suite.define(() => {
|
||||
});
|
||||
|
||||
for (const reconnectKind of ["same-client reconnect", "client replacement"] as const) {
|
||||
it(`marks a pending creation outcome unknown after ${reconnectKind}`, async () => {
|
||||
it(`automatically resumes an idempotent session creation after ${reconnectKind}`, async () => {
|
||||
await withNewSessionPage(DESKTOP_CONTEXT, async (page) => {
|
||||
const sessionKey = `agent:main:unknown-${reconnectKind.replaceAll(" ", "-")}`;
|
||||
const sessionKey = `agent:main:resumed-${reconnectKind.replaceAll(" ", "-")}`;
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"agents.list": mainAgentList("Original agent", SOURCE_REPO),
|
||||
@@ -413,12 +414,14 @@ suite.define(() => {
|
||||
await page.goto(`${suite.server.baseUrl}new`);
|
||||
await page.getByRole("heading", { name: "Original agent" }).waitFor();
|
||||
const message = page.locator(".new-session-page__message");
|
||||
const projectTrigger = page.locator("#new-session-project-trigger");
|
||||
const start = page.locator("button.chat-send-btn");
|
||||
await message.fill("retry this draft after reconnect");
|
||||
await gateway.deferNext("sessions.create");
|
||||
await start.click();
|
||||
await gateway.waitForRequest("sessions.create");
|
||||
const originalCreate = await gateway.waitForRequest("sessions.create");
|
||||
await expect.poll(() => start.isDisabled()).toBe(true);
|
||||
await gateway.deferNext("sessions.create");
|
||||
|
||||
if (reconnectKind === "client replacement") {
|
||||
await gateway.setMethodResponse(
|
||||
@@ -433,30 +436,67 @@ suite.define(() => {
|
||||
const agentRequestsBefore = (await gateway.getRequests("agents.list")).length;
|
||||
await gateway.setOnline(false);
|
||||
await waitForControlUiGatewayReconnecting(page);
|
||||
expect(await message.isDisabled()).toBe(true);
|
||||
expect(await projectTrigger.isDisabled()).toBe(true);
|
||||
expect(await start.getAttribute("aria-busy")).toBe("true");
|
||||
await gateway.setOnline(true);
|
||||
await waitForControlUiGatewayReady(page);
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("agents.list")).length)
|
||||
.toBe(agentRequestsBefore + 1);
|
||||
}
|
||||
await expect.poll(() => message.inputValue()).toBe("retry this draft after reconnect");
|
||||
await expect.poll(() => message.isEnabled()).toBe(true);
|
||||
await expect.poll(() => start.isDisabled()).toBe(true);
|
||||
// The gate table also surfaces this reason in the Start tooltip, so
|
||||
// scope to the page callout instead of a bare text match.
|
||||
await page
|
||||
.getByRole("alert")
|
||||
.filter({
|
||||
hasText:
|
||||
"The Gateway changed while this session was starting. Check recent sessions before starting this task again.",
|
||||
})
|
||||
.waitFor();
|
||||
expect(new URL(page.url()).pathname).toBe("/new");
|
||||
expect(await gateway.getRequests("sessions.create")).toHaveLength(1);
|
||||
await expect
|
||||
.poll(async () => (await gateway.getRequests("sessions.create")).length)
|
||||
.toBe(2);
|
||||
const resumedCreate = (await gateway.getRequests("sessions.create")).at(-1);
|
||||
expect(originalCreate.params).toMatchObject({
|
||||
idempotencyKey: expect.any(String),
|
||||
message: "retry this draft after reconnect",
|
||||
});
|
||||
expect(resumedCreate?.params).toEqual(originalCreate.params);
|
||||
expect(await message.inputValue()).toBe("retry this draft after reconnect");
|
||||
expect(await message.isDisabled()).toBe(true);
|
||||
expect(await projectTrigger.isDisabled()).toBe(true);
|
||||
await gateway.resolveDeferred("sessions.create", { key: sessionKey });
|
||||
await gateway.resolveDeferred("sessions.create", { key: sessionKey });
|
||||
await page.waitForURL((url) => url.pathname === controlUiSessionPath(sessionKey));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps an interrupted creation fail-closed after the Gateway process restarts", async () => {
|
||||
await withNewSessionPage(DESKTOP_CONTEXT, async (page) => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"agents.list": mainAgentList(),
|
||||
"worktrees.branches": branchList(),
|
||||
},
|
||||
});
|
||||
await page.goto(`${suite.server.baseUrl}new`);
|
||||
await page.getByRole("heading", { name: "Main" }).waitFor();
|
||||
await page.locator(".new-session-page__message").fill("do not duplicate this task");
|
||||
await gateway.deferNext("sessions.create");
|
||||
await page.getByRole("button", { name: "Start session" }).click();
|
||||
await gateway.waitForRequest("sessions.create");
|
||||
|
||||
await gateway.setOnline(false);
|
||||
await waitForControlUiGatewayReconnecting(page);
|
||||
await gateway.setGatewayBootId("different-gateway-process");
|
||||
await gateway.setOnline(true);
|
||||
await waitForControlUiGatewayReady(page);
|
||||
|
||||
await page
|
||||
.getByRole("alert")
|
||||
.filter({
|
||||
hasText:
|
||||
"The Gateway changed while this session was starting. Check recent sessions before starting this task again.",
|
||||
})
|
||||
.waitFor();
|
||||
expect(await gateway.getRequests("sessions.create")).toHaveLength(1);
|
||||
expect(new URL(page.url()).pathname).toBe("/new");
|
||||
});
|
||||
});
|
||||
|
||||
it("resets agent-derived workspace state when retargeted to a catalog", async () => {
|
||||
await withNewSessionPage(DESKTOP_CONTEXT, async (page) => {
|
||||
const gateway = await installMockGateway(page, {
|
||||
|
||||
@@ -575,13 +575,18 @@ suite.define(() => {
|
||||
await card.waitFor({ state: "visible" });
|
||||
const firstBounds = await first.boundingBox();
|
||||
expect(firstBounds).not.toBeNull();
|
||||
const cardBounds = await card.boundingBox();
|
||||
expect(cardBounds).not.toBeNull();
|
||||
await pointer(first, "pointerout", {
|
||||
clientX: (firstBounds?.x ?? 0) + (firstBounds?.width ?? 0),
|
||||
clientY: (firstBounds?.y ?? 0) + (firstBounds?.height ?? 0) / 2,
|
||||
});
|
||||
await page.waitForTimeout(150);
|
||||
expect(await card.count()).toBe(1);
|
||||
await card.hover();
|
||||
await page.mouse.move(
|
||||
(cardBounds?.x ?? 0) + (cardBounds?.width ?? 0) / 2,
|
||||
(cardBounds?.y ?? 0) + (cardBounds?.height ?? 0) / 2,
|
||||
);
|
||||
expect(await card.count()).toBe(1);
|
||||
await page.mouse.move(900, 800);
|
||||
await page.waitForTimeout(50);
|
||||
|
||||
@@ -31,7 +31,9 @@ const textTokens = [
|
||||
const surfaceTokens = ["--bg", "--bg-elevated", "--bg-muted", "--card", "--panel"] as const;
|
||||
|
||||
function themeConfigResponse(family: "claw" | "knot" | "dash", mode: "dark" | "light") {
|
||||
const config = { ui: { prefs: { theme: family, themeMode: mode } } };
|
||||
const config = {
|
||||
ui: { prefs: { ...(family === "claw" ? {} : { theme: family }), themeMode: mode } },
|
||||
};
|
||||
const hash = `theme-contrast-${family}-${mode}`;
|
||||
return {
|
||||
appliedConfigHash: hash,
|
||||
@@ -176,7 +178,9 @@ suite.define(() => {
|
||||
const patch = await gateway.waitForRequest("config.patch");
|
||||
const raw = (patch.params as { raw?: unknown } | undefined)?.raw;
|
||||
expect(typeof raw).toBe("string");
|
||||
expect(JSON.parse(String(raw))).toMatchObject({ ui: { prefs: { theme: family } } });
|
||||
expect(JSON.parse(String(raw))).toMatchObject({
|
||||
ui: { prefs: { theme: family === "claw" ? null : family } },
|
||||
});
|
||||
|
||||
// Theme clicks apply immediately; the eventual Gateway acknowledgement must not revert them.
|
||||
await expect.poll(() => page.locator("html").getAttribute("data-theme")).toBe(resolved);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { SessionsCreateResult } from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import type {
|
||||
SessionsCreateParams,
|
||||
SessionsCreateResult,
|
||||
} from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
|
||||
export type SessionCreateOutcome = {
|
||||
@@ -9,35 +12,8 @@ export type SessionCreateOutcome = {
|
||||
| { status: "rejected"; error: string };
|
||||
};
|
||||
|
||||
export type SessionCreateParams = {
|
||||
key?: string;
|
||||
agentId?: string;
|
||||
catalogId?: string;
|
||||
export type SessionCreateParams = SessionsCreateParams & {
|
||||
currentSessionKey?: string;
|
||||
parentSessionKey?: string;
|
||||
fork?: boolean;
|
||||
forkFrom?: "last-completed";
|
||||
succeedsParent?: boolean;
|
||||
label?: string;
|
||||
category?: string;
|
||||
model?: string;
|
||||
contextWindow?: string;
|
||||
thinkingLevel?: string;
|
||||
incognito?: boolean;
|
||||
worktree?: boolean;
|
||||
/** Base ref for the managed worktree branch; requires worktree. */
|
||||
worktreeBaseRef?: string;
|
||||
/** Worktree name (branch becomes openclaw/<name>); requires worktree. */
|
||||
worktreeName?: string;
|
||||
/** Bind session exec to host=node with this node id (operator.admin). */
|
||||
execNode?: string;
|
||||
/** Absolute source checkout for the worktree (operator.admin). */
|
||||
cwd?: string;
|
||||
/** First message; the gateway creates the session and starts the run in one call. */
|
||||
message?: string;
|
||||
/** Attachments for the first message, using the chat.send wire format. */
|
||||
attachments?: unknown[];
|
||||
task?: string;
|
||||
};
|
||||
|
||||
export function resolveSessionCreateParams(sessionKey = "", agentId?: string) {
|
||||
@@ -56,7 +32,7 @@ export function resolveSessionCreateParams(sessionKey = "", agentId?: string) {
|
||||
|
||||
export async function requestSessionCreate(
|
||||
client: Pick<GatewayBrowserClient, "request">,
|
||||
params: Omit<SessionCreateParams, "currentSessionKey"> & { emitCommandHooks?: boolean } = {},
|
||||
params: Omit<SessionCreateParams, "currentSessionKey"> = {},
|
||||
): Promise<SessionCreateOutcome> {
|
||||
const result = await client.request<SessionsCreateResult>("sessions.create", params);
|
||||
const key = typeof result?.key === "string" ? result.key.trim() : "";
|
||||
|
||||
@@ -201,11 +201,7 @@ describe("ConfigPage synced preference provenance", () => {
|
||||
expect(themeSection?.textContent).not.toContain("Default: Knot");
|
||||
expect(themeSection?.textContent).not.toContain("Stored in this browser only");
|
||||
|
||||
themeSection
|
||||
?.querySelector<HTMLButtonElement>(
|
||||
":scope > .settings-section__header button[aria-label='Reset to default']",
|
||||
)
|
||||
?.click();
|
||||
themeSection?.querySelector<HTMLButtonElement>(".settings-theme-card--claw")?.click();
|
||||
|
||||
expect(changedServerUiPrefs(beforeReset, state.settings)).toEqual({ theme: null });
|
||||
});
|
||||
|
||||
@@ -938,7 +938,6 @@ export class ConfigPage extends OpenClawLightDomElement {
|
||||
) {
|
||||
switch (key) {
|
||||
case "theme":
|
||||
this.customThemeImportOwner.recordActivation(null);
|
||||
this.settings = resetServerUiPref(
|
||||
"theme",
|
||||
this.currentThemePref(),
|
||||
@@ -981,13 +980,16 @@ export class ConfigPage extends OpenClawLightDomElement {
|
||||
theme: ThemeName,
|
||||
context?: Parameters<typeof startThemeTransition>[0]["context"],
|
||||
) {
|
||||
this.customThemeImportOwner.recordActivation(theme);
|
||||
const preference = this.currentThemePref();
|
||||
const reset = preference.overridden && theme === preference.resetValue;
|
||||
this.customThemeImportOwner.recordActivation(reset ? null : theme);
|
||||
const currentTheme = resolveTheme(this.settings.theme, this.settings.themeMode);
|
||||
startThemeTransition({
|
||||
currentTheme,
|
||||
nextTheme: resolveTheme(theme, this.settings.themeMode),
|
||||
context,
|
||||
applyTheme: () => this.applySettings({ theme }),
|
||||
applyTheme: () =>
|
||||
reset ? this.resetSyncedAppearancePref("theme") : this.applySettings({ theme }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -995,12 +997,17 @@ export class ConfigPage extends OpenClawLightDomElement {
|
||||
mode: ThemeMode,
|
||||
context?: Parameters<typeof startThemeTransition>[0]["context"],
|
||||
) {
|
||||
const preference = this.currentThemeModePref();
|
||||
const reset = preference.overridden && mode === preference.resetValue;
|
||||
const currentTheme = resolveTheme(this.settings.theme, this.settings.themeMode);
|
||||
startThemeTransition({
|
||||
currentTheme,
|
||||
nextTheme: resolveTheme(this.settings.theme, mode),
|
||||
context,
|
||||
applyTheme: () => this.applySettings({ themeMode: mode }),
|
||||
applyTheme: () =>
|
||||
reset
|
||||
? this.resetSyncedAppearancePref("themeMode")
|
||||
: this.applySettings({ themeMode: mode }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1248,11 +1255,11 @@ export class ConfigPage extends OpenClawLightDomElement {
|
||||
onLocaleChange: (locale) => this.setLocale(locale),
|
||||
resetLocale: () => this.resetLocale(),
|
||||
setTheme: (theme, transitionContext) => this.setTheme(theme, transitionContext),
|
||||
resetTheme: () => this.resetSyncedAppearancePref("theme"),
|
||||
setThemeMode: (mode, transitionContext) => this.setThemeMode(mode, transitionContext),
|
||||
resetThemeMode: () => this.resetSyncedAppearancePref("themeMode"),
|
||||
setAccent: (accent) => this.applySettings({ accent }),
|
||||
resetAccent: () => this.resetSyncedAppearancePref("accent"),
|
||||
setAccent: (accent) =>
|
||||
accent === undefined
|
||||
? this.resetSyncedAppearancePref("accent")
|
||||
: this.applySettings({ accent }),
|
||||
hasCustomTheme: Boolean(this.settings.customTheme),
|
||||
customThemeLabel: this.settings.customTheme?.label ?? null,
|
||||
customThemeSourceUrl: this.settings.customTheme?.sourceUrl ?? null,
|
||||
@@ -1267,8 +1274,11 @@ export class ConfigPage extends OpenClawLightDomElement {
|
||||
onOpenCustomThemeImport: () => this.customThemeImportOwner.open(),
|
||||
textScale: this.settings.textScale ?? UI_APPEARANCE_DEFAULTS.textScale,
|
||||
textScaleOverridden: this.settings.textScale !== undefined,
|
||||
setTextScale: (value) => this.setSetting("textScale", normalizeTextScale(value)),
|
||||
resetTextScale: () => this.setSetting("textScale", undefined),
|
||||
setTextScale: (value) =>
|
||||
this.setSetting(
|
||||
"textScale",
|
||||
value === UI_APPEARANCE_DEFAULTS.textScale ? undefined : normalizeTextScale(value),
|
||||
),
|
||||
sidebarLiveActivity:
|
||||
this.settings.sidebarLiveActivity ?? UI_APPEARANCE_DEFAULTS.sidebarLiveActivity,
|
||||
setSidebarLiveActivity: (enabled) => this.setSetting("sidebarLiveActivity", enabled),
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { ThemeName } from "../../app/theme.ts";
|
||||
import { icons } from "../../components/icons.ts";
|
||||
import {
|
||||
renderDocsLink,
|
||||
renderSettingsDefaultState,
|
||||
renderSettingsDefaultDescription,
|
||||
renderSettingsRow,
|
||||
renderSettingsSegmented,
|
||||
renderSettingsStatus,
|
||||
@@ -148,39 +148,21 @@ export function renderAppearanceSection(
|
||||
: t("configView.appearance.importHint"),
|
||||
},
|
||||
];
|
||||
const themeDefaultState = renderSettingsDefaultState({
|
||||
value:
|
||||
themeOptions.find((option) => option.id === props.themeResetValue)?.label ??
|
||||
t("configView.themes.claw.label"),
|
||||
overridden: props.themeOverridden,
|
||||
onReset: props.resetTheme,
|
||||
});
|
||||
const themeModeDefaultState = renderSettingsDefaultState({
|
||||
value:
|
||||
props.themeModeResetValue === "light"
|
||||
? t("common.light")
|
||||
: props.themeModeResetValue === "dark"
|
||||
? t("common.dark")
|
||||
: t("common.system"),
|
||||
overridden: props.themeModeOverridden,
|
||||
onReset: props.resetThemeMode,
|
||||
});
|
||||
const accentDefaultState = renderSettingsDefaultState({
|
||||
value: t("configView.appearance.accents.default"),
|
||||
overridden: props.accentOverridden,
|
||||
onReset: props.resetAccent,
|
||||
});
|
||||
const themeDefault =
|
||||
themeOptions.find((option) => option.id === props.themeResetValue)?.label ??
|
||||
t("configView.themes.claw.label");
|
||||
const themeModeDefault =
|
||||
props.themeModeResetValue === "light"
|
||||
? t("common.light")
|
||||
: props.themeModeResetValue === "dark"
|
||||
? t("common.dark")
|
||||
: t("common.system");
|
||||
const themeProvenance = serverUiPrefProvenanceHint(props.themeProvenance);
|
||||
const themeModeProvenance = serverUiPrefProvenanceHint(props.themeModeProvenance);
|
||||
const accentProvenance = serverUiPrefProvenanceHint(props.accentProvenance);
|
||||
const customAccentSelected = Boolean(
|
||||
props.accent && !ACCENT_PRESETS.some((preset) => preset.hex === props.accent),
|
||||
);
|
||||
const textScaleDefaultState = renderSettingsDefaultState({
|
||||
value: `${UI_APPEARANCE_DEFAULTS.textScale}%`,
|
||||
overridden: props.textScaleOverridden,
|
||||
onReset: props.resetTextScale,
|
||||
});
|
||||
return html`
|
||||
<div class="settings-page">
|
||||
<p class="settings-page__intro">
|
||||
@@ -191,10 +173,10 @@ export function renderAppearanceSection(
|
||||
<section id=${APPEARANCE_SETTINGS_TARGET_IDS.theme} class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h2 class="settings-section__heading">${t("configView.appearance.theme")}</h2>
|
||||
<div class="settings-section__actions">${themeDefaultState.action}</div>
|
||||
</div>
|
||||
<p class="settings-section__desc">
|
||||
${t("configView.appearance.chooseTheme")} ${themeDefaultState.description}
|
||||
${t("configView.appearance.chooseTheme")}
|
||||
${renderSettingsDefaultDescription(themeDefault, props.themeOverridden)}
|
||||
${themeProvenance}
|
||||
</p>
|
||||
<div class="settings-group">
|
||||
@@ -216,7 +198,10 @@ export function renderAppearanceSection(
|
||||
props.onOpenCustomThemeImport?.();
|
||||
return;
|
||||
}
|
||||
if (opt.id !== props.theme) {
|
||||
if (
|
||||
opt.id !== props.theme ||
|
||||
(opt.id === props.themeResetValue && props.themeOverridden)
|
||||
) {
|
||||
const context: ThemeTransitionContext = {
|
||||
element: (e.currentTarget as HTMLElement) ?? undefined,
|
||||
};
|
||||
@@ -238,21 +223,27 @@ export function renderAppearanceSection(
|
||||
</div>
|
||||
${renderSettingsRow({
|
||||
title: t("common.colorMode"),
|
||||
description: html`${themeModeDefaultState.description} ${themeModeProvenance}`,
|
||||
description: html`${renderSettingsDefaultDescription(
|
||||
themeModeDefault,
|
||||
props.themeModeOverridden,
|
||||
)}
|
||||
${themeModeProvenance}`,
|
||||
stacked: true,
|
||||
control: html`
|
||||
${themeModeDefaultState.action}
|
||||
${renderSettingsSegmented({
|
||||
value: props.themeMode,
|
||||
options: [
|
||||
{ value: "system", label: t("common.system") },
|
||||
{ value: "light", label: t("common.light") },
|
||||
{ value: "dark", label: t("common.dark") },
|
||||
],
|
||||
ariaLabel: t("common.colorMode"),
|
||||
onChange: (mode, element) => props.setThemeMode(mode, { element }),
|
||||
})}
|
||||
`,
|
||||
control: renderSettingsSegmented({
|
||||
value: props.themeMode,
|
||||
options: [
|
||||
{ value: "system", label: t("common.system") },
|
||||
{ value: "light", label: t("common.light") },
|
||||
{ value: "dark", label: t("common.dark") },
|
||||
],
|
||||
ariaLabel: t("common.colorMode"),
|
||||
onChange: (mode, element) => props.setThemeMode(mode, { element }),
|
||||
onReselect: (mode, element) => {
|
||||
if (props.themeModeOverridden && mode === props.themeModeResetValue) {
|
||||
props.setThemeMode(mode, { element });
|
||||
}
|
||||
},
|
||||
}),
|
||||
})}
|
||||
<div class="settings-row settings-row--stacked">
|
||||
${showCustomThemeImport
|
||||
@@ -337,10 +328,13 @@ export function renderAppearanceSection(
|
||||
<section id=${APPEARANCE_SETTINGS_TARGET_IDS.accent} class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h2 class="settings-section__heading">${t("configView.appearance.accent")}</h2>
|
||||
<div class="settings-section__actions">${accentDefaultState.action}</div>
|
||||
</div>
|
||||
<p class="settings-section__desc">
|
||||
${t("configView.appearance.accentHint")} ${accentDefaultState.description}
|
||||
${t("configView.appearance.accentHint")}
|
||||
${renderSettingsDefaultDescription(
|
||||
t("configView.appearance.accents.default"),
|
||||
props.accentOverridden,
|
||||
)}
|
||||
${accentProvenance}
|
||||
</p>
|
||||
<div class="settings-group">
|
||||
@@ -368,8 +362,7 @@ export function renderAppearanceSection(
|
||||
aria-label=${label}
|
||||
aria-pressed=${String(selected)}
|
||||
title=${label}
|
||||
@click=${() =>
|
||||
preset.hex === undefined ? props.resetAccent() : props.setAccent(preset.hex)}
|
||||
@click=${() => props.setAccent(preset.hex)}
|
||||
>
|
||||
${selected
|
||||
? html`<span class="settings-accent-swatch__check" aria-hidden="true"
|
||||
@@ -399,10 +392,13 @@ export function renderAppearanceSection(
|
||||
<section id=${APPEARANCE_SETTINGS_TARGET_IDS.textSize} class="settings-section">
|
||||
<div class="settings-section__header">
|
||||
<h2 class="settings-section__heading">${t("configView.appearance.textSize")}</h2>
|
||||
<div class="settings-section__actions">${textScaleDefaultState.action}</div>
|
||||
</div>
|
||||
<p class="settings-section__desc">
|
||||
${textScaleDefaultState.description} ${t("quickSettings.personal.browserOnly")}
|
||||
${renderSettingsDefaultDescription(
|
||||
`${UI_APPEARANCE_DEFAULTS.textScale}%`,
|
||||
props.textScaleOverridden,
|
||||
)}
|
||||
${t("quickSettings.personal.browserOnly")}
|
||||
</p>
|
||||
<div class="settings-group">
|
||||
<div class="settings-row settings-row--stacked">
|
||||
|
||||
@@ -115,11 +115,8 @@ export type ConfigProps = {
|
||||
onLocaleChange: (locale: Locale | undefined) => void;
|
||||
resetLocale: () => void;
|
||||
setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void;
|
||||
resetTheme: () => void;
|
||||
setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void;
|
||||
resetThemeMode: () => void;
|
||||
setAccent: (accent: string) => void;
|
||||
resetAccent: () => void;
|
||||
setAccent: (accent: string | undefined) => void;
|
||||
hasCustomTheme: boolean;
|
||||
customThemeLabel: string | null;
|
||||
customThemeSourceUrl: string | null;
|
||||
@@ -135,7 +132,6 @@ export type ConfigProps = {
|
||||
textScale: number;
|
||||
textScaleOverridden: boolean;
|
||||
setTextScale: (value: number) => void;
|
||||
resetTextScale: () => void;
|
||||
sidebarLiveActivity: boolean;
|
||||
setSidebarLiveActivity: (enabled: boolean) => void;
|
||||
hiddenSessionCatalogIds: ReadonlySet<string>;
|
||||
|
||||
@@ -67,11 +67,8 @@ describe("config view", () => {
|
||||
onLocaleChange: vi.fn(),
|
||||
resetLocale: vi.fn(),
|
||||
setTheme: vi.fn(),
|
||||
resetTheme: vi.fn(),
|
||||
setThemeMode: vi.fn(),
|
||||
resetThemeMode: vi.fn(),
|
||||
setAccent: vi.fn(),
|
||||
resetAccent: vi.fn(),
|
||||
hasCustomTheme: false,
|
||||
customThemeLabel: null,
|
||||
customThemeSourceUrl: null,
|
||||
@@ -87,7 +84,6 @@ describe("config view", () => {
|
||||
textScale: 100,
|
||||
textScaleOverridden: false,
|
||||
setTextScale: vi.fn(),
|
||||
resetTextScale: vi.fn(),
|
||||
sidebarLiveActivity: true,
|
||||
setSidebarLiveActivity: vi.fn(),
|
||||
hiddenSessionCatalogIds: new Set<string>(),
|
||||
@@ -1642,14 +1638,12 @@ describe("config view", () => {
|
||||
expect(container.querySelector('button[aria-label="Reset to default"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("resets every explicit Appearance override independently", () => {
|
||||
it("keeps direct Appearance defaults while resetting unrelated overrides independently", () => {
|
||||
const resetLocale = vi.fn();
|
||||
const setTheme = vi.fn();
|
||||
const resetTheme = vi.fn();
|
||||
const setThemeMode = vi.fn();
|
||||
const resetThemeMode = vi.fn();
|
||||
const setAccent = vi.fn();
|
||||
const setTextScale = vi.fn();
|
||||
const resetTextScale = vi.fn();
|
||||
const setSidebarLiveActivity = vi.fn();
|
||||
const setChatMessageMaxWidth = vi.fn();
|
||||
const setChatSendShortcut = vi.fn();
|
||||
@@ -1668,15 +1662,15 @@ describe("config view", () => {
|
||||
theme: "knot",
|
||||
themeOverridden: true,
|
||||
setTheme,
|
||||
resetTheme,
|
||||
themeMode: "dark",
|
||||
themeModeOverridden: true,
|
||||
setThemeMode,
|
||||
resetThemeMode,
|
||||
accent: "#52c99a",
|
||||
accentOverridden: true,
|
||||
setAccent,
|
||||
textScale: 110,
|
||||
textScaleOverridden: true,
|
||||
setTextScale,
|
||||
resetTextScale,
|
||||
sidebarLiveActivity: false,
|
||||
setSidebarLiveActivity,
|
||||
chatMessageMaxWidth: "82%",
|
||||
@@ -1707,10 +1701,21 @@ describe("config view", () => {
|
||||
candidate.querySelector(".settings-row__title")?.textContent?.trim() === title,
|
||||
) ?? null;
|
||||
|
||||
findButtonByText(container, "Claw").click();
|
||||
const colorModeGroup = row("Color mode")?.querySelector<HTMLElement & { value: string }>(
|
||||
"wa-radio-group",
|
||||
);
|
||||
expect(colorModeGroup).toBeDefined();
|
||||
if (colorModeGroup) {
|
||||
colorModeGroup.value = "system";
|
||||
colorModeGroup.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
container.querySelector<HTMLButtonElement>('[data-accent-preset="default"]')?.click();
|
||||
Array.from(container.querySelectorAll<HTMLButtonElement>(".settings-text-scale__btn"))
|
||||
.find((button) => button.textContent?.includes("100%"))
|
||||
?.click();
|
||||
|
||||
resetIn(container.querySelector("#settings-language .settings-row"));
|
||||
resetIn(container.querySelector("#settings-appearance-theme > .settings-section__header"));
|
||||
resetIn(row("Color mode"));
|
||||
resetIn(container.querySelector("#settings-appearance-text-size > .settings-section__header"));
|
||||
resetIn(row("Show live agent activity in sidebar"));
|
||||
resetIn(row("Message width"));
|
||||
resetIn(row("Send shortcut"));
|
||||
@@ -1720,9 +1725,10 @@ describe("config view", () => {
|
||||
resetIn(row("Lobster sounds"));
|
||||
|
||||
expect(resetLocale).toHaveBeenCalledOnce();
|
||||
expect(resetTheme).toHaveBeenCalledOnce();
|
||||
expect(resetThemeMode).toHaveBeenCalledOnce();
|
||||
expect(resetTextScale).toHaveBeenCalledOnce();
|
||||
expect(setTheme).toHaveBeenCalledWith("claw", expect.any(Object));
|
||||
expect(setThemeMode).toHaveBeenCalledWith("system", expect.any(Object));
|
||||
expect(setAccent).toHaveBeenCalledWith(undefined);
|
||||
expect(setTextScale).toHaveBeenCalledWith(100);
|
||||
expect(setSidebarLiveActivity).toHaveBeenCalledWith(true);
|
||||
expect(setChatMessageMaxWidth).toHaveBeenCalledWith(undefined);
|
||||
expect(resetChatSendShortcut).toHaveBeenCalledOnce();
|
||||
@@ -1732,9 +1738,9 @@ describe("config view", () => {
|
||||
expect(setLobsterPetSounds).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it("shows reset actions for authored synced values equal to product defaults", () => {
|
||||
const resetTheme = vi.fn();
|
||||
const resetThemeMode = vi.fn();
|
||||
it("keeps authored visual defaults direct while preserving chat preference resets", () => {
|
||||
const setTheme = vi.fn();
|
||||
const setThemeMode = vi.fn();
|
||||
const resetChatSendShortcut = vi.fn();
|
||||
const { container } = renderConfigView({
|
||||
activeSection: "__appearance__",
|
||||
@@ -1742,11 +1748,11 @@ describe("config view", () => {
|
||||
theme: "claw",
|
||||
themeOverridden: true,
|
||||
themeProvenance: "synced",
|
||||
resetTheme,
|
||||
setTheme,
|
||||
themeMode: "system",
|
||||
themeModeOverridden: true,
|
||||
themeModeProvenance: "synced",
|
||||
resetThemeMode,
|
||||
setThemeMode,
|
||||
chatSendShortcut: "enter",
|
||||
chatSendShortcutOverridden: true,
|
||||
chatSendShortcutProvenance: "synced",
|
||||
@@ -1761,30 +1767,18 @@ describe("config view", () => {
|
||||
expect(normalizedText(themeSection)).toContain("Default: Claw");
|
||||
expect(normalizedText(themeSection)).toContain("Default: System");
|
||||
expect(shortcutRow?.textContent).toContain("Default: Enter");
|
||||
themeSection
|
||||
.querySelector<HTMLButtonElement>(
|
||||
":scope > .settings-section__header button[aria-label='Reset to default']",
|
||||
)
|
||||
?.click();
|
||||
const colorModeRow = Array.from(
|
||||
themeSection.querySelectorAll<HTMLElement>(".settings-row"),
|
||||
).find(
|
||||
(candidate) =>
|
||||
candidate.querySelector(".settings-row__title")?.textContent?.trim() === "Color mode",
|
||||
);
|
||||
colorModeRow
|
||||
?.querySelector<HTMLButtonElement>("button[aria-label='Reset to default']")
|
||||
?.click();
|
||||
findButtonByText(themeSection, "Claw").click();
|
||||
themeSection.querySelector<HTMLElement>('wa-radio[value="system"]')?.click();
|
||||
shortcutRow?.querySelector<HTMLButtonElement>("button[aria-label='Reset to default']")?.click();
|
||||
|
||||
expect(resetTheme).toHaveBeenCalledOnce();
|
||||
expect(resetThemeMode).toHaveBeenCalledOnce();
|
||||
expect(setTheme).toHaveBeenCalledWith("claw", expect.any(Object));
|
||||
expect(setThemeMode).toHaveBeenCalledWith("system", expect.any(Object));
|
||||
expect(resetChatSendShortcut).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders rejected theme and locale edits as resettable browser-only fallbacks", () => {
|
||||
it("renders rejected theme and locale edits as browser-only fallbacks", () => {
|
||||
const resetLocale = vi.fn();
|
||||
const resetTheme = vi.fn();
|
||||
const setTheme = vi.fn();
|
||||
const { container } = renderConfigView({
|
||||
activeSection: "__appearance__",
|
||||
includeSections: ["__appearance__"],
|
||||
@@ -1797,7 +1791,7 @@ describe("config view", () => {
|
||||
themeOverridden: true,
|
||||
themeProvenance: "device-local",
|
||||
themeResetValue: "claw",
|
||||
resetTheme,
|
||||
setTheme,
|
||||
});
|
||||
const languageRow = queryRequired(container, "#settings-language .settings-row", HTMLElement);
|
||||
const themeSection = queryRequired(container, "#settings-appearance-theme", HTMLElement);
|
||||
@@ -1825,14 +1819,10 @@ describe("config view", () => {
|
||||
).toBe("true");
|
||||
|
||||
languageRow.querySelector<HTMLButtonElement>('button[aria-label="Reset to default"]')?.click();
|
||||
themeSection
|
||||
.querySelector<HTMLButtonElement>(
|
||||
":scope > .settings-section__header button[aria-label='Reset to default']",
|
||||
)
|
||||
?.click();
|
||||
findButtonByText(themeSection, "Claw").click();
|
||||
|
||||
expect(resetLocale).toHaveBeenCalledOnce();
|
||||
expect(resetTheme).toHaveBeenCalledOnce();
|
||||
expect(setTheme).toHaveBeenCalledWith("claw", expect.any(Object));
|
||||
});
|
||||
|
||||
it("shows pending synced preferences without claiming they already synced", () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { SessionCreateParams } from "../../lib/sessions/create.ts";
|
||||
import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
|
||||
|
||||
const WORKTREE_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
@@ -32,7 +33,7 @@ export function buildDraftSessionCreateParams(draft: {
|
||||
contextWindow?: string;
|
||||
thinkingLevel?: string;
|
||||
visibility?: NewSessionVisibility;
|
||||
attachments?: unknown[];
|
||||
attachments?: SessionCreateParams["attachments"];
|
||||
projectId?: string;
|
||||
worktree: boolean;
|
||||
baseRef?: string;
|
||||
@@ -41,7 +42,7 @@ export function buildDraftSessionCreateParams(draft: {
|
||||
workspace?: string;
|
||||
catalogId?: string;
|
||||
category?: string;
|
||||
}): Record<string, unknown> {
|
||||
}): SessionCreateParams {
|
||||
const cwd = normalizeOptionalString(draft.cwd);
|
||||
const workspace = normalizeOptionalString(draft.workspace);
|
||||
const catalogId = normalizeOptionalString(draft.catalogId);
|
||||
|
||||
@@ -73,6 +73,7 @@ export class DraftGatewayState {
|
||||
private gatewaySource: ApplicationContext["gateway"] | null = null;
|
||||
private gatewayClientValue: ApplicationContext["gateway"]["snapshot"]["client"] = null;
|
||||
private gatewayUrlValue = "";
|
||||
private gatewayBootIdValue = "";
|
||||
private gatewayRecoveryScopeValue = "";
|
||||
private gatewayRecoveryScopeReady = false;
|
||||
private gatewayConnectedValue = false;
|
||||
@@ -178,6 +179,11 @@ export class DraftGatewayState {
|
||||
return this.gatewayRecoveryScopeValue;
|
||||
}
|
||||
|
||||
get sessionCreateScope(): string {
|
||||
const scope = [this.gatewayUrlValue, this.gatewayRecoveryScopeValue, this.gatewayBootIdValue];
|
||||
return scope.every(Boolean) ? JSON.stringify(scope) : "";
|
||||
}
|
||||
|
||||
get connected(): boolean {
|
||||
return this.gatewayConnectedValue;
|
||||
}
|
||||
@@ -208,6 +214,15 @@ export class DraftGatewayState {
|
||||
const snapshot = gateway.snapshot;
|
||||
const connected = snapshot.phase === "connected";
|
||||
const firstBind = this.gatewaySource === null;
|
||||
// The Gateway's idempotency ledger is process-local; a new boot cannot safely replay a start.
|
||||
const bootId = connected
|
||||
? (snapshot.hello?.server?.bootId?.trim() ?? "")
|
||||
: this.gatewayBootIdValue;
|
||||
const gatewayBootChanged =
|
||||
!firstBind &&
|
||||
connected &&
|
||||
Boolean(this.gatewayBootIdValue) &&
|
||||
bootId !== this.gatewayBootIdValue;
|
||||
const gatewayUrlChanged = !firstBind && this.gatewayUrlValue !== gateway.connection.gatewayUrl;
|
||||
const gatewaySourceChanged = !firstBind && this.gatewaySource !== gateway;
|
||||
const identityChanged =
|
||||
@@ -224,13 +239,20 @@ export class DraftGatewayState {
|
||||
this.gatewaySource = gateway;
|
||||
this.gatewayClientValue = snapshot.client;
|
||||
this.gatewayUrlValue = gateway.connection.gatewayUrl;
|
||||
this.gatewayBootIdValue = bootId;
|
||||
this.gatewayRecoveryScopeValue = recoveryScope.next;
|
||||
this.gatewayRecoveryScopeReady = snapshot.client?.recoveryScopeReady === true;
|
||||
this.gatewayConnectedValue = connected;
|
||||
if (this.read().visibility === "draft" && !this.read().canStartAsDraft) {
|
||||
this.callbacks.onVisibilityRetired();
|
||||
}
|
||||
if (gatewayUrlChanged || identityChanged || connectionChanged || recoveryScope.changed) {
|
||||
if (
|
||||
gatewayUrlChanged ||
|
||||
gatewayBootChanged ||
|
||||
identityChanged ||
|
||||
connectionChanged ||
|
||||
recoveryScope.changed
|
||||
) {
|
||||
const ownerChanged = gatewaySourceChanged || gatewayUrlChanged || recoveryScope.changed;
|
||||
const gatewayIdentityChanged = gatewayUrlChanged || recoveryScope.changed;
|
||||
this.invalidateDiscovery(
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SESSION_CREATE_RETRY_WINDOW_MS } from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import { DraftSessionStartup } from "./draft-session-startup.ts";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createStartup() {
|
||||
const gateway = { connected: true, sessionCreateScope: "gateway:principal:boot-a" };
|
||||
const startup = new DraftSessionStartup(gateway);
|
||||
const params = startup.start({ agentId: "main", message: "preserve this task" });
|
||||
return { gateway, params, startup };
|
||||
}
|
||||
|
||||
describe("DraftSessionStartup", () => {
|
||||
it("resumes the exact frozen direct-create intent once on the same Gateway scope", () => {
|
||||
const { params, startup } = createStartup();
|
||||
expect(params).toMatchObject({
|
||||
agentId: "main",
|
||||
idempotencyKey: expect.any(String),
|
||||
message: "preserve this task",
|
||||
});
|
||||
expect(Object.isFrozen(params)).toBe(true);
|
||||
expect(startup.interrupt()).toBe(true);
|
||||
|
||||
const resumed = startup.resume();
|
||||
expect(resumed).toMatchObject({ kind: "resume", params, startedAt: expect.any(Number) });
|
||||
if (resumed.kind === "resume") {
|
||||
expect(resumed.params).toBe(params);
|
||||
}
|
||||
expect(startup.resume()).toEqual({ kind: "wait" });
|
||||
});
|
||||
|
||||
it("fails closed and unlocks when the Gateway scope or process boot changes", () => {
|
||||
const { gateway, startup } = createStartup();
|
||||
startup.interrupt();
|
||||
gateway.sessionCreateScope = "gateway:principal:boot-b";
|
||||
|
||||
expect(startup.resume()).toEqual({ kind: "owner-changed" });
|
||||
expect(startup.active).toBe(false);
|
||||
});
|
||||
|
||||
it("waits for reconnection without consulting mutable draft selections", () => {
|
||||
const { gateway, params, startup } = createStartup();
|
||||
startup.interrupt();
|
||||
gateway.connected = false;
|
||||
|
||||
expect(startup.resume()).toEqual({ kind: "wait" });
|
||||
expect(startup.active).toBe(true);
|
||||
gateway.connected = true;
|
||||
expect(startup.resume()).toMatchObject({ kind: "resume", params });
|
||||
});
|
||||
|
||||
it("expires and unlocks at the shared client retry deadline", () => {
|
||||
let now = 1_000;
|
||||
vi.spyOn(Date, "now").mockImplementation(() => now);
|
||||
const { startup } = createStartup();
|
||||
startup.interrupt();
|
||||
now += SESSION_CREATE_RETRY_WINDOW_MS;
|
||||
|
||||
expect(startup.resume()).toEqual({ kind: "expired" });
|
||||
expect(startup.active).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { SESSION_CREATE_RETRY_WINDOW_MS } from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import type { SessionCreateParams } from "../../lib/sessions/create.ts";
|
||||
import { generateUUID } from "../../lib/uuid.ts";
|
||||
import type { DraftGatewayState } from "./draft-gateway-state.ts";
|
||||
|
||||
type DraftSessionStartupIntent = {
|
||||
params: SessionCreateParams;
|
||||
scope: string;
|
||||
startedAt: number;
|
||||
deadline: number;
|
||||
interrupted: boolean;
|
||||
};
|
||||
|
||||
type DraftSessionStartupResume =
|
||||
| { kind: "wait" | "expired" | "owner-changed" }
|
||||
| { kind: "resume"; params: SessionCreateParams; startedAt: number };
|
||||
|
||||
export class DraftSessionStartup {
|
||||
private pending: DraftSessionStartupIntent | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly gateway: Pick<DraftGatewayState, "connected" | "sessionCreateScope">,
|
||||
) {}
|
||||
|
||||
get active(): boolean {
|
||||
return this.pending !== null;
|
||||
}
|
||||
|
||||
start(params: SessionCreateParams): SessionCreateParams {
|
||||
const scope = this.gateway.sessionCreateScope;
|
||||
if (!scope) {
|
||||
return params;
|
||||
}
|
||||
if (!this.pending) {
|
||||
const startedAt = Date.now();
|
||||
this.pending = {
|
||||
params: Object.freeze({ ...params, idempotencyKey: generateUUID() }),
|
||||
scope,
|
||||
startedAt,
|
||||
deadline: startedAt + SESSION_CREATE_RETRY_WINDOW_MS,
|
||||
interrupted: false,
|
||||
};
|
||||
}
|
||||
return this.pending.params;
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.pending = null;
|
||||
}
|
||||
|
||||
interrupt(): boolean {
|
||||
if (!this.pending || !this.matchesGateway()) {
|
||||
this.clear();
|
||||
return false;
|
||||
}
|
||||
this.pending.interrupted = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
retireChangedOwner(): boolean {
|
||||
if (!this.pending || this.matchesGateway()) {
|
||||
return false;
|
||||
}
|
||||
this.clear();
|
||||
return true;
|
||||
}
|
||||
|
||||
resume(): DraftSessionStartupResume {
|
||||
if (!this.pending?.interrupted) {
|
||||
return { kind: "wait" };
|
||||
}
|
||||
if (Date.now() >= this.pending.deadline) {
|
||||
this.clear();
|
||||
return { kind: "expired" };
|
||||
}
|
||||
if (!this.matchesGateway()) {
|
||||
this.clear();
|
||||
return { kind: "owner-changed" };
|
||||
}
|
||||
if (!this.gateway.connected) {
|
||||
return { kind: "wait" };
|
||||
}
|
||||
this.pending.interrupted = false;
|
||||
return { kind: "resume", params: this.pending.params, startedAt: this.pending.startedAt };
|
||||
}
|
||||
|
||||
private matchesGateway(): boolean {
|
||||
return this.pending?.scope === this.gateway.sessionCreateScope;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { SESSION_CREATE_RETRY_WINDOW_MS } from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import type { ApplicationContext } from "../../app/context.ts";
|
||||
import { CHAT_ROUTE_READY_EVENT } from "../../app/route-transition.ts";
|
||||
import { writeSessionPlacementRecovery } from "../../lib/sessions/session-placement-recovery.ts";
|
||||
@@ -29,6 +30,7 @@ class ControllerHost implements ReactiveControllerHost {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
@@ -64,6 +66,7 @@ function createDraftFixture(options: FixtureOptions = {}) {
|
||||
hello:
|
||||
phase === "connected"
|
||||
? {
|
||||
server: { bootId: "gateway-boot-a" },
|
||||
auth: {
|
||||
role: "operator",
|
||||
scopes: options.scopes ?? ["operator.read", "operator.write"],
|
||||
@@ -362,6 +365,95 @@ describe("DraftSubmissionFlow submit gates", () => {
|
||||
});
|
||||
|
||||
describe("DraftSubmissionFlow", () => {
|
||||
it("replays a frozen direct create without inheriting refreshed placement or mutable submit gates", async () => {
|
||||
const { context, flow, place } = createDraftFixture({
|
||||
methods: ["sessions.create", "sessions.dispatch"],
|
||||
scopes: ["operator.admin", "operator.read", "operator.write"],
|
||||
});
|
||||
let finishOriginal!: (value: { key: string; initialRun: { status: "idle" } }) => void;
|
||||
const original = new Promise<{ key: string; initialRun: { status: "idle" } }>((resolve) => {
|
||||
finishOriginal = resolve;
|
||||
});
|
||||
const result = { key: "agent:main:direct-resumed", initialRun: { status: "idle" as const } };
|
||||
vi.mocked(context.sessions.createResult)
|
||||
.mockImplementationOnce(() => original)
|
||||
.mockResolvedValueOnce(result);
|
||||
vi.mocked(context.navigateAndWait).mockImplementation(async () => {
|
||||
queueMicrotask(() => document.dispatchEvent(new Event(CHAT_ROUTE_READY_EVENT)));
|
||||
});
|
||||
flow.setMessage("keep the original direct request");
|
||||
|
||||
const initialSubmission = flow.submit();
|
||||
await vi.waitFor(() => expect(context.sessions.createResult).toHaveBeenCalledOnce());
|
||||
const originalParams = vi.mocked(context.sessions.createResult).mock.calls[0]?.[0];
|
||||
flow.invalidate("gateway-changed");
|
||||
place.applyPendingPlacement({ agentId: "main", profileId: "new-cloud-discovery" });
|
||||
expect(flow.canSubmit()).toBe(false);
|
||||
expect(flow.submitting).toBe(true);
|
||||
|
||||
flow.resumeInterruptedSubmission();
|
||||
await vi.waitFor(() => expect(context.sessions.createResult).toHaveBeenCalledTimes(2));
|
||||
expect(vi.mocked(context.sessions.createResult).mock.calls[1]?.[0]).toEqual(originalParams);
|
||||
expect(flow.pendingPlacement.sessionKey).toBe("");
|
||||
finishOriginal(result);
|
||||
await initialSubmission;
|
||||
await vi.waitFor(() => expect(flow.submitting).toBe(false));
|
||||
});
|
||||
|
||||
it("unlocks visibly when a frozen retry loses sessions.create access", async () => {
|
||||
const { context, flow } = createDraftFixture();
|
||||
let finishOriginal!: (value: { key: string; initialRun: { status: "idle" } }) => void;
|
||||
vi.mocked(context.sessions.createResult).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishOriginal = resolve;
|
||||
}),
|
||||
);
|
||||
flow.setMessage("do not replay without authority");
|
||||
const initialSubmission = flow.submit();
|
||||
await vi.waitFor(() => expect(context.sessions.createResult).toHaveBeenCalledOnce());
|
||||
flow.invalidate("gateway-changed");
|
||||
if (context.gateway.snapshot.hello?.features) {
|
||||
context.gateway.snapshot.hello.features.methods = [];
|
||||
}
|
||||
|
||||
flow.resumeInterruptedSubmission();
|
||||
|
||||
expect(flow.error).toBeTruthy();
|
||||
expect(flow.submitting).toBe(false);
|
||||
expect(context.sessions.createResult).toHaveBeenCalledOnce();
|
||||
finishOriginal({ key: "agent:main:old", initialRun: { status: "idle" } });
|
||||
await initialSubmission;
|
||||
});
|
||||
|
||||
it("expires an interrupted direct create and unlocks with an explicit unknown outcome", async () => {
|
||||
const clock = vi.spyOn(Date, "now");
|
||||
let now = 1_000;
|
||||
clock.mockImplementation(() => now);
|
||||
const { context, flow } = createDraftFixture();
|
||||
let finishOriginal!: (value: { key: string; initialRun: { status: "idle" } }) => void;
|
||||
vi.mocked(context.sessions.createResult).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
finishOriginal = resolve;
|
||||
}),
|
||||
);
|
||||
flow.setMessage("the original outcome is unknown");
|
||||
const initialSubmission = flow.submit();
|
||||
await vi.waitFor(() => expect(context.sessions.createResult).toHaveBeenCalledOnce());
|
||||
flow.invalidate("gateway-changed");
|
||||
now += SESSION_CREATE_RETRY_WINDOW_MS;
|
||||
|
||||
flow.resumeInterruptedSubmission();
|
||||
|
||||
expect(flow.submissionOutcomeUnknown).toBe("gateway-changed");
|
||||
expect(flow.submitting).toBe(false);
|
||||
expect(context.sessions.createResult).toHaveBeenCalledOnce();
|
||||
finishOriginal({ key: "agent:main:old", initialRun: { status: "idle" } });
|
||||
await initialSubmission;
|
||||
clock.mockRestore();
|
||||
});
|
||||
|
||||
it("surfaces navigation failure after a session has already been created", async () => {
|
||||
const { context, flow } = createDraftFixture();
|
||||
vi.mocked(context.sessions.createResult).mockResolvedValue({
|
||||
@@ -729,14 +821,24 @@ describe("DraftSubmissionFlow", () => {
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ scenario: "keeps startup progress active through navigation", navigationError: null },
|
||||
{
|
||||
scenario: "keeps startup progress active through navigation",
|
||||
navigationError: null,
|
||||
canonicalSessionKey: null,
|
||||
},
|
||||
{
|
||||
scenario: "keeps placement ownership when the Gateway promotes a new session key",
|
||||
navigationError: null,
|
||||
canonicalSessionKey: "agent:cloud:dashboard:server-key",
|
||||
},
|
||||
{
|
||||
scenario: "surfaces navigation failure after placement startup commits",
|
||||
navigationError: "Placement chat route failed to load",
|
||||
canonicalSessionKey: null,
|
||||
},
|
||||
])("$scenario", async ({ navigationError }) => {
|
||||
])("$scenario", async ({ canonicalSessionKey, navigationError }) => {
|
||||
const createResult = vi.fn(async (params: Record<string, unknown>) => ({
|
||||
key: String(params.key),
|
||||
key: canonicalSessionKey ?? String(params.key),
|
||||
initialRun: { status: "idle" as const },
|
||||
}));
|
||||
const start = vi.fn(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type SessionMethodAccess,
|
||||
} from "../../lib/session-method-access.ts";
|
||||
import { openTerminalSessionInTerminal } from "../../lib/sessions/catalog-terminal.ts";
|
||||
import type { SessionCreateParams } from "../../lib/sessions/create.ts";
|
||||
import { normalizeAgentId } from "../../lib/sessions/session-key.ts";
|
||||
import type { SessionPlacementRecovery } from "../../lib/sessions/session-placement-recovery.ts";
|
||||
import {
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
projectDraftSessionPlacementRecovery,
|
||||
resolveDraftSessionPlacement,
|
||||
} from "./draft-session-placement.ts";
|
||||
import { DraftSessionStartup } from "./draft-session-startup.ts";
|
||||
import type {
|
||||
DraftSubmissionCallbacks,
|
||||
DraftSubmissionSnapshot,
|
||||
@@ -58,6 +60,7 @@ export class DraftSubmissionFlow {
|
||||
private readonly startedSession = new StartedSessionNavigation();
|
||||
error: string | null = null;
|
||||
private submitRequestToken = 0;
|
||||
private readonly sessionStartup: DraftSessionStartup;
|
||||
readonly pendingPlacement = new PendingSessionPlacementRecoveryState();
|
||||
readonly attachmentDraft: NewSessionAttachmentDraft;
|
||||
readonly composerTextarea = new NewSessionComposerTextareaController();
|
||||
@@ -69,6 +72,7 @@ export class DraftSubmissionFlow {
|
||||
private readonly read: () => DraftSubmissionSnapshot,
|
||||
private readonly callbacks: DraftSubmissionCallbacks,
|
||||
) {
|
||||
this.sessionStartup = new DraftSessionStartup(gateway);
|
||||
this.draftPersistence = new NewSessionDraftPersistence(
|
||||
() => ({
|
||||
message: this.messageValue,
|
||||
@@ -102,13 +106,23 @@ export class DraftSubmissionFlow {
|
||||
}
|
||||
|
||||
get submitting(): boolean {
|
||||
return this.submittingValue;
|
||||
return this.submittingValue || this.sessionStartup.active;
|
||||
}
|
||||
|
||||
get submissionOutcomeUnknown(): SubmissionOutcomeReason | null {
|
||||
return this.submissionOutcomeUnknownValue;
|
||||
}
|
||||
|
||||
resumeInterruptedSubmission() {
|
||||
const startup = this.sessionStartup.resume();
|
||||
if (startup.kind === "resume") {
|
||||
void this.submit(startup);
|
||||
} else if (startup.kind !== "wait") {
|
||||
this.submissionOutcomeUnknownValue = "gateway-changed";
|
||||
this.callbacks.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
setMessage(message: string) {
|
||||
this.startedSession.current = null;
|
||||
this.messageValue = message;
|
||||
@@ -209,10 +223,10 @@ export class DraftSubmissionFlow {
|
||||
private buildDraftSessionCreateParams(
|
||||
options: {
|
||||
message?: string;
|
||||
attachments?: unknown[];
|
||||
attachments?: SessionCreateParams["attachments"];
|
||||
visibility?: NewSessionVisibility;
|
||||
} = {},
|
||||
): Record<string, unknown> {
|
||||
): SessionCreateParams {
|
||||
const snapshot = this.read();
|
||||
return assembleDraftSessionCreateParams({
|
||||
agentId: this.place.agentId,
|
||||
@@ -246,22 +260,16 @@ export class DraftSubmissionFlow {
|
||||
requiredScope: "operator.write",
|
||||
});
|
||||
}
|
||||
if (!pendingPlacement || this.pendingPlacement.phase === "creating") {
|
||||
const target = this.placement().target;
|
||||
if (!target || !pendingPlacement || this.pendingPlacement.phase === "creating") {
|
||||
const createAccess = readSessionMethodAccess(gateway, {
|
||||
method: "sessions.create",
|
||||
params: createParams,
|
||||
});
|
||||
if (!createAccess.allowed || !this.placement().target) {
|
||||
if (!createAccess.allowed || !target) {
|
||||
return createAccess;
|
||||
}
|
||||
}
|
||||
const target = this.placement().target;
|
||||
if (!target) {
|
||||
return readSessionMethodAccess(gateway, {
|
||||
method: "sessions.create",
|
||||
params: createParams,
|
||||
});
|
||||
}
|
||||
return readSessionMethodAccess(gateway, {
|
||||
method: "sessions.dispatch",
|
||||
requiredScope: target.kind === "profile" ? "operator.admin" : "operator.write",
|
||||
@@ -322,7 +330,10 @@ export class DraftSubmissionFlow {
|
||||
),
|
||||
placementTargetForSubmission: () => this.placement().target,
|
||||
cloudDisabledReason: () => this.cloudDisabledReason(),
|
||||
cloudRuntimeUnsupportedReason: () => this.cloudRuntimeUnsupportedReason(),
|
||||
cloudRuntimeUnsupportedReason: () =>
|
||||
this.place.modelControl.cloudRuntimeUnsupportedReason(
|
||||
this.gateway.cloudProfiles.find((profile) => profile.id === this.place.cloudProfileId),
|
||||
),
|
||||
},
|
||||
kind,
|
||||
);
|
||||
@@ -359,7 +370,10 @@ export class DraftSubmissionFlow {
|
||||
invalidate(outcomeUnknown: SubmissionOutcomeReason | null = null) {
|
||||
this.submitRequestToken += 1;
|
||||
this.startedSession.current = null;
|
||||
if (outcomeUnknown && this.submittingValue) {
|
||||
if (
|
||||
(outcomeUnknown && this.submittingValue && !this.sessionStartup.interrupt()) ||
|
||||
this.sessionStartup.retireChangedOwner()
|
||||
) {
|
||||
this.submissionOutcomeUnknownValue = outcomeUnknown;
|
||||
}
|
||||
this.submittingValue = false;
|
||||
@@ -367,6 +381,7 @@ export class DraftSubmissionFlow {
|
||||
}
|
||||
|
||||
resetDraft() {
|
||||
this.sessionStartup.clear();
|
||||
const preservePendingPlacement = Boolean(this.pendingPlacement.sessionKey);
|
||||
this.blockedSubmitGate = null;
|
||||
this.invalidate();
|
||||
@@ -413,22 +428,27 @@ export class DraftSubmissionFlow {
|
||||
this.applyRecoveryDraft(recovery);
|
||||
}
|
||||
|
||||
async submit() {
|
||||
async submit(startup?: { params: SessionCreateParams; startedAt: number }) {
|
||||
const context = this.read().context;
|
||||
if (!context || !this.canSubmit()) {
|
||||
if (!context || (!startup && !this.canSubmit())) {
|
||||
this.noteBlockedSubmitAttempt();
|
||||
return;
|
||||
}
|
||||
this.blockedSubmitGate = null;
|
||||
const pendingPlacement = Boolean(this.pendingPlacement.sessionKey);
|
||||
const message = pendingPlacement ? this.pendingPlacement.message : this.messageValue.trim();
|
||||
const pendingPlacement = !startup && Boolean(this.pendingPlacement.sessionKey);
|
||||
const message =
|
||||
startup?.params.message ??
|
||||
(pendingPlacement ? this.pendingPlacement.message : this.messageValue.trim());
|
||||
const attachments = this.attachmentDraft.attachments;
|
||||
const apiAttachments = pendingPlacement
|
||||
? this.pendingPlacement.attachments
|
||||
: buildChatApiAttachments(attachments);
|
||||
const submissionAgentId = pendingPlacement
|
||||
? this.pendingPlacement.agentId
|
||||
: normalizeAgentId(this.place.agentId);
|
||||
const draftAttachments = startup
|
||||
? startup.params.attachments
|
||||
: pendingPlacement
|
||||
? undefined
|
||||
: buildChatApiAttachments(attachments);
|
||||
const apiAttachments = pendingPlacement ? this.pendingPlacement.attachments : draftAttachments;
|
||||
const submissionAgentId =
|
||||
startup?.params.agentId ??
|
||||
(pendingPlacement ? this.pendingPlacement.agentId : normalizeAgentId(this.place.agentId));
|
||||
const submissionGatewayUrl = pendingPlacement
|
||||
? this.pendingPlacement.gatewayUrl
|
||||
: context.gateway.connection.gatewayUrl;
|
||||
@@ -440,7 +460,7 @@ export class DraftSubmissionFlow {
|
||||
? this.pendingPlacement.recoveryScope
|
||||
: submissionClient.recoveryScope;
|
||||
const requestId = ++this.submitRequestToken;
|
||||
const submittedAt = Date.now();
|
||||
const submittedAt = startup?.startedAt ?? Date.now();
|
||||
this.submittingValue = true;
|
||||
this.error = null;
|
||||
this.place.browser.close();
|
||||
@@ -453,7 +473,7 @@ export class DraftSubmissionFlow {
|
||||
return;
|
||||
}
|
||||
this.startedSession.current = null;
|
||||
const remoteProject = pendingPlacement ? null : this.place.browser.remoteProject;
|
||||
const remoteProject = pendingPlacement || startup ? null : this.place.browser.remoteProject;
|
||||
if (remoteProject && !remoteProject.projectId && !this.place.browser.projectId) {
|
||||
const project = await submissionClient.request<ProjectsAddResult>(
|
||||
"projects.add",
|
||||
@@ -465,13 +485,17 @@ export class DraftSubmissionFlow {
|
||||
}
|
||||
this.place.browser.recordRemoteProjectId(remoteProject.cloneUrl, project.id);
|
||||
}
|
||||
const { target: placementTarget } = this.placement();
|
||||
const draftRetired = this.visibilityValue === "draft" && !this.canStartAsDraft();
|
||||
const createParams = this.buildDraftSessionCreateParams({
|
||||
message: placementTarget ? "" : message,
|
||||
visibility: draftRetired ? "normal" : this.visibilityValue,
|
||||
attachments: placementTarget ? undefined : apiAttachments,
|
||||
});
|
||||
const placementTarget = startup ? null : this.placement().target;
|
||||
const createParams =
|
||||
startup?.params ??
|
||||
this.buildDraftSessionCreateParams({
|
||||
message: placementTarget ? "" : message,
|
||||
visibility:
|
||||
this.visibilityValue === "draft" && !this.canStartAsDraft()
|
||||
? "normal"
|
||||
: this.visibilityValue,
|
||||
attachments: placementTarget ? undefined : draftAttachments,
|
||||
});
|
||||
const placementCreateParams = placementTarget
|
||||
? pendingPlacement
|
||||
? this.pendingPlacement.createParams
|
||||
@@ -486,27 +510,26 @@ export class DraftSubmissionFlow {
|
||||
persistent: this.visibilityValue !== "incognito",
|
||||
})
|
||||
: undefined;
|
||||
const requestAccess = this.submissionAccess(placementCreateParams ?? createParams);
|
||||
const requestAccess = startup
|
||||
? readSessionMethodAccess(context.gateway.snapshot, {
|
||||
method: "sessions.create",
|
||||
params: createParams,
|
||||
})
|
||||
: this.submissionAccess(placementCreateParams ?? createParams);
|
||||
if (!requestAccess.allowed) {
|
||||
this.sessionStartup.clear();
|
||||
this.error = requestAccess.reason;
|
||||
return;
|
||||
}
|
||||
if (placementTarget && !pendingPlacement && !placementCreateParams) {
|
||||
this.error = t("newSession.placementStartFailed", {
|
||||
error: "placement recovery storage is unavailable",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const submissionPlacementRecovery = placementTarget ? this.pendingPlacement.capture() : null;
|
||||
if (placementTarget && !submissionPlacementRecovery) {
|
||||
this.error = t("newSession.placementStartFailed", {
|
||||
error: "placement recovery storage is unavailable",
|
||||
});
|
||||
this.setPlacementRecoveryUnavailable();
|
||||
return;
|
||||
}
|
||||
const recoveryOwnerKey = submissionPlacementRecovery?.sessionKey ?? "";
|
||||
const ownsSubmissionRecovery = () =>
|
||||
this.pendingPlacement.owns(submissionGatewayUrl, submissionRecoveryScope, recoveryOwnerKey);
|
||||
const ownsRecovery = (sessionKey: string) =>
|
||||
this.pendingPlacement.owns(submissionGatewayUrl, submissionRecoveryScope, sessionKey);
|
||||
const ownsSubmissionRecovery = () => ownsRecovery(recoveryOwnerKey);
|
||||
const isSubmissionLifecycleCurrent = () =>
|
||||
this.read().isConnected &&
|
||||
submissionClient.recoveryScopeReady &&
|
||||
@@ -517,9 +540,10 @@ export class DraftSubmissionFlow {
|
||||
const result =
|
||||
pendingPlacement && this.pendingPlacement.phase !== "creating"
|
||||
? { key: this.pendingPlacement.sessionKey, initialRun: { status: "idle" as const } }
|
||||
: await context.sessions.createResult(placementCreateParams ?? createParams, {
|
||||
reconciliation: "background",
|
||||
});
|
||||
: await context.sessions.createResult(
|
||||
placementCreateParams ?? startup?.params ?? this.sessionStartup.start(createParams),
|
||||
{ reconciliation: "background" },
|
||||
);
|
||||
if (requestId !== this.submitRequestToken && !placementTarget) {
|
||||
return;
|
||||
}
|
||||
@@ -527,6 +551,7 @@ export class DraftSubmissionFlow {
|
||||
if (requestId !== this.submitRequestToken) {
|
||||
return;
|
||||
}
|
||||
this.sessionStartup.clear();
|
||||
this.error = context.sessions.state.error ?? t("newSession.createFailed");
|
||||
return;
|
||||
}
|
||||
@@ -553,20 +578,15 @@ export class DraftSubmissionFlow {
|
||||
if (
|
||||
submissionPlacementRecovery.phase === "creating" &&
|
||||
isSubmissionLifecycleCurrent() &&
|
||||
ownsSubmissionRecovery()
|
||||
ownsSubmissionRecovery() &&
|
||||
!this.pendingPlacement.promoteToDispatching(result.key)
|
||||
) {
|
||||
if (!this.pendingPlacement.promoteToDispatching(result.key)) {
|
||||
this.error = t("newSession.placementStartFailed", {
|
||||
error: "placement recovery storage is unavailable",
|
||||
});
|
||||
return;
|
||||
}
|
||||
this.setPlacementRecoveryUnavailable();
|
||||
return;
|
||||
}
|
||||
const recovery = this.pendingPlacement.capture();
|
||||
if (!recovery || recovery.phase === "creating") {
|
||||
this.error = t("newSession.placementStartFailed", {
|
||||
error: "placement recovery storage is unavailable",
|
||||
});
|
||||
this.setPlacementRecoveryUnavailable();
|
||||
return;
|
||||
}
|
||||
if (requestId !== this.submitRequestToken) {
|
||||
@@ -578,27 +598,13 @@ export class DraftSubmissionFlow {
|
||||
recovering: pendingPlacement,
|
||||
createdAt: submittedAt,
|
||||
});
|
||||
if (
|
||||
requestId !== this.submitRequestToken ||
|
||||
!isSubmissionLifecycleCurrent() ||
|
||||
!this.pendingPlacement.owns(
|
||||
submissionGatewayUrl,
|
||||
submissionRecoveryScope,
|
||||
recovery.sessionKey,
|
||||
)
|
||||
) {
|
||||
const ownsStartedPlacement = () =>
|
||||
isSubmissionLifecycleCurrent() && ownsRecovery(recovery.sessionKey);
|
||||
if (!ownsStartedPlacement()) {
|
||||
return;
|
||||
}
|
||||
await this.draftPersistence.clearSubmittedDraft();
|
||||
if (
|
||||
requestId !== this.submitRequestToken ||
|
||||
!isSubmissionLifecycleCurrent() ||
|
||||
!this.pendingPlacement.owns(
|
||||
submissionGatewayUrl,
|
||||
submissionRecoveryScope,
|
||||
recovery.sessionKey,
|
||||
)
|
||||
) {
|
||||
if (!ownsStartedPlacement()) {
|
||||
return;
|
||||
}
|
||||
this.pendingPlacement.reset();
|
||||
@@ -645,8 +651,10 @@ export class DraftSubmissionFlow {
|
||||
key: result.key,
|
||||
agentId: submissionAgentId,
|
||||
});
|
||||
this.sessionStartup.clear();
|
||||
} catch (error) {
|
||||
if (requestId === this.submitRequestToken && this.gateway.client === submissionClient) {
|
||||
this.sessionStartup.clear();
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
} finally {
|
||||
@@ -720,11 +728,10 @@ export class DraftSubmissionFlow {
|
||||
|
||||
private placement = () => resolveDraftSessionPlacement(this.pendingPlacement, this.place);
|
||||
|
||||
private cloudRuntimeUnsupportedReason(): string | undefined {
|
||||
const profile = this.gateway.cloudProfiles.find(
|
||||
(candidate) => candidate.id === this.place.cloudProfileId,
|
||||
);
|
||||
return this.place.modelControl.cloudRuntimeUnsupportedReason(profile);
|
||||
private setPlacementRecoveryUnavailable() {
|
||||
this.error = t("newSession.placementStartFailed", {
|
||||
error: "placement recovery storage is unavailable",
|
||||
});
|
||||
}
|
||||
|
||||
private applyRecoveryDraft(recovery: SessionPlacementRecovery) {
|
||||
|
||||
@@ -290,6 +290,7 @@ export class NewSessionPage extends OpenClawLightDomElement {
|
||||
}
|
||||
this.place.restorePreferenceSelections();
|
||||
activateDraft(this.submission, openKey);
|
||||
this.submission.resumeInterruptedSubmission();
|
||||
}
|
||||
|
||||
private invalidateGatewayDiscovery(
|
||||
@@ -594,7 +595,7 @@ export class NewSessionPage extends OpenClawLightDomElement {
|
||||
agent: this.place.selectedAgent(),
|
||||
agentId: this.place.agentId,
|
||||
attachmentDraft: this.submission.attachmentDraft,
|
||||
canSubmit: this.submission.canSubmit(),
|
||||
canSubmit: !this.submission.submitting && this.submission.canSubmit(),
|
||||
submitDisabledReason: this.submission.submitDisabledReason(),
|
||||
blockedSubmitNotice: this.submission.blockedSubmitNotice(),
|
||||
context: this.context,
|
||||
@@ -610,7 +611,7 @@ export class NewSessionPage extends OpenClawLightDomElement {
|
||||
messageLocked: Boolean(this.submission.pendingPlacement.sessionKey),
|
||||
terminalAction: this.submission.showStartInTerminal()
|
||||
? {
|
||||
canStart: this.submission.canSubmit("terminal"),
|
||||
canStart: !this.submission.submitting && this.submission.canSubmit("terminal"),
|
||||
disabledReason: this.submission.terminalStartDisabledReason(),
|
||||
onStart: () => void this.submission.startInTerminal(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user