Handle WebChat sessions.patch rejection in send/settings flows

This commit is contained in:
George Pickett
2026-02-27 14:52:46 -08:00
parent 226ca5c0ee
commit 1c9afdd4e2
6 changed files with 323 additions and 17 deletions
@@ -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",
+14
View File
@@ -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;
+155
View File
@@ -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,
+29 -1
View File
@@ -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);
});
});