mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 00:47:51 +00:00
Handle WebChat sessions.patch rejection in send/settings flows
This commit is contained in:
@@ -1,4 +1,8 @@
|
||||
import { syncGatewaySessionSettings, type GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import {
|
||||
isWebchatSessionMutationBlockedError,
|
||||
syncGatewaySessionSettings,
|
||||
type GatewayClient,
|
||||
} from "@/lib/gateway/GatewayClient";
|
||||
import {
|
||||
buildAgentInstruction,
|
||||
isMetaMarkdown,
|
||||
@@ -151,21 +155,33 @@ export async function sendChatMessageViaStudio(params: {
|
||||
|
||||
let createdSession = agent.sessionCreated;
|
||||
if (!agent.sessionSettingsSynced) {
|
||||
await syncGatewaySessionSettings({
|
||||
client: params.client as unknown as GatewayClient,
|
||||
sessionKey: params.sessionKey,
|
||||
model: agent.model ?? null,
|
||||
thinkingLevel: agent.thinkingLevel ?? null,
|
||||
execHost: agent.sessionExecHost,
|
||||
execSecurity: agent.sessionExecSecurity,
|
||||
execAsk: agent.sessionExecAsk,
|
||||
});
|
||||
createdSession = true;
|
||||
params.dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
patch: { sessionSettingsSynced: true, sessionCreated: true },
|
||||
});
|
||||
try {
|
||||
await syncGatewaySessionSettings({
|
||||
client: params.client as unknown as GatewayClient,
|
||||
sessionKey: params.sessionKey,
|
||||
model: agent.model ?? null,
|
||||
thinkingLevel: agent.thinkingLevel ?? null,
|
||||
execHost: agent.sessionExecHost,
|
||||
execSecurity: agent.sessionExecSecurity,
|
||||
execAsk: agent.sessionExecAsk,
|
||||
});
|
||||
createdSession = true;
|
||||
params.dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
patch: { sessionSettingsSynced: true, sessionCreated: true },
|
||||
});
|
||||
} catch (syncError) {
|
||||
if (!isWebchatSessionMutationBlockedError(syncError)) {
|
||||
throw syncError;
|
||||
}
|
||||
createdSession = true;
|
||||
params.dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
patch: { sessionSettingsSynced: true, sessionCreated: true },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sendResult = await params.client.call("chat.send", {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
isWebchatSessionMutationBlockedError,
|
||||
syncGatewaySessionSettings,
|
||||
type GatewayClient,
|
||||
type GatewaySessionsPatchResult,
|
||||
@@ -9,6 +10,8 @@ type SessionSettingField = "model" | "thinkingLevel";
|
||||
type AgentSessionState = {
|
||||
agentId: string;
|
||||
sessionCreated: boolean;
|
||||
model?: string | null;
|
||||
thinkingLevel?: string | null;
|
||||
};
|
||||
|
||||
type SessionSettingsDispatchAction =
|
||||
@@ -19,6 +22,7 @@ type SessionSettingsDispatchAction =
|
||||
model?: string | null;
|
||||
thinkingLevel?: string | null;
|
||||
sessionSettingsSynced?: boolean;
|
||||
sessionCreated?: boolean;
|
||||
};
|
||||
}
|
||||
| {
|
||||
@@ -45,7 +49,13 @@ const buildFallbackError = (field: SessionSettingField) =>
|
||||
const buildErrorPrefix = (field: SessionSettingField) =>
|
||||
field === "model" ? "Model update failed" : "Thinking update failed";
|
||||
|
||||
const buildWebchatBlockedMessage = (field: SessionSettingField) =>
|
||||
field === "model"
|
||||
? "Model update not applied: this gateway blocks sessions.patch for WebChat clients; message sending still works."
|
||||
: "Thinking level update not applied: this gateway blocks sessions.patch for WebChat clients; message sending still works.";
|
||||
|
||||
export const applySessionSettingMutation = async ({
|
||||
agents,
|
||||
dispatch,
|
||||
client,
|
||||
agentId,
|
||||
@@ -53,6 +63,9 @@ export const applySessionSettingMutation = async ({
|
||||
field,
|
||||
value,
|
||||
}: ApplySessionSettingMutationParams) => {
|
||||
const targetAgent = agents.find((candidate) => candidate.agentId === agentId) ?? null;
|
||||
const previousModel = targetAgent?.model ?? null;
|
||||
const previousThinkingLevel = targetAgent?.thinkingLevel ?? null;
|
||||
dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
@@ -91,6 +104,25 @@ export const applySessionSettingMutation = async ({
|
||||
patch,
|
||||
});
|
||||
} catch (err) {
|
||||
if (isWebchatSessionMutationBlockedError(err)) {
|
||||
dispatch({
|
||||
type: "updateAgent",
|
||||
agentId,
|
||||
patch: {
|
||||
...(field === "model"
|
||||
? { model: previousModel }
|
||||
: { thinkingLevel: previousThinkingLevel }),
|
||||
sessionSettingsSynced: true,
|
||||
sessionCreated: true,
|
||||
},
|
||||
});
|
||||
dispatch({
|
||||
type: "appendOutput",
|
||||
agentId,
|
||||
line: buildWebchatBlockedMessage(field),
|
||||
});
|
||||
return;
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : buildFallbackError(field);
|
||||
dispatch({
|
||||
type: "appendOutput",
|
||||
|
||||
@@ -298,6 +298,20 @@ export const isGatewayDisconnectLikeError = (err: unknown): boolean => {
|
||||
return Number.isFinite(code) && code === 1012;
|
||||
};
|
||||
|
||||
const WEBCHAT_SESSION_MUTATION_BLOCKED_RE = /webchat clients cannot (patch|delete) sessions/i;
|
||||
const WEBCHAT_SESSION_MUTATION_HINT_RE = /use chat\.send for session-scoped updates/i;
|
||||
|
||||
export const isWebchatSessionMutationBlockedError = (error: unknown): boolean => {
|
||||
if (!(error instanceof GatewayResponseError)) return false;
|
||||
if (error.code.trim().toUpperCase() !== "INVALID_REQUEST") return false;
|
||||
const message = error.message.trim();
|
||||
if (!message) return false;
|
||||
return (
|
||||
WEBCHAT_SESSION_MUTATION_BLOCKED_RE.test(message) &&
|
||||
WEBCHAT_SESSION_MUTATION_HINT_RE.test(message)
|
||||
);
|
||||
};
|
||||
|
||||
type SessionSettingsPatchPayload = {
|
||||
key: string;
|
||||
model?: string | null;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { AgentState } from "@/features/agents/state/store";
|
||||
import { sendChatMessageViaStudio } from "@/features/agents/operations/chatSendOperation";
|
||||
import { GatewayResponseError } from "@/lib/gateway/errors";
|
||||
import { formatMetaMarkdown } from "@/lib/text/message-extract";
|
||||
|
||||
const createAgent = (overrides?: Partial<AgentState>): AgentState => {
|
||||
@@ -49,6 +50,12 @@ const createAgent = (overrides?: Partial<AgentState>): AgentState => {
|
||||
};
|
||||
};
|
||||
|
||||
const createWebchatBlockedPatchError = () =>
|
||||
new GatewayResponseError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "webchat clients cannot patch sessions; use chat.send for session-scoped updates",
|
||||
});
|
||||
|
||||
describe("sendChatMessageViaStudio", () => {
|
||||
it("handles_reset_command", async () => {
|
||||
const agent = createAgent({
|
||||
@@ -134,6 +141,154 @@ describe("sendChatMessageViaStudio", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("continues_send_when_webchat_patch_is_blocked", async () => {
|
||||
const agent = createAgent({ sessionSettingsSynced: false, sessionCreated: false });
|
||||
const dispatch = vi.fn();
|
||||
const call = vi.fn(async (method: string, payload?: unknown) => {
|
||||
if (method === "sessions.patch") {
|
||||
throw createWebchatBlockedPatchError();
|
||||
}
|
||||
if (method === "chat.send") {
|
||||
const runId =
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"idempotencyKey" in payload &&
|
||||
typeof payload.idempotencyKey === "string"
|
||||
? payload.idempotencyKey
|
||||
: "run";
|
||||
return { runId, status: "started" };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: "hello",
|
||||
now: () => 1234,
|
||||
generateRunId: () => "run-1",
|
||||
});
|
||||
|
||||
const methods = call.mock.calls.map((entry) => entry[0]);
|
||||
expect(methods).toEqual(["sessions.patch", "chat.send"]);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: "updateAgent",
|
||||
agentId: agent.agentId,
|
||||
patch: { sessionSettingsSynced: true, sessionCreated: true },
|
||||
});
|
||||
|
||||
const errorLines = dispatch.mock.calls
|
||||
.map((entry) => entry[0])
|
||||
.filter(
|
||||
(
|
||||
action
|
||||
): action is {
|
||||
type: "appendOutput";
|
||||
line: string;
|
||||
} =>
|
||||
action &&
|
||||
typeof action === "object" &&
|
||||
"type" in action &&
|
||||
action.type === "appendOutput" &&
|
||||
"line" in action &&
|
||||
typeof action.line === "string" &&
|
||||
action.line.startsWith("Error:")
|
||||
)
|
||||
.map((action) => action.line);
|
||||
expect(errorLines).toEqual([]);
|
||||
});
|
||||
|
||||
it("fails_send_when_patch_error_is_not_webchat_blocked", async () => {
|
||||
const agent = createAgent({ sessionSettingsSynced: false, sessionCreated: false });
|
||||
const dispatch = vi.fn();
|
||||
const call = vi.fn(async (method: string) => {
|
||||
if (method === "sessions.patch") {
|
||||
throw new GatewayResponseError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "invalid model ref",
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: "hello",
|
||||
now: () => 1234,
|
||||
generateRunId: () => "run-1",
|
||||
});
|
||||
|
||||
const methods = call.mock.calls.map((entry) => entry[0]);
|
||||
expect(methods).toEqual(["sessions.patch"]);
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: "appendOutput",
|
||||
agentId: agent.agentId,
|
||||
line: "Error: invalid model ref",
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses_patch_retry_after_webchat_blocked_patch_error", async () => {
|
||||
let agent = createAgent({ sessionSettingsSynced: false, sessionCreated: false });
|
||||
const dispatch = vi.fn(
|
||||
(action: { type: string; agentId?: string; patch?: Partial<AgentState> }) => {
|
||||
if (action.type !== "updateAgent" || action.agentId !== agent.agentId || !action.patch) {
|
||||
return;
|
||||
}
|
||||
agent = { ...agent, ...action.patch };
|
||||
}
|
||||
);
|
||||
const call = vi.fn(async (method: string, payload?: unknown) => {
|
||||
if (method === "sessions.patch") {
|
||||
throw createWebchatBlockedPatchError();
|
||||
}
|
||||
if (method === "chat.send") {
|
||||
const runId =
|
||||
payload &&
|
||||
typeof payload === "object" &&
|
||||
"idempotencyKey" in payload &&
|
||||
typeof payload.idempotencyKey === "string"
|
||||
? payload.idempotencyKey
|
||||
: "run";
|
||||
return { runId, status: "started" };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: "first",
|
||||
now: () => 1234,
|
||||
generateRunId: () => "run-1",
|
||||
});
|
||||
await sendChatMessageViaStudio({
|
||||
client: { call },
|
||||
dispatch,
|
||||
getAgent: () => agent,
|
||||
agentId: agent.agentId,
|
||||
sessionKey: agent.sessionKey,
|
||||
message: "second",
|
||||
now: () => 1240,
|
||||
generateRunId: () => "run-2",
|
||||
});
|
||||
|
||||
const methods = call.mock.calls.map((entry) => entry[0]);
|
||||
expect(methods.filter((method) => method === "sessions.patch")).toHaveLength(1);
|
||||
expect(methods.filter((method) => method === "chat.send")).toHaveLength(2);
|
||||
expect(agent.sessionSettingsSynced).toBe(true);
|
||||
expect(agent.sessionCreated).toBe(true);
|
||||
});
|
||||
|
||||
it("syncs exec session overrides for ask-first agents", async () => {
|
||||
const agent = createAgent({
|
||||
sessionSettingsSynced: false,
|
||||
|
||||
@@ -1,9 +1,37 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { syncGatewaySessionSettings } from "@/lib/gateway/GatewayClient";
|
||||
import {
|
||||
isWebchatSessionMutationBlockedError,
|
||||
syncGatewaySessionSettings,
|
||||
} from "@/lib/gateway/GatewayClient";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { GatewayResponseError } from "@/lib/gateway/errors";
|
||||
|
||||
describe("session settings sync helper", () => {
|
||||
it("detects webchat session mutation blocked gateway errors", () => {
|
||||
const blocked = new GatewayResponseError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "webchat clients cannot patch sessions; use chat.send for session-scoped updates",
|
||||
});
|
||||
expect(isWebchatSessionMutationBlockedError(blocked)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not misclassify unrelated invalid request errors", () => {
|
||||
const invalid = new GatewayResponseError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "invalid model ref",
|
||||
});
|
||||
expect(isWebchatSessionMutationBlockedError(invalid)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not misclassify non-gateway errors", () => {
|
||||
expect(
|
||||
isWebchatSessionMutationBlockedError(
|
||||
new Error("webchat clients cannot patch sessions; use chat.send for session-scoped updates")
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("throws when session key is missing", async () => {
|
||||
const client = { call: vi.fn() } as unknown as GatewayClient;
|
||||
await expect(
|
||||
|
||||
@@ -2,6 +2,13 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { applySessionSettingMutation } from "@/features/agents/state/sessionSettingsMutations";
|
||||
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
|
||||
import { GatewayResponseError } from "@/lib/gateway/errors";
|
||||
|
||||
const createWebchatBlockedPatchError = () =>
|
||||
new GatewayResponseError({
|
||||
code: "INVALID_REQUEST",
|
||||
message: "webchat clients cannot patch sessions; use chat.send for session-scoped updates",
|
||||
});
|
||||
|
||||
describe("session settings mutations helper", () => {
|
||||
it("applies optimistic update before remote sync", async () => {
|
||||
@@ -127,4 +134,58 @@ describe("session settings mutations helper", () => {
|
||||
line: "Model update failed: network timeout",
|
||||
});
|
||||
});
|
||||
|
||||
it("restores sync state and appends capability notice when webchat patch is blocked", async () => {
|
||||
const dispatch = vi.fn();
|
||||
const client = {
|
||||
call: vi.fn(async () => {
|
||||
throw createWebchatBlockedPatchError();
|
||||
}),
|
||||
} as unknown as GatewayClient;
|
||||
|
||||
await applySessionSettingMutation({
|
||||
agents: [{ agentId: "agent-1", sessionCreated: true, model: "openai/gpt-5-mini" }],
|
||||
dispatch,
|
||||
client,
|
||||
agentId: "agent-1",
|
||||
sessionKey: "agent:1:studio:abc",
|
||||
field: "model",
|
||||
value: "openai/gpt-5",
|
||||
});
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: "updateAgent",
|
||||
agentId: "agent-1",
|
||||
patch: {
|
||||
model: "openai/gpt-5-mini",
|
||||
sessionSettingsSynced: true,
|
||||
sessionCreated: true,
|
||||
},
|
||||
});
|
||||
expect(dispatch).toHaveBeenCalledWith({
|
||||
type: "appendOutput",
|
||||
agentId: "agent-1",
|
||||
line:
|
||||
"Model update not applied: this gateway blocks sessions.patch for WebChat clients; message sending still works.",
|
||||
});
|
||||
|
||||
const failureLines = dispatch.mock.calls
|
||||
.map((entry) => entry[0])
|
||||
.filter(
|
||||
(
|
||||
action
|
||||
): action is {
|
||||
type: "appendOutput";
|
||||
line: string;
|
||||
} =>
|
||||
action &&
|
||||
typeof action === "object" &&
|
||||
"type" in action &&
|
||||
action.type === "appendOutput" &&
|
||||
"line" in action &&
|
||||
typeof action.line === "string" &&
|
||||
action.line.startsWith("Model update failed:")
|
||||
);
|
||||
expect(failureLines).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user