From 1c9afdd4e2d5f873cf2e24043e3bdde19001f671 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Fri, 27 Feb 2026 14:52:46 -0800 Subject: [PATCH] Handle WebChat sessions.patch rejection in send/settings flows --- .../agents/operations/chatSendOperation.ts | 48 ++++-- .../agents/state/sessionSettingsMutations.ts | 32 ++++ src/lib/gateway/GatewayClient.ts | 14 ++ tests/unit/chatSendOperation.test.ts | 155 ++++++++++++++++++ tests/unit/sessionSettings.test.ts | 30 +++- tests/unit/sessionSettingsMutations.test.ts | 61 +++++++ 6 files changed, 323 insertions(+), 17 deletions(-) diff --git a/src/features/agents/operations/chatSendOperation.ts b/src/features/agents/operations/chatSendOperation.ts index 27529b9..09b937c 100644 --- a/src/features/agents/operations/chatSendOperation.ts +++ b/src/features/agents/operations/chatSendOperation.ts @@ -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", { diff --git a/src/features/agents/state/sessionSettingsMutations.ts b/src/features/agents/state/sessionSettingsMutations.ts index 075bdd0..ed35628 100644 --- a/src/features/agents/state/sessionSettingsMutations.ts +++ b/src/features/agents/state/sessionSettingsMutations.ts @@ -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", diff --git a/src/lib/gateway/GatewayClient.ts b/src/lib/gateway/GatewayClient.ts index 38d0f42..14d9654 100644 --- a/src/lib/gateway/GatewayClient.ts +++ b/src/lib/gateway/GatewayClient.ts @@ -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; diff --git a/tests/unit/chatSendOperation.test.ts b/tests/unit/chatSendOperation.test.ts index 432a29a..52ff2a0 100644 --- a/tests/unit/chatSendOperation.test.ts +++ b/tests/unit/chatSendOperation.test.ts @@ -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 => { @@ -49,6 +50,12 @@ const createAgent = (overrides?: Partial): 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 }) => { + 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, diff --git a/tests/unit/sessionSettings.test.ts b/tests/unit/sessionSettings.test.ts index 4e2bef8..7b8980d 100644 --- a/tests/unit/sessionSettings.test.ts +++ b/tests/unit/sessionSettings.test.ts @@ -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( diff --git a/tests/unit/sessionSettingsMutations.test.ts b/tests/unit/sessionSettingsMutations.test.ts index c67f722..05b91a3 100644 --- a/tests/unit/sessionSettingsMutations.test.ts +++ b/tests/unit/sessionSettingsMutations.test.ts @@ -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); + }); });