diff --git a/docs/platformclaw/web-ingress-runtime.md b/docs/platformclaw/web-ingress-runtime.md index f010c4e9a7..f85e7b3f10 100644 --- a/docs/platformclaw/web-ingress-runtime.md +++ b/docs/platformclaw/web-ingress-runtime.md @@ -24,6 +24,26 @@ in `plugins.entries.admin-http-rpc.config.composerPredictionsEnabled`, then rest the development Gateway. Each employee can enable or disable their own preference; it persists in the plugin's SQLite keyed store without a schema-version change. +Set `composerPredictionsTimeoutMs` in the same plugin config to control how long a +prediction may wait. The default is 30,000 ms; supported values are integers from +1,000 to 300,000 ms. For example, merge these values into the existing +`plugins.entries.admin-http-rpc.config` object: + +```json +{ + "composerPredictionsEnabled": true, + "composerPredictionsTimeoutMs": 60000 +} +``` + +The server gate and each employee's preference still control whether predictions +run. The timeout does not enable the feature or change saved account preferences. +The Gateway advertises its configured deadline to the Web UI and BFF; inference +and its internal HTTP transport use that budget instead of separate short limits. +Settings lookups retain a separate ten-second control-request deadline. +Refresh the Web page after updating the Gateway config so it reads the new budget. +An external reverse proxy must also allow responses to wait for that budget. + The browser waits one second after a completed response while the composer is empty. It requests `/platformclaw/api/composer-predictions` using the employee cookie and same-origin policy. The BFF reads history through the existing personal @@ -55,15 +75,17 @@ Normal conversation defaults and fallback settings remain unchanged. Inference receives only the last six user/assistant text messages, each capped at 300 Unicode characters. This bounded context supports two or three exchanges without copying attachments, reasoning, tool output, or an unbounded transcript. -Output is capped at 64 tokens and 300 characters. The whole browser/BFF request -has a ten-second deadline, including Gateway history access. There is one active +Output is capped at 64 tokens and 300 characters. The browser/BFF prediction deadline +includes Gateway history access. There is one active prediction per Gateway process and no prediction queue. Ordinary model calls preempt predictions. No vLLM load metrics are assumed; the single-slot policy is the conservative admission control. A multi-Gateway deployment needs a shared admission owner before enabling this feature across instances. Tab inserts Ghost Text without sending. Escape, typing, IME composition, session -changes, and a new response invalidate it. Enter retains its existing behavior. +changes, and a new response invalidate it. Sending a new browser message cancels +pending prediction work immediately, before waiting for the send acknowledgment +or the next model call. Enter retains its existing behavior. Cancellation closes the HTTP request to the model; actual GPU cancellation depends on the company's serving stack and must be measured there. A server that continues inference after disconnect can still consume up to the 64-token diff --git a/extensions/admin-http-rpc/index.test.ts b/extensions/admin-http-rpc/index.test.ts index 10b7404cb8..74d1956039 100644 --- a/extensions/admin-http-rpc/index.test.ts +++ b/extensions/admin-http-rpc/index.test.ts @@ -1,5 +1,6 @@ // Admin Http Rpc tests cover index plugin behavior. import { createServer } from "node:http"; +import { validateJsonSchemaValue } from "openclaw/plugin-sdk/json-schema-runtime"; import { afterEach, describe, expect, it, vi } from "vitest"; import plugin from "./index.js"; import manifest from "./openclaw.plugin.json" with { type: "json" }; @@ -22,6 +23,36 @@ afterEach(() => { }); describe("admin-http-rpc plugin entry", () => { + const validateConfig = (value: unknown) => + validateJsonSchemaValue({ + schema: manifest.configSchema, + cacheKey: "admin-http-rpc.timeout-contract", + value, + applyDefaults: true, + }); + + it("defaults the inference timeout while retaining explicit server opt-in", () => { + expect(validateConfig({})).toEqual({ + ok: true, + value: { composerPredictionsEnabled: false, composerPredictionsTimeoutMs: 30_000 }, + }); + expect(validateConfig({ composerPredictionsEnabled: true })).toEqual({ + ok: true, + value: { composerPredictionsEnabled: true, composerPredictionsTimeoutMs: 30_000 }, + }); + }); + + it.each([1_000, 300_000])("accepts the configured timeout boundary %i", (timeoutMs) => { + expect(validateConfig({ composerPredictionsTimeoutMs: timeoutMs })).toEqual({ + ok: true, + value: { composerPredictionsEnabled: false, composerPredictionsTimeoutMs: timeoutMs }, + }); + }); + + it.each([999, 300_001, 1_000.5, "30000"])("rejects an invalid timeout %j", (timeoutMs) => { + expect(validateConfig({ composerPredictionsTimeoutMs: timeoutMs }).ok).toBe(false); + }); + it("stays startup-off until the plugin entry is explicitly enabled", () => { expect(manifest.activation).toEqual({ onStartup: false, @@ -156,7 +187,14 @@ describe("admin-http-rpc plugin entry", () => { config: { current: () => ({ plugins: { - entries: { "admin-http-rpc": { config: { composerPredictionsEnabled: true } } }, + entries: { + "admin-http-rpc": { + config: { + composerPredictionsEnabled: true, + composerPredictionsTimeoutMs: 45_000, + }, + }, + }, }, models: { providers: { @@ -210,7 +248,11 @@ describe("admin-http-rpc plugin entry", () => { expect(response.ok).toBe(true); return response.json(); }; - await post({ action: "preferences", userId: "user-1", enabled: true }); + expect(await post({ action: "preferences", userId: "user-1", enabled: true })).toEqual({ + id: expect.any(String), + ok: true, + payload: { available: true, enabled: true, timeoutMs: 45_000 }, + }); expect(stores.get("platformclaw.composer-preferences")?.get("user-1")).toBe(true); expect(stores.get("platformclaw.employee-profiles")?.has("user-1")).toBe(false); mocks.lookup.mockResolvedValue([{ address: "10.1.2.3", family: 4 }]); diff --git a/extensions/admin-http-rpc/openclaw.plugin.json b/extensions/admin-http-rpc/openclaw.plugin.json index f52a9d7a9e..3f8ff76c37 100644 --- a/extensions/admin-http-rpc/openclaw.plugin.json +++ b/extensions/admin-http-rpc/openclaw.plugin.json @@ -17,6 +17,13 @@ "type": "boolean", "default": false, "description": "Enable Web-only, account-opt-in predictions using the model selected by PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL." + }, + "composerPredictionsTimeoutMs": { + "type": "integer", + "default": 30000, + "minimum": 1000, + "maximum": 300000, + "description": "Maximum duration in milliseconds for composer prediction inference. Normal chat retains priority." } } } diff --git a/extensions/admin-http-rpc/src/composer-predictions.test.ts b/extensions/admin-http-rpc/src/composer-predictions.test.ts index 0c01817a28..9a14393c78 100644 --- a/extensions/admin-http-rpc/src/composer-predictions.test.ts +++ b/extensions/admin-http-rpc/src/composer-predictions.test.ts @@ -11,8 +11,12 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ function setup(providerId = "codemate", modelId = "codellm-fast", apiType = "openai-completions") { const values = new Map(); + const pluginConfig: { + composerPredictionsEnabled?: boolean; + composerPredictionsTimeoutMs?: number; + } = { composerPredictionsEnabled: true }; const config = { - plugins: { entries: { "admin-http-rpc": { config: { composerPredictionsEnabled: true } } } }, + plugins: { entries: { "admin-http-rpc": { config: pluginConfig } } }, models: { providers: { [providerId]: { @@ -52,7 +56,20 @@ function setup(providerId = "codemate", modelId = "codellm-fast", apiType = "ope ], }; const predict = (signal = new AbortController().signal) => call(params, signal); - return { call, predict, composer, values, params }; + return { call, predict, composer, values, params, pluginConfig }; +} + +function useDeadlineClock() { + vi.useFakeTimers(); + // Node's native AbortSignal.timeout bypasses the global timers replaced by Vitest. + return vi.spyOn(AbortSignal, "timeout").mockImplementation((milliseconds) => { + const deadline = new AbortController(); + setTimeout( + () => deadline.abort(new DOMException("Prediction timed out", "TimeoutError")), + milliseconds, + ); + return deadline.signal; + }); } beforeEach(() => { vi.stubEnv("PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL", undefined); @@ -61,6 +78,7 @@ beforeEach(() => { afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks(); + vi.restoreAllMocks(); vi.useRealTimers(); }); @@ -69,7 +87,16 @@ describe("Explicit isolated prediction inference", () => { const { call, predict, params } = setup(); expect(await predict()).toEqual({ text: null }); const signal = new AbortController().signal; - await call({ action: "preferences", userId: "user-1", enabled: true }, signal); + expect(await call({ action: "preferences", userId: "user-1" }, signal)).toEqual({ + available: true, + enabled: false, + timeoutMs: 30_000, + }); + expect(await call({ action: "preferences", userId: "user-1", enabled: true }, signal)).toEqual({ + available: true, + enabled: true, + timeoutMs: 30_000, + }); expect(await call({ ...params, userId: "user-2" }, signal)).toEqual({ text: null }); expect(await predict()).toEqual({ text: "다음 단계는 무엇인가요?" }); const request = mocks.fetch.mock.calls[0]?.[0]; @@ -79,6 +106,129 @@ describe("Explicit isolated prediction inference", () => { expect(request.capture).toBe(false); expect(mocks.fetch).toHaveBeenCalledTimes(1); }); + it.each([ + { + name: "omitted server gate", + server: undefined, + account: true, + available: false, + enabled: true, + }, + { name: "disabled server gate", server: false, account: true, available: false, enabled: true }, + { + name: "missing account preference", + server: true, + account: undefined, + available: true, + enabled: false, + }, + { + name: "disabled account preference", + server: true, + account: false, + available: true, + enabled: false, + }, + ])( + "keeps $name and returns the configured timeout", + async ({ server, account, available, enabled }) => { + const { call, predict, values, pluginConfig } = setup(); + pluginConfig.composerPredictionsEnabled = server; + pluginConfig.composerPredictionsTimeoutMs = 45_000; + if (account !== undefined) { + values.set("user-1", account); + } + expect( + await call({ action: "preferences", userId: "user-1" }, new AbortController().signal), + ).toEqual({ + available, + enabled, + timeoutMs: 45_000, + }); + expect(await predict()).toEqual({ text: null }); + expect(mocks.fetch).not.toHaveBeenCalled(); + }, + ); + it("allows a configured inference to finish beyond the former ten-second deadline", async () => { + const timeout = useDeadlineClock(); + const { predict, values, pluginConfig } = setup(); + pluginConfig.composerPredictionsTimeoutMs = 45_000; + values.set("user-1", true); + let upstreamSignal: AbortSignal | undefined; + mocks.fetch.mockImplementationOnce( + ({ signal }: { signal: AbortSignal }) => + new Promise<{ response: Response; release: () => Promise }>((resolve, reject) => { + upstreamSignal = signal; + const timer = setTimeout(() => { + resolve({ + response: Response.json({ + choices: [{ message: { content: "다음 단계는 무엇인가요?" } }], + }), + release: async () => {}, + }); + }, 15_000); + signal.addEventListener( + "abort", + () => { + clearTimeout(timer); + reject(new Error("aborted")); + }, + { once: true }, + ); + }), + ); + const pending = predict(); + await vi.advanceTimersByTimeAsync(15_000); + expect(await pending).toEqual({ text: "다음 단계는 무엇인가요?" }); + expect(upstreamSignal?.aborted).toBe(false); + expect(timeout).toHaveBeenCalledWith(45_000); + }); + it("aborts at the configured deadline and releases prediction admission", async () => { + useDeadlineClock(); + const { predict, values, pluginConfig } = setup(); + pluginConfig.composerPredictionsTimeoutMs = 12_000; + values.set("user-1", true); + let upstreamSignal: AbortSignal | undefined; + mocks.fetch.mockImplementationOnce( + ({ signal }: { signal: AbortSignal }) => + new Promise((_resolve, reject) => { + upstreamSignal = signal; + signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + }), + ); + const pending = predict(); + await vi.advanceTimersByTimeAsync(11_999); + expect(upstreamSignal?.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(upstreamSignal?.aborted).toBe(true); + expect(await pending).toEqual({ text: null }); + expect(await predict()).toEqual({ text: "다음 단계는 무엇인가요?" }); + expect(mocks.fetch).toHaveBeenCalledTimes(2); + }); + it("releases an interrupted response stream and admits the next prediction", async () => { + const { predict, values } = setup(); + values.set("user-1", true); + const release = vi.fn(async () => {}); + let upstreamResponse: Response | undefined; + mocks.fetch.mockImplementationOnce(async ({ signal }: { signal: AbortSignal }) => { + upstreamResponse = new Response( + new ReadableStream({ + start(controller) { + signal.addEventListener("abort", () => controller.error(signal.reason), { once: true }); + }, + }), + ); + return { response: upstreamResponse, release }; + }); + const cancellation = new AbortController(); + const pending = predict(cancellation.signal); + await vi.waitFor(() => expect(upstreamResponse?.body?.locked).toBe(true)); + cancellation.abort(); + expect(await pending).toEqual({ text: null }); + expect(release).toHaveBeenCalledTimes(1); + expect(await predict()).toEqual({ text: "다음 단계는 무엇인가요?" }); + expect(mocks.fetch).toHaveBeenCalledTimes(2); + }); it("uses the configured provider/model and pins it until plugin recreation", async () => { vi.stubEnv("PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL", " company-fast/org/followup-v2 "); const { predict, values } = setup("company-fast", "org/followup-v2"); diff --git a/extensions/admin-http-rpc/src/composer-predictions.ts b/extensions/admin-http-rpc/src/composer-predictions.ts index a86e67eda5..e56f6f5e10 100644 --- a/extensions/admin-http-rpc/src/composer-predictions.ts +++ b/extensions/admin-http-rpc/src/composer-predictions.ts @@ -6,6 +6,7 @@ import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; export const COMPOSER_RPC = "platformclaw.composer"; const DEFAULT_MODEL_REF = "codemate/codellm-fast"; +const DEFAULT_TIMEOUT_MS = 30_000; const SYSTEM_PROMPT = [ "Predict one short, useful next message the USER might type in this conversation.", "Write in the user's voice and language (Korean when appropriate). Return only one concise question.", @@ -38,6 +39,12 @@ export function createComposerPredictions(api: OpenClawPluginApi) { const enabled = () => api.runtime.config.current().plugins?.entries?.["admin-http-rpc"]?.config ?.composerPredictionsEnabled === true; + const timeoutMs = () => { + // The manifest validates configured values; omitted keys keep the deployment default. + const configured = api.runtime.config.current().plugins?.entries?.["admin-http-rpc"]?.config + ?.composerPredictionsTimeoutMs as number | undefined; + return configured ?? DEFAULT_TIMEOUT_MS; + }; const handleRequest = async (params: unknown, signal: AbortSignal): Promise => { if (!isRecord(params) || typeof params.userId !== "string" || !params.userId) { throw new Error("Invalid composer request"); @@ -47,7 +54,11 @@ export function createComposerPredictions(api: OpenClawPluginApi) { if (typeof params.enabled === "boolean") { await preferences.register(userId, params.enabled); } - return { available: enabled(), enabled: (await preferences.lookup(userId)) === true }; + return { + available: enabled(), + enabled: (await preferences.lookup(userId)) === true, + timeoutMs: timeoutMs(), + }; } if (action !== "predict" || !enabled() || (await preferences.lookup(userId)) !== true) { return { text: null }; @@ -73,7 +84,7 @@ export function createComposerPredictions(api: OpenClawPluginApi) { } const controller = new AbortController(); active = controller; - const combined = AbortSignal.any([signal, controller.signal, AbortSignal.timeout(10_000)]); + const combined = AbortSignal.any([signal, controller.signal, AbortSignal.timeout(timeoutMs())]); try { if (!modelRef) { return { text: null }; diff --git a/packages/platformclaw-control-plane/src/browser-composer-http.test.ts b/packages/platformclaw-control-plane/src/browser-composer-http.test.ts index 7cbfe2b581..d9ced40f36 100644 --- a/packages/platformclaw-control-plane/src/browser-composer-http.test.ts +++ b/packages/platformclaw-control-plane/src/browser-composer-http.test.ts @@ -36,7 +36,7 @@ async function setup( const call = vi.fn(async (_method: string, params: unknown) => { const request = params as { action: string }; return request.action === "preferences" - ? { available: true, enabled: true } + ? { available: true, enabled: true, timeoutMs: 30_000 } : { text: "다음 질문" }; }); const handler = createBrowserComposerHandler({ @@ -66,7 +66,7 @@ async function setup( if (!address || typeof address === "string") { throw new Error("missing HTTP address"); } - const post = (body: unknown, headers: Record = {}) => + const post = (body: unknown, headers: Record = {}, signal?: AbortSignal) => fetch(`http://127.0.0.1:${address.port}/platformclaw/api/composer-predictions`, { method: "POST", headers: { @@ -76,6 +76,7 @@ async function setup( ...headers, }, body: JSON.stringify(body), + signal, }); return { ...fixture, call, post }; } @@ -96,6 +97,7 @@ describe("authenticated Web-only prediction ingress", () => { ], }, expect.any(AbortSignal), + { timeoutMs: 30_000 }, ); }); it("blocks non-browser callers, foreign sessions, and client-supplied identity/context", async () => { @@ -156,12 +158,13 @@ describe("authenticated Web-only prediction ingress", () => { f.store.spaces.setMember(f.bob.user.id, f.space.id, f.alice.user.id, null, 2); return { text: "must not leak" }; } - return { available: true, enabled: true }; + return { available: true, enabled: true, timeoutMs: 30_000 }; }); expect(await (await post(body)).json()).toEqual({ text: null }); }); - it("caps the whole request at ten seconds even when Gateway history is waiting", async () => { + it("caps the whole request at the configured deadline while Gateway history is waiting", async () => { const { post, request, call, binding } = await setup(); + call.mockResolvedValueOnce({ available: true, enabled: true, timeoutMs: 1000 } as never); let release!: (value: unknown) => void; request.mockImplementationOnce( () => @@ -169,14 +172,65 @@ describe("authenticated Web-only prediction ingress", () => { release = resolve; }), ); - const started = performance.now(); expect( await (await post({ action: "predict", sessionKey: `agent:${binding.agentId}:main` })).json(), ).toEqual({ text: null }); - expect(performance.now() - started).toBeLessThan(11_000); release({ messages: [] }); expect( call.mock.calls.some(([, value]) => (value as { action: string }).action === "predict"), ).toBe(false); + }); + it("allows authorized history to finish after the old ten-second deadline", async () => { + const { post, request, call, binding } = await setup(); + request.mockImplementationOnce( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve({ + messages: [ + { role: "user", content: "질문" }, + { role: "assistant", content: "답변" }, + ], + }), + 10_100, + ); + }), + ); + expect( + await (await post({ action: "predict", sessionKey: `agent:${binding.agentId}:main` })).json(), + ).toEqual({ + text: "다음 질문", + }); + expect(call).toHaveBeenCalledWith( + "platformclaw.composer", + expect.objectContaining({ action: "predict" }), + expect.any(AbortSignal), + { timeoutMs: 30_000 }, + ); }, 15_000); + it("aborts model work when the browser cancels its prediction request", async () => { + const { post, call, binding } = await setup(); + let inferenceSignal: AbortSignal | undefined; + call.mockImplementation(async (_method, value, ...args: unknown[]) => { + if ((value as { action: string }).action === "preferences") { + return { available: true, enabled: true, timeoutMs: 30_000 }; + } + const signal = args[0] as AbortSignal; + inferenceSignal = signal; + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(new Error("cancelled")), { once: true }); + }); + }); + const controller = new AbortController(); + const pending = post( + { action: "predict", sessionKey: `agent:${binding.agentId}:main` }, + {}, + controller.signal, + ); + await vi.waitFor(() => expect(inferenceSignal).toBeDefined()); + controller.abort(); + await expect(pending).rejects.toMatchObject({ name: "AbortError" }); + await vi.waitFor(() => expect(inferenceSignal?.aborted).toBe(true)); + }); }); diff --git a/packages/platformclaw-control-plane/src/browser-composer-http.ts b/packages/platformclaw-control-plane/src/browser-composer-http.ts index e82d3e866d..ef27e3e004 100644 --- a/packages/platformclaw-control-plane/src/browser-composer-http.ts +++ b/packages/platformclaw-control-plane/src/browser-composer-http.ts @@ -66,10 +66,13 @@ export function createBrowserComposerHandler(options: { } const controller = new AbortController(); const cancel = () => controller.abort(); - const timer = setTimeout(() => { + const startedAt = performance.now(); + const expire = () => { cancel(); sendBrowserJson(res, 200, { text: null }); - }, 10_000); + }; + // Settings lookup stays bounded until the Gateway supplies its prediction budget. + let timer = setTimeout(expire, 10_000); req.once("aborted", cancel); res.once("close", cancel); try { @@ -117,15 +120,24 @@ export function createBrowserComposerHandler(options: { sendBrowserJson(res, 400, { error: "invalid prediction request" }); return true; } - const preference = await options.adminRpc.call<{ available: boolean; enabled: boolean }>( - "platformclaw.composer", - { action: "preferences", userId }, - signal, - ); + const preference = await options.adminRpc.call<{ + available: boolean; + enabled: boolean; + timeoutMs: number; + }>("platformclaw.composer", { action: "preferences", userId }, signal); if (!preference.available || !preference.enabled) { sendBrowserJson(res, 200, { text: null }); return true; } + signal.throwIfAborted(); + clearTimeout(timer); + // Include authentication/settings time; nested transports must not restart the budget. + const remainingMs = preference.timeoutMs - (performance.now() - startedAt); + if (remainingMs <= 0) { + expire(); + return true; + } + timer = setTimeout(expire, remainingMs); const sessionKey = params.sessionKey; const load = async () => conversationText( @@ -148,6 +160,7 @@ export function createBrowserComposerHandler(options: { "platformclaw.composer", { action: "predict", userId, messages }, signal, + { timeoutMs: preference.timeoutMs }, ); // Recheck ACL and the authoritative transcript after inference, including Space membership. const current = await load(); diff --git a/packages/platformclaw-control-plane/src/gateway-admin-rpc-client.test.ts b/packages/platformclaw-control-plane/src/gateway-admin-rpc-client.test.ts index 755cc353fa..9c12f93e05 100644 --- a/packages/platformclaw-control-plane/src/gateway-admin-rpc-client.test.ts +++ b/packages/platformclaw-control-plane/src/gateway-admin-rpc-client.test.ts @@ -99,6 +99,21 @@ describe("HttpGatewayAdminRpcClient", () => { expect(timeout.mock.calls.map(([timeoutMs]) => timeoutMs)).toEqual([240_000, 240_000]); }); + it("uses the caller's operation budget without changing the default for sibling calls", async () => { + const timeout = vi.spyOn(AbortSignal, "timeout"); + const { client } = createClient((request) => ({ + body: { id: request.id, ok: true, payload: {} }, + })); + await client.call("platformclaw.composer", { action: "predict" }, undefined, { + timeoutMs: 60_000, + }); + await client.call("agents.list", {}); + expect(timeout.mock.calls.map(([timeoutMs]) => timeoutMs)).toEqual([60_000, 15_000]); + await expect(client.call("agents.list", {}, undefined, { timeoutMs: 0.5 })).rejects.toThrow( + "Gateway Admin RPC timeout must be an integer from 1 to 4294967295", + ); + }); + it("returns bounded Gateway errors without exposing response bodies", async () => { const { client } = createClient((request) => ({ status: 400, diff --git a/packages/platformclaw-control-plane/src/gateway-admin-rpc-client.ts b/packages/platformclaw-control-plane/src/gateway-admin-rpc-client.ts index 1fff84ed7f..4ebf7dbd56 100644 --- a/packages/platformclaw-control-plane/src/gateway-admin-rpc-client.ts +++ b/packages/platformclaw-control-plane/src/gateway-admin-rpc-client.ts @@ -15,7 +15,18 @@ export type GatewayAdminRpcClientConfig = { }; export interface GatewayAdminRpc { - call(method: string, params: unknown, signal?: AbortSignal): Promise; + call( + method: string, + params: unknown, + signal?: AbortSignal, + options?: { timeoutMs?: number }, + ): Promise; +} + +function validateRpcTimeoutMs(timeoutMs: number): void { + if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_ABORT_SIGNAL_TIMEOUT_MS) { + throw new Error("Gateway Admin RPC timeout must be an integer from 1 to 4294967295"); + } } type RpcErrorBody = { @@ -164,19 +175,24 @@ export class HttpGatewayAdminRpcClient implements GatewayAdminRpc { throw new Error("Gateway Admin RPC bearer token is required"); } const timeoutMs = config.timeoutMs ?? DEFAULT_GATEWAY_ADMIN_RPC_TIMEOUT_MS; - if (!Number.isInteger(timeoutMs) || timeoutMs <= 0 || timeoutMs > MAX_ABORT_SIGNAL_TIMEOUT_MS) { - throw new Error("Gateway Admin RPC timeout must be an integer from 1 to 4294967295"); - } + validateRpcTimeoutMs(timeoutMs); this.config = { rpcUrl: normalizeRpcUrl(config.rpcUrl), bearerToken, timeoutMs }; } - async call(method: string, params: unknown, signal?: AbortSignal): Promise { + async call( + method: string, + params: unknown, + signal?: AbortSignal, + options?: { timeoutMs?: number }, + ): Promise { const normalizedMethod = method.trim(); if (!normalizedMethod) { throw new Error("Gateway Admin RPC method is required"); } const id = randomUUID(); - const timeoutMs = resolveCallTimeoutMs(normalizedMethod, params, this.config.timeoutMs); + const configuredTimeoutMs = options?.timeoutMs ?? this.config.timeoutMs; + validateRpcTimeoutMs(configuredTimeoutMs); + const timeoutMs = resolveCallTimeoutMs(normalizedMethod, params, configuredTimeoutMs); let response: Response; try { response = await this.fetchImpl(this.config.rpcUrl, { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c808d5345f..244964b9e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,7 @@ overrides: '@anthropic-ai/sdk': 0.115.0 '@opentelemetry/core': 2.10.0 '@opentelemetry/propagator-jaeger': 2.10.0 + '@openclaw/crabline>re2js': 2.8.7 '@aws-sdk/core': 3.977.1 '@aws-sdk/credential-provider-node': 3.972.72 '@aws-sdk/xml-builder': 3.972.37 @@ -8510,9 +8511,9 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true - re2js@2.8.6: - resolution: {integrity: sha512-xLgQil4kIUCrAzVk9fRSkxkFNwmygLFjVxXrLc65aE1F0+Zsb8rxumFBy4XKyvgMCTL6kilDq3EZ0piE2dP/Dg==} - engines: {node: '>=18.0.0'} + re2js@2.8.7: + resolution: {integrity: sha512-bRSL/vn3KAmlGfGNoVpcsBXLaVvl6AbuvMMWbz0m3vBj60nj+p2/biozfBUOSBP+5XTduF6mr6IO/ZVjmqqkVQ==} + engines: {node: '>=20.0.0'} react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} @@ -11590,7 +11591,7 @@ snapshots: libsignal: 6.0.0 picocolors: 1.1.1 proper-lockfile: 4.1.2 - re2js: 2.8.6 + re2js: 2.8.7 ws: 8.21.1 yaml: 2.9.0 zod: 4.6.6(patch_hash=5b448a1a3aafd543cd67bc4520817f995b85324b886be37256af9b4cb15401aa) @@ -16077,7 +16078,7 @@ snapshots: strip-json-comments: 2.0.1 optional: true - re2js@2.8.6: {} + re2js@2.8.7: {} react-dom@19.2.4(react@19.2.4): dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ab1bddbd72..87d0d7ba52 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -124,6 +124,8 @@ overrides: "@anthropic-ai/sdk": 0.115.0 "@opentelemetry/core": 2.10.0 "@opentelemetry/propagator-jaeger": 2.10.0 + # Remove when crabline adopts the lookbehind DoS fix (GHSA-gg6h-894w-4m9c). + "@openclaw/crabline>re2js": 2.8.7 "@aws-sdk/core": 3.977.1 "@aws-sdk/credential-provider-node": 3.972.72 "@aws-sdk/xml-builder": 3.972.37 diff --git a/scripts/platformclaw-composer-preview.ts b/scripts/platformclaw-composer-preview.ts index 1dd9d9b5ed..3fdc46e385 100644 --- a/scripts/platformclaw-composer-preview.ts +++ b/scripts/platformclaw-composer-preview.ts @@ -142,7 +142,7 @@ const demoPlugin: Plugin = { if (typeof input.enabled === "boolean") { enabled = input.enabled; } - json({ available: true, enabled }); + json({ available: true, enabled, timeoutMs: 10_000 }); return; } const currentMode = mode; diff --git a/ui/src/pages/chat/chat-pane-session-create.test.ts b/ui/src/pages/chat/chat-pane-session-create.test.ts index e3a34b6561..43cb264c5f 100644 --- a/ui/src/pages/chat/chat-pane-session-create.test.ts +++ b/ui/src/pages/chat/chat-pane-session-create.test.ts @@ -81,7 +81,7 @@ describe("chat pane session creation lifecycle", () => { expect(state.chatMessage).toBe("take notes"); expect(state.chatError).toContain("not sent"); - expect(request).not.toHaveBeenCalledWith("chat.send", expect.anything()); + expect(request.mock.calls.filter(([method]) => method === "chat.send")).toHaveLength(0); }); it("does not send a board /new prompt after reset changes the selected session", async () => { @@ -106,7 +106,7 @@ describe("chat pane session creation lifecycle", () => { expect(state.chatMessage).toBe("take notes"); expect(state.chatError).toContain("not sent"); - expect(request).not.toHaveBeenCalledWith("chat.send", expect.anything()); + expect(request.mock.calls.filter(([method]) => method === "chat.send")).toHaveLength(0); }); it("drops a created session after a same-client reconnect", async () => { diff --git a/ui/src/pages/chat/chat-send-delivery.ts b/ui/src/pages/chat/chat-send-delivery.ts index 017e673496..48bb3e686b 100644 --- a/ui/src/pages/chat/chat-send-delivery.ts +++ b/ui/src/pages/chat/chat-send-delivery.ts @@ -5,7 +5,6 @@ import { t } from "../../i18n/index.ts"; import type { ChatQueueItem } from "../../lib/chat/chat-types.ts"; import { scopedAgentIdForSession, visibleSessionMatches } from "../../lib/sessions/index.ts"; import { generateUUID } from "../../lib/uuid.ts"; -import { recordWebComposerRun } from "../../platformclaw/composer-predictions.ts"; import { discardChatAttachmentDataUrls, snapshotChatAttachmentsWithDataUrls, @@ -292,7 +291,6 @@ async function sendQueuedChatMessage( } try { - recordWebComposerRun(host.client, sessionKey, runId); const ack = prepared.skillWorkshopRevision ? await requestSkillWorkshopRevisionChatSend(host as unknown as ChatState, { proposalId: prepared.skillWorkshopRevision.proposalId, diff --git a/ui/src/pages/chat/chat-send-request.ts b/ui/src/pages/chat/chat-send-request.ts index ddea1e2f77..05cb30e23d 100644 --- a/ui/src/pages/chat/chat-send-request.ts +++ b/ui/src/pages/chat/chat-send-request.ts @@ -6,9 +6,17 @@ import { normalizeAgentId, resolveUiSelectedSessionAgentId, } from "../../lib/sessions/session-key.ts"; +import { + recordWebComposerDispatch, + recordWebComposerRun, +} from "../../platformclaw/composer-predictions.ts"; import { buildChatApiAttachments } from "./attachment-api.ts"; import type { ChatState } from "./chat-history.ts"; -import { normalizeChatSendAck, type ChatSendAck } from "./chat-send-ack.ts"; +import { + isTerminalFailureChatSendAck, + normalizeChatSendAck, + type ChatSendAck, +} from "./chat-send-ack.ts"; export async function requestChatSend( state: ChatState, @@ -30,32 +38,45 @@ export async function requestChatSend( const controlUiReconnectResume = Boolean( !params.requireExactLeaf && sessionId && state.reconnectResumeSessionId === sessionId, ); - const payload = await state.client!.request("chat.send", { - sessionKey: routing.sessionKey, - ...(isUiGlobalSessionKey(routing.sessionKey) && routing.selectedAgentId - ? { agentId: routing.selectedAgentId } - : {}), - ...(sessionId ? { sessionId } : {}), - ...(controlUiReconnectResume ? { __controlUiReconnectResume: true } : {}), - message: params.message, - deliver: false, - ...(params.replyToId ? { replyToId: params.replyToId } : {}), - ...(params.requireExactLeaf - ? { queueMode: "followup" } - : params.queueMode - ? { queueMode: params.queueMode } + const client = state.client!; + const pending = client.request( + "chat.send", + { + sessionKey: routing.sessionKey, + ...(isUiGlobalSessionKey(routing.sessionKey) && routing.selectedAgentId + ? { agentId: routing.selectedAgentId } : {}), - ...(params.expectedLeafEntryId !== undefined - ? { expectedLeafEntryId: params.expectedLeafEntryId } - : {}), - ...(params.requireExactLeaf ? { requireExactLeaf: true } : {}), - idempotencyKey: params.runId, - attachments: buildChatApiAttachments(params.attachments), - }); + ...(sessionId ? { sessionId } : {}), + ...(controlUiReconnectResume ? { __controlUiReconnectResume: true } : {}), + message: params.message, + deliver: false, + ...(params.replyToId ? { replyToId: params.replyToId } : {}), + ...(params.requireExactLeaf + ? { queueMode: "followup" } + : params.queueMode + ? { queueMode: params.queueMode } + : {}), + ...(params.expectedLeafEntryId !== undefined + ? { expectedLeafEntryId: params.expectedLeafEntryId } + : {}), + ...(params.requireExactLeaf ? { requireExactLeaf: true } : {}), + idempotencyKey: params.runId, + attachments: buildChatApiAttachments(params.attachments), + }, + { + onSent: () => recordWebComposerDispatch(client, routing.sessionKey, params.runId), + }, + ); + const payload = await pending; if (controlUiReconnectResume) { state.reconnectResumeSessionId = null; } - return normalizeChatSendAck(payload, params.runId); + const ack = normalizeChatSendAck(payload, params.runId); + // ACKs can arrive after the run completes: adopt its ID without dispatching again. + if (ack.runId !== params.runId && !isTerminalFailureChatSendAck(ack)) { + recordWebComposerRun(client, routing.sessionKey, ack.runId); + } + return ack; } export function resolveDisplayedLeafEntryId( @@ -124,14 +145,25 @@ export async function requestSkillWorkshopRevisionChatSend( sessionKey: params.sessionKey, agentId: params.targetAgentId, }); - const payload = await state.client!.request("skills.proposals.requestRevision", { - ...(params.agentId ? { agentId: normalizeAgentId(params.agentId) } : {}), - ...(routing.selectedAgentId ? { targetAgentId: routing.selectedAgentId } : {}), - proposalId: params.proposalId, - instructions: params.instructions, - sessionKey: routing.sessionKey, - ...(routing.sessionId ? { sessionId: routing.sessionId } : {}), - idempotencyKey: params.runId, - }); - return normalizeChatSendAck(payload, params.runId); + const client = state.client!; + const pending = client.request( + "skills.proposals.requestRevision", + { + ...(params.agentId ? { agentId: normalizeAgentId(params.agentId) } : {}), + ...(routing.selectedAgentId ? { targetAgentId: routing.selectedAgentId } : {}), + proposalId: params.proposalId, + instructions: params.instructions, + sessionKey: routing.sessionKey, + ...(routing.sessionId ? { sessionId: routing.sessionId } : {}), + idempotencyKey: params.runId, + }, + { + onSent: () => recordWebComposerDispatch(client, routing.sessionKey, params.runId), + }, + ); + const ack = normalizeChatSendAck(await pending, params.runId); + if (ack.runId !== params.runId && !isTerminalFailureChatSendAck(ack)) { + recordWebComposerRun(client, routing.sessionKey, ack.runId); + } + return ack; } diff --git a/ui/src/pages/chat/chat-send.test.ts b/ui/src/pages/chat/chat-send.test.ts index df276aadb2..5d5cf41da4 100644 --- a/ui/src/pages/chat/chat-send.test.ts +++ b/ui/src/pages/chat/chat-send.test.ts @@ -76,8 +76,17 @@ function makeRequestMock(handlers: RequestHandlers = {}) { type RequestMock = ReturnType; -function clientWithRequest(request: unknown): ChatHost["client"] { - return { request } as unknown as ChatHost["client"]; +function clientWithRequest( + request: (method: string, params?: unknown) => unknown, +): ChatHost["client"] { + return { + request(method: string, params?: unknown, options?: { onSent?: () => void }) { + const pending = request(method, params); + // The transport reports dispatch synchronously, before its RPC response resolves. + options?.onSent?.(); + return pending; + }, + } as unknown as ChatHost["client"]; } type TestChatHost = Omit & { diff --git a/ui/src/pages/chat/components/chat-composer-prediction.ts b/ui/src/pages/chat/components/chat-composer-prediction.ts index 17c0bbc837..c5931b507e 100644 --- a/ui/src/pages/chat/components/chat-composer-prediction.ts +++ b/ui/src/pages/chat/components/chat-composer-prediction.ts @@ -28,6 +28,7 @@ export function syncComposerPrediction( areUiSessionKeysEquivalent(submittedSession, props.sessionKey), ); state.prediction.sync({ + client: props.gatewayClient ?? null, sessionKey: props.sessionKey, completedRunId: completedWebRun ? (runId ?? null) : null, idle, diff --git a/ui/src/platformclaw/composer-predictions.test.ts b/ui/src/platformclaw/composer-predictions.test.ts index 05dec939e2..fb7332f44e 100644 --- a/ui/src/platformclaw/composer-predictions.test.ts +++ b/ui/src/platformclaw/composer-predictions.test.ts @@ -1,7 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChatState } from "../pages/chat/chat-history.ts"; +import { requestChatSend } from "../pages/chat/chat-send-request.ts"; import { ComposerPredictionController, updateComposerPreferences, + recordWebComposerDispatch, recordWebComposerRun, webComposerRunSession, } from "./composer-predictions.ts"; @@ -9,7 +12,9 @@ import { describe("Web composer predictions", () => { const fetchMock = vi.fn(); let prediction: ComposerPredictionController; + let timeoutMs: number; const source = { + client: {}, sessionKey: "agent:personal:main", completedRunId: "run-1", idle: true, @@ -23,11 +28,12 @@ describe("Web composer predictions", () => { }; beforeEach(async () => { vi.useFakeTimers(); + timeoutMs = 30_000; fetchMock.mockImplementation(async (_url, options) => { const body = requestBody(options); return Response.json( body.action === "preferences" - ? { available: true, enabled: body.enabled ?? true } + ? { available: true, enabled: body.enabled ?? true, timeoutMs } : { text: "구체적인 실행 방법을 알려줘" }, ); }); @@ -38,6 +44,7 @@ describe("Web composer predictions", () => { afterEach(() => { prediction.dispose(); fetchMock.mockReset(); + vi.restoreAllMocks(); vi.unstubAllGlobals(); vi.useRealTimers(); }); @@ -55,7 +62,7 @@ describe("Web composer predictions", () => { await vi.advanceTimersByTimeAsync(2000); expect(predictCalls()).toHaveLength(1); }); - it.each(["typing", "session", "run", "off"])( + it.each(["typing", "session", "client", "run", "off"])( "discards a late response after %s", async (reason) => { let resolve!: (response: Response) => void; @@ -74,6 +81,9 @@ describe("Web composer predictions", () => { if (reason === "session") { prediction.sync({ ...source, sessionKey: "agent:personal:other", completedRunId: null }); } + if (reason === "client") { + prediction.sync({ ...source, client: {}, completedRunId: null }); + } if (reason === "run") { prediction.sync({ ...source, idle: false, completedRunId: null }); } @@ -98,6 +108,176 @@ describe("Web composer predictions", () => { await vi.advanceTimersByTimeAsync(1000); expect(prediction.text).not.toBeNull(); }); + it.each([ + { responseAfterMs: 11_000, aborted: false, text: "slow prediction" }, + { responseAfterMs: 20_001, aborted: true, text: null }, + ])("honors the server timeout for a response after $responseAfterMs ms", async (scenario) => { + timeoutMs = 20_000; + await updateComposerPreferences(); + vi.spyOn(AbortSignal, "timeout").mockImplementation((delay) => { + const controller = new AbortController(); + setTimeout(() => controller.abort(), delay); + return controller.signal; + }); + let resolve!: (response: Response) => void; + fetchMock.mockImplementationOnce( + () => + new Promise((done) => { + resolve = done; + }), + ); + prediction.sync(source); + await vi.advanceTimersByTimeAsync(1000); + const signal = predictCalls()[0]?.[1]?.signal; + expect(signal).toBeDefined(); + await vi.advanceTimersByTimeAsync(scenario.responseAfterMs); + expect(signal?.aborted).toBe(scenario.aborted); + resolve(Response.json({ text: "slow prediction" })); + await vi.advanceTimersByTimeAsync(0); + expect(prediction.text).toBe(scenario.text); + }); + it("rejects out-of-contract timeout metadata without making a prediction", async () => { + timeoutMs = 300_001; + await updateComposerPreferences(); + prediction.sync(source); + await vi.advanceTimersByTimeAsync(2000); + expect(predictCalls()).toHaveLength(0); + }); + it.each(["started", "error", "timeout"] as const)( + "cancels at browser dispatch before a %s ACK and ignores the old completion afterward", + async (status) => { + let resolvePrediction!: (response: Response) => void; + fetchMock.mockImplementationOnce( + () => + new Promise((done) => { + resolvePrediction = done; + }), + ); + let resolveAck!: (ack: unknown) => void; + const ack = new Promise((resolve) => { + resolveAck = resolve; + }); + const request = vi.fn( + (_method: string, _params: unknown, options?: { onSent?: () => void }) => { + expect(predictCalls()[0]?.[1]?.signal?.aborted).toBe(false); + options?.onSent?.(); + return ack; + }, + ); + const client = { request }; + const currentSource = { ...source, client }; + prediction.sync(currentSource); + await vi.advanceTimersByTimeAsync(1000); + const signal = predictCalls()[0]?.[1]?.signal; + const state = { + client: client as unknown as ChatState["client"], + sessionKey: source.sessionKey, + } as ChatState; + const sending = requestChatSend(state, { message: "new reply", runId: "new-run" }); + expect(request).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ message: "new reply" }), + { onSent: expect.any(Function) }, + ); + expect(signal?.aborted).toBe(true); + expect(webComposerRunSession(client, "new-run")).toBe(source.sessionKey); + resolvePrediction(Response.json({ text: "stale" })); + await vi.advanceTimersByTimeAsync(0); + prediction.sync(currentSource); + await vi.advanceTimersByTimeAsync(1000); + expect(prediction.text).toBeNull(); + expect(predictCalls()).toHaveLength(1); + resolveAck({ runId: "server-run", status }); + await expect(sending).resolves.toMatchObject({ runId: "server-run" }); + expect(webComposerRunSession(client, "server-run")).toBe( + status === "started" ? source.sessionKey : undefined, + ); + }, + ); + it("preserves the pending prediction when the socket rejects before sending", async () => { + let resolvePrediction!: (response: Response) => void; + fetchMock.mockImplementationOnce( + () => + new Promise((done) => { + resolvePrediction = done; + }), + ); + const client = { request: vi.fn().mockRejectedValue(new Error("socket send failed")) }; + prediction.sync({ ...source, client }); + await vi.advanceTimersByTimeAsync(1000); + const signal = predictCalls()[0]?.[1]?.signal; + const state = { + client: client as unknown as ChatState["client"], + sessionKey: source.sessionKey, + } as ChatState; + await expect( + requestChatSend(state, { message: "unsent reply", runId: "unsent-run" }), + ).rejects.toThrow("socket send failed"); + expect(signal?.aborted).toBe(false); + expect(webComposerRunSession(client, "unsent-run")).toBeUndefined(); + resolvePrediction(Response.json({ text: "still current" })); + await vi.advanceTimersByTimeAsync(0); + expect(prediction.text).toBe("still current"); + }); + it("adopts a delayed ACK ID without cancelling the completed reply's prediction", async () => { + let resolveAck!: (ack: unknown) => void; + const ack = new Promise((resolve) => { + resolveAck = resolve; + }); + const client = { + request: vi.fn((_method: string, _params: unknown, options?: { onSent?: () => void }) => { + options?.onSent?.(); + return ack; + }), + }; + prediction.sync({ ...source, client }); + const state = { + client: client as unknown as ChatState["client"], + sessionKey: source.sessionKey, + } as ChatState; + const sending = requestChatSend(state, { message: "new reply", runId: "new-run" }); + prediction.sync({ ...source, client, completedRunId: "server-run" }); + await vi.advanceTimersByTimeAsync(1000); + expect(prediction.text).toBe("구체적인 실행 방법을 알려줘"); + resolveAck({ runId: "server-run", status: "ok" }); + await sending; + expect(webComposerRunSession(client, "server-run")).toBe(source.sessionKey); + expect(prediction.text).toBe("구체적인 실행 방법을 알려줘"); + }); + it("limits dispatch cancellation to the same client and session and retires a pending timer", async () => { + prediction.sync(source); + recordWebComposerDispatch({}, source.sessionKey, "other-client-run"); + recordWebComposerDispatch(source.client, "agent:personal:other", "other-session-run"); + await vi.advanceTimersByTimeAsync(1000); + expect(prediction.text).not.toBeNull(); + prediction.sync({ ...source, completedRunId: "next-completion" }); + recordWebComposerDispatch(source.client, source.sessionKey.toUpperCase(), "next-dispatch"); + prediction.sync({ ...source, completedRunId: "next-completion" }); + await vi.advanceTimersByTimeAsync(1000); + expect(prediction.text).toBeNull(); + expect(predictCalls()).toHaveLength(1); + }); + it("retires a completion dispatched before its first idle render", async () => { + prediction.sync({ ...source, idle: false }); + recordWebComposerDispatch(source.client, source.sessionKey, "new-dispatch"); + prediction.sync(source); + await vi.advanceTimersByTimeAsync(1000); + expect(prediction.text).toBeNull(); + expect(predictCalls()).toHaveLength(0); + }); + it("unsubscribes from browser dispatch when disposed", async () => { + prediction.dispose(); + const requestUpdate = vi.fn(); + prediction = new ComposerPredictionController(requestUpdate); + await vi.advanceTimersByTimeAsync(0); + prediction.sync(source); + prediction.dispose(); + requestUpdate.mockClear(); + recordWebComposerDispatch(source.client, source.sessionKey, "after-dispose"); + await vi.advanceTimersByTimeAsync(1000); + expect(requestUpdate).not.toHaveBeenCalled(); + expect(predictCalls()).toHaveLength(0); + }); it("requires a browser dispatch fact and isolates its client and run identity", () => { const client = {}; expect(webComposerRunSession(client, "cron-run")).toBeUndefined(); diff --git a/ui/src/platformclaw/composer-predictions.ts b/ui/src/platformclaw/composer-predictions.ts index 1598db1cd8..c107854a6f 100644 --- a/ui/src/platformclaw/composer-predictions.ts +++ b/ui/src/platformclaw/composer-predictions.ts @@ -1,5 +1,8 @@ +import { areUiSessionKeysEquivalent } from "../lib/sessions/session-key.ts"; + const PATH = "/platformclaw/api/composer-predictions"; const webRuns = new WeakMap>(); +const dispatchListeners = new Set<(client: object, sessionKey: string) => void>(); // Record provenance at browser dispatch, rather than inferring it from a shared final event. // A Cron/Knox/background final can otherwise adopt the visible chat's run status. @@ -18,10 +21,19 @@ export function recordWebComposerRun(client: object, sessionKey: string, runId: } } +export function recordWebComposerDispatch(client: object, sessionKey: string, runId: string): void { + recordWebComposerRun(client, sessionKey, runId); + for (const listener of dispatchListeners) { + listener(client, sessionKey); + } +} + export function webComposerRunSession(client: object, runId: string): string | undefined { return webRuns.get(client)?.get(runId); } -type PredictionPreferences = { available: boolean; enabled: boolean }; +type PredictionPreferences = + | { available: false; enabled: false } + | { available: true; enabled: boolean; timeoutMs: number }; const listeners = new Set<() => void>(); let preferences: PredictionPreferences = { available: false, enabled: false }; @@ -48,8 +60,13 @@ export async function updateComposerPreferences(enabled?: boolean): Promise= 1000 && + result.timeoutMs <= 300_000 + ? { available: true, enabled: result.enabled === true, timeoutMs: result.timeoutMs } : { available: false, enabled: false }; } catch { preferences = { available: false, enabled: false }; @@ -65,6 +82,7 @@ export function subscribeComposerPreferences(listener: () => void): () => void { } type PredictionSource = { + client: object | null; sessionKey: string; completedRunId: string | null; idle: boolean; @@ -79,8 +97,21 @@ export class ComposerPredictionController { private request: AbortController | null = null; private timer: ReturnType | undefined; private readonly unsubscribe: () => void; + private readonly onDispatch = (client: object, sessionKey: string) => { + if ( + this.source?.client === client && + areUiSessionKeysEquivalent(this.source.sessionKey, sessionKey) + ) { + // Dispatch retires this completion before an ACK or busy render arrives. + // Retire even a completion still awaiting idle, so stale renders cannot re-arm it. + this.lastRunId = this.source.completedRunId; + this.dismiss(); + this.requestUpdate(); + } + }; constructor(private readonly requestUpdate: () => void) { + dispatchListeners.add(this.onDispatch); this.unsubscribe = subscribeComposerPreferences(() => { if (!preferences.available || !preferences.enabled) { this.dismiss(); @@ -91,7 +122,8 @@ export class ComposerPredictionController { } sync(next: PredictionSource): void { - const scopeChanged = this.source?.sessionKey !== next.sessionKey; + const scopeChanged = + this.source?.client !== next.client || this.source?.sessionKey !== next.sessionKey; if (scopeChanged) { this.dismiss(); this.lastRunId = null; @@ -137,12 +169,16 @@ export class ComposerPredictionController { dispose(): void { this.dismiss(); this.unsubscribe(); + dispatchListeners.delete(this.onDispatch); } private async predict(origin: PredictionSource): Promise { + if (!preferences.available || !preferences.enabled) { + return; + } const pending = new AbortController(); this.request = pending; - const signal = AbortSignal.any([pending.signal, AbortSignal.timeout(10_000)]); + const signal = AbortSignal.any([pending.signal, AbortSignal.timeout(preferences.timeoutMs)]); try { const response = await fetch(PATH, { method: "POST", @@ -155,6 +191,7 @@ export class ComposerPredictionController { if ( this.request !== pending || signal.aborted || + this.source?.client !== origin.client || this.source?.sessionKey !== origin.sessionKey || !this.source.idle || !this.source.eligible || diff --git a/ui/src/test-helpers/platformclaw-composer-fixture.ts b/ui/src/test-helpers/platformclaw-composer-fixture.ts index 807bfd0db1..c1c0e8f561 100644 --- a/ui/src/test-helpers/platformclaw-composer-fixture.ts +++ b/ui/src/test-helpers/platformclaw-composer-fixture.ts @@ -40,7 +40,7 @@ export async function openComposerPredictionFixture(params: { if (typeof body.enabled === "boolean") { enabled = body.enabled; } - await route.fulfill({ json: { available: true, enabled } }); + await route.fulfill({ json: { available: true, enabled, timeoutMs: 10_000 } }); return; } predictionCalls++;