diff --git a/docs/platformclaw/web-ingress-runtime.md b/docs/platformclaw/web-ingress-runtime.md index 888bcaaf81..6a7cfc600b 100644 --- a/docs/platformclaw/web-ingress-runtime.md +++ b/docs/platformclaw/web-ingress-runtime.md @@ -98,6 +98,44 @@ The private composer operation has no Gateway WebSocket method or agent tool. Only the authenticated Web BFF may supply employee identity and approved context through the existing trusted admin HTTP transport. +### Diagnose a missing prediction + +Open browser developer tools before sending a new message. In **Network**, filter +for `composer-predictions`. Select the request whose **Payload** has +`action: "predict"`, then open **Response**. The `preferences` request only reports +the feature gates and configured timeout; `available: true` does not verify model +configuration or connectivity. + +Prediction responses retain `text` and add a fixed `reason` and integer +`elapsedMs` for the whole HTTP operation. For example: + +```json +{ "text": null, "reason": "unsupported_api", "elapsedMs": 1180 } +``` + +| Reason | Meaning | +| ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `suggested` | A suggestion passed the output and final access checks. | +| `server_disabled`, `preference_disabled`, `disabled` | A feature gate is off. | +| `prediction_busy`, `model_busy`, `model_preempted` | Admission or ordinary-call tracking prevented or interrupted the prediction. This is not a measurement of GPU load. | +| `model_unavailable`, `invalid_provider`, `unsupported_api`, `credentials_unready` | Check the selected provider/model, its explicit API adapter, and resolved credentials. | +| `destination_rejected` | The model destination did not pass the private-address restriction. | +| `provider_http_error` | The provider returned a non-success response; `providerStatus` contains its numeric HTTP status. The response body is never copied. | +| `response_missing`, `response_invalid`, `response_empty`, `response_oversized` | The provider response could not be used. | +| `no_suggestion`, `format_rejected` | The model abstained or its output failed the existing format/length checks. | +| `history_ineligible`, `history_changed` | The authorized text context was unsuitable or changed during inference. | +| `timeout`, `cancelled` | The operation reached its deadline or was cancelled. A browser cancellation can have no Response body. | +| `access_error`, `unauthenticated`, `forbidden`, `preferences_error`, `history_error`, `gateway_error` | A request, access, settings, transcript, or internal Gateway operation failed. | +| `diagnostics_unavailable` | The Gateway supplied no recognized reason, including during a rolling update. The existing `text` field remains usable. | + +The Web ingress writes one `PlatformClaw composer prediction` terminal log entry +with the same diagnostic fields. It excludes conversation text, raw exceptions, +user/session identities, destination URLs, and credentials. Timeout, cancellation, +and late completion cannot emit duplicate outcomes. A `text: null` response in +about one second is not evidence that the configured thirty-second deadline was +reached; use its reason instead. An older deployed image will still return only +`text` until the diagnostic change is deployed. + ## Request flow 1. The browser signs in through the PlatformClaw authentication endpoints. diff --git a/extensions/admin-http-rpc/src/composer-predictions.test.ts b/extensions/admin-http-rpc/src/composer-predictions.test.ts index 9a14393c78..cf4d626d6f 100644 --- a/extensions/admin-http-rpc/src/composer-predictions.test.ts +++ b/extensions/admin-http-rpc/src/composer-predictions.test.ts @@ -15,15 +15,22 @@ function setup(providerId = "codemate", modelId = "codellm-fast", apiType = "ope composerPredictionsEnabled?: boolean; composerPredictionsTimeoutMs?: number; } = { composerPredictionsEnabled: true }; + const provider: { + baseUrl: string; + api: string; + apiKey?: unknown; + headers?: Record; + models: { id: string }[]; + } = { + baseUrl: "http://company.internal/v1", + api: apiType, + models: [{ id: modelId }], + }; const config = { plugins: { entries: { "admin-http-rpc": { config: pluginConfig } } }, models: { providers: { - [providerId]: { - baseUrl: "http://company.internal/v1", - api: apiType, - models: [{ id: modelId }], - }, + [providerId]: provider, }, }, }; @@ -56,7 +63,7 @@ function setup(providerId = "codemate", modelId = "codellm-fast", apiType = "ope ], }; const predict = (signal = new AbortController().signal) => call(params, signal); - return { call, predict, composer, values, params, pluginConfig }; + return { call, predict, composer, values, params, pluginConfig, provider }; } function useDeadlineClock() { @@ -85,7 +92,7 @@ afterEach(() => { describe("Explicit isolated prediction inference", () => { it("defaults OFF, isolates account preferences, and never follows conversation models", async () => { const { call, predict, params } = setup(); - expect(await predict()).toEqual({ text: null }); + expect(await predict()).toEqual({ text: null, reason: "disabled" }); const signal = new AbortController().signal; expect(await call({ action: "preferences", userId: "user-1" }, signal)).toEqual({ available: true, @@ -97,8 +104,11 @@ describe("Explicit isolated prediction inference", () => { enabled: true, timeoutMs: 30_000, }); - expect(await call({ ...params, userId: "user-2" }, signal)).toEqual({ text: null }); - expect(await predict()).toEqual({ text: "다음 단계는 무엇인가요?" }); + expect(await call({ ...params, userId: "user-2" }, signal)).toEqual({ + text: null, + reason: "disabled", + }); + expect(await predict()).toEqual({ text: "다음 단계는 무엇인가요?", reason: "suggested" }); const request = mocks.fetch.mock.calls[0]?.[0]; const body = JSON.parse(request.init.body); expect(body).toMatchObject({ model: "codellm-fast", max_tokens: 64, stream: false }); @@ -145,7 +155,7 @@ describe("Explicit isolated prediction inference", () => { enabled, timeoutMs: 45_000, }); - expect(await predict()).toEqual({ text: null }); + expect(await predict()).toEqual({ text: null, reason: "disabled" }); expect(mocks.fetch).not.toHaveBeenCalled(); }, ); @@ -179,7 +189,7 @@ describe("Explicit isolated prediction inference", () => { ); const pending = predict(); await vi.advanceTimersByTimeAsync(15_000); - expect(await pending).toEqual({ text: "다음 단계는 무엇인가요?" }); + expect(await pending).toEqual({ text: "다음 단계는 무엇인가요?", reason: "suggested" }); expect(upstreamSignal?.aborted).toBe(false); expect(timeout).toHaveBeenCalledWith(45_000); }); @@ -201,14 +211,16 @@ describe("Explicit isolated prediction inference", () => { 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(await pending).toEqual({ text: null, reason: "timeout" }); + expect(await predict()).toEqual({ text: "다음 단계는 무엇인가요?", reason: "suggested" }); expect(mocks.fetch).toHaveBeenCalledTimes(2); }); - it("releases an interrupted response stream and admits the next prediction", async () => { - const { predict, values } = setup(); + it("preserves request cancellation when normal chat starts during stream cleanup", async () => { + const { predict, composer, values } = setup(); values.set("user-1", true); - const release = vi.fn(async () => {}); + const release = vi.fn(async () => { + composer.modelCallStarted("normal-chat"); + }); let upstreamResponse: Response | undefined; mocks.fetch.mockImplementationOnce(async ({ signal }: { signal: AbortSignal }) => { upstreamResponse = new Response( @@ -223,10 +235,12 @@ describe("Explicit isolated prediction inference", () => { 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 }); + cancellation.abort(new Error("private request cancellation reason")); + expect(await pending).toEqual({ text: null, reason: "cancelled" }); expect(release).toHaveBeenCalledTimes(1); - expect(await predict()).toEqual({ text: "다음 단계는 무엇인가요?" }); + expect(await predict()).toEqual({ text: null, reason: "model_busy" }); + composer.modelCallEnded("normal-chat"); + expect(await predict()).toEqual({ text: "다음 단계는 무엇인가요?", reason: "suggested" }); expect(mocks.fetch).toHaveBeenCalledTimes(2); }); it("uses the configured provider/model and pins it until plugin recreation", async () => { @@ -234,7 +248,7 @@ describe("Explicit isolated prediction inference", () => { const { predict, values } = setup("company-fast", "org/followup-v2"); values.set("user-1", true); vi.stubEnv("PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL", "codemate/codellm-fast"); - expect(await predict()).toEqual({ text: "다음 단계는 무엇인가요?" }); + expect(await predict()).toEqual({ text: "다음 단계는 무엇인가요?", reason: "suggested" }); const request = mocks.fetch.mock.calls[0]?.[0]; expect(JSON.parse(request.init.body).model).toBe("org/followup-v2"); expect(mocks.fetch).toHaveBeenCalledTimes(1); @@ -252,7 +266,7 @@ describe("Explicit isolated prediction inference", () => { vi.stubEnv("PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL", value); const { predict, values } = setup(); values.set("user-1", true); - expect(await predict()).toEqual({ text: null }); + expect(await predict()).toEqual({ text: null, reason: "model_unavailable" }); expect(mocks.fetch).not.toHaveBeenCalled(); expect(mocks.lookup).not.toHaveBeenCalled(); }, @@ -261,9 +275,149 @@ describe("Explicit isolated prediction inference", () => { vi.stubEnv("PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL", "company-fast/followup-v2"); const { predict, values } = setup("company-fast", "followup-v2", "anthropic-messages"); values.set("user-1", true); - expect(await predict()).toEqual({ text: null }); + expect(await predict()).toEqual({ text: null, reason: "unsupported_api" }); + expect(mocks.fetch).not.toHaveBeenCalled(); + }); + it.each([ + ["malformed URL", "not a URL"], + // A redacted fixture password still exercises the URL userinfo rejection. + ["embedded credentials", "http://example-user:***@provider.example.test/v1"], + ["query credentials", "http://company.internal/v1?key=private-key"], + ])( + "reports an invalid provider for %s without exposing its configuration", + async (_name, baseUrl) => { + const { predict, values, provider } = setup(); + values.set("user-1", true); + provider.baseUrl = baseUrl; + expect(await predict()).toEqual({ text: null, reason: "invalid_provider" }); + expect(mocks.fetch).not.toHaveBeenCalled(); + expect(mocks.lookup).not.toHaveBeenCalled(); + }, + ); + it.each(["apiKey", "headers"] as const)( + "reports unresolved %s without exposing the credential reference", + async (field) => { + const { predict, values, provider } = setup(); + values.set("user-1", true); + const credential = { source: "env", id: "PRIVATE_CREDENTIAL" }; + if (field === "apiKey") { + provider.apiKey = credential; + } else { + provider.headers = { Authorization: credential }; + } + expect(await predict()).toEqual({ text: null, reason: "credentials_unready" }); + expect(mocks.fetch).not.toHaveBeenCalled(); + }, + ); + it.each([ + { name: "missing messages", messages: undefined }, + { name: "empty history", messages: [] }, + { name: "short history", messages: [{ role: "assistant", content: "답변" }] }, + { + name: "disallowed role", + messages: [ + { role: "system", content: "private context" }, + { role: "assistant", content: "답변" }, + ], + }, + { + name: "oversized message", + messages: [ + { role: "user", content: "x".repeat(601) }, + { role: "assistant", content: "답변" }, + ], + }, + { + name: "unfinished response", + messages: [ + { role: "assistant", content: "답변" }, + { role: "user", content: "계획" }, + ], + }, + ])("reports $name as invalid context without inference", async ({ messages }) => { + const { call, values, params } = setup(); + values.set("user-1", true); + expect(await call({ ...params, messages }, new AbortController().signal)).toEqual({ + text: null, + reason: "invalid_context", + }); expect(mocks.fetch).not.toHaveBeenCalled(); }); + it.each([401, 403, 429, 500, 503])( + "returns only the provider status for HTTP %s and releases admission", + async (status) => { + const { predict, values } = setup(); + values.set("user-1", true); + const cancel = vi.fn(); + const release = vi.fn(async () => {}); + mocks.fetch.mockResolvedValueOnce({ + response: new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("private provider body and prompt")); + }, + cancel, + }), + { status, headers: { "X-Private-Credential": "private-key" } }, + ), + release, + }); + expect(await predict()).toEqual({ + text: null, + reason: "provider_http_error", + providerStatus: status, + }); + expect(cancel).toHaveBeenCalledTimes(1); + expect(release).toHaveBeenCalledTimes(1); + expect(await predict()).toEqual({ text: "다음 단계는 무엇인가요?", reason: "suggested" }); + expect(mocks.fetch).toHaveBeenCalledTimes(2); + }, + ); + it.each([ + { name: "missing body", body: null, reason: "response_missing" }, + { name: "empty body", body: " ", reason: "response_empty" }, + { name: "malformed JSON", body: "private non-JSON provider error", reason: "response_invalid" }, + { name: "missing choices", body: "{}", reason: "response_invalid" }, + { name: "oversized body", body: "x".repeat(16_385), reason: "response_oversized" }, + ...[ + { + name: "non-text content", + content: { private: "provider output" }, + reason: "response_invalid", + }, + { name: "empty content", content: " ", reason: "response_empty" }, + { name: "intentional abstention", content: "NO_SUGGESTION", reason: "no_suggestion" }, + { name: "multiline content", content: "private\nprovider output", reason: "format_rejected" }, + { name: "Markdown content", content: "# private provider output", reason: "format_rejected" }, + { name: "long content", content: "x".repeat(301), reason: "format_rejected" }, + ].map(({ name, content, reason }) => ({ + name, + body: JSON.stringify({ choices: [{ message: { content } }] }), + reason, + })), + ])("reports $name without returning rejected output", async ({ body, reason }) => { + const { predict, values } = setup(); + values.set("user-1", true); + const release = vi.fn(async () => {}); + mocks.fetch.mockResolvedValueOnce({ response: new Response(body), release }); + expect(await predict()).toEqual({ text: null, reason }); + expect(release).toHaveBeenCalledTimes(1); + expect(await predict()).toEqual({ text: "다음 단계는 무엇인가요?", reason: "suggested" }); + expect(mocks.fetch).toHaveBeenCalledTimes(2); + }); + it("reports preferences disabled during inference without returning the suggestion", async () => { + const { predict, values } = setup(); + values.set("user-1", true); + mocks.fetch.mockImplementationOnce(async () => { + values.set("user-1", false); + return { + response: Response.json({ choices: [{ message: { content: "private suggestion" } }] }), + release: async () => {}, + }; + }); + expect(await predict()).toEqual({ text: null, reason: "disabled" }); + expect(mocks.fetch).toHaveBeenCalledTimes(1); + }); it("skips concurrent predictions and preempts them for ordinary chat", async () => { const { predict, composer, values } = setup(); values.set("user-1", true); @@ -277,23 +431,23 @@ describe("Explicit isolated prediction inference", () => { ); const pending = predict(); await vi.waitFor(() => expect(abortSignal).toBeDefined()); - expect(await predict()).toEqual({ text: null }); + expect(await predict()).toEqual({ text: null, reason: "prediction_busy" }); composer.modelCallStarted("normal-chat"); expect(abortSignal?.aborted).toBe(true); - expect(await pending).toEqual({ text: null }); - expect(await predict()).toEqual({ text: null }); + expect(await pending).toEqual({ text: null, reason: "model_preempted" }); + expect(await predict()).toEqual({ text: null, reason: "model_busy" }); composer.modelCallEnded("normal-chat"); - expect(await predict()).toEqual({ text: "다음 단계는 무엇인가요?" }); + expect(await predict()).toEqual({ text: "다음 단계는 무엇인가요?", reason: "suggested" }); }); it("rejects public destinations and does not retry or fall back on failure", async () => { vi.stubEnv("PLATFORMCLAW_COMPOSER_PREDICTIONS_MODEL", "company-fast/followup-v2"); const { predict, values } = setup("company-fast", "followup-v2"); values.set("user-1", true); mocks.lookup.mockResolvedValueOnce([{ address: "8.8.8.8", family: 4 }]); - expect(await predict()).toEqual({ text: null }); + expect(await predict()).toEqual({ text: null, reason: "destination_rejected" }); expect(mocks.fetch).not.toHaveBeenCalled(); - mocks.fetch.mockRejectedValueOnce(new Error("private failure")); - expect(await predict()).toEqual({ text: null }); + mocks.fetch.mockRejectedValueOnce(new Error("TimeoutError: secret credential and prompt")); + expect(await predict()).toEqual({ text: null, reason: "provider_error" }); expect(mocks.fetch).toHaveBeenCalledTimes(1); }); it("forwards cancellation without leaking the response", async () => { @@ -301,7 +455,7 @@ describe("Explicit isolated prediction inference", () => { values.set("user-1", true); const controller = new AbortController(); controller.abort(); - expect(await predict(controller.signal)).toEqual({ text: null }); + expect(await predict(controller.signal)).toEqual({ text: null, reason: "cancelled" }); expect(mocks.fetch).not.toHaveBeenCalled(); }); }); diff --git a/extensions/admin-http-rpc/src/composer-predictions.ts b/extensions/admin-http-rpc/src/composer-predictions.ts index e56f6f5e10..019ed417fc 100644 --- a/extensions/admin-http-rpc/src/composer-predictions.ts +++ b/extensions/admin-http-rpc/src/composer-predictions.ts @@ -60,11 +60,20 @@ export function createComposerPredictions(api: OpenClawPluginApi) { timeoutMs: timeoutMs(), }; } - if (action !== "predict" || !enabled() || (await preferences.lookup(userId)) !== true) { - return { text: null }; + if (action !== "predict") { + return { text: null, reason: "invalid_request" }; } - if (active || modelCalls.size || !Array.isArray(params.messages)) { - return { text: null }; + if (!enabled() || (await preferences.lookup(userId)) !== true) { + return { text: null, reason: "disabled" }; + } + if (active) { + return { text: null, reason: "prediction_busy" }; + } + if (modelCalls.size) { + return { text: null, reason: "model_busy" }; + } + if (!Array.isArray(params.messages)) { + return { text: null, reason: "invalid_context" }; } const messages = params.messages; if ( @@ -80,44 +89,53 @@ export function createComposerPredictions(api: OpenClawPluginApi) { !isRecord(messages.at(-1)) || messages.at(-1).role !== "assistant" ) { - return { text: null }; + return { text: null, reason: "invalid_context" }; } const controller = new AbortController(); active = controller; - const combined = AbortSignal.any([signal, controller.signal, AbortSignal.timeout(timeoutMs())]); + const deadline = AbortSignal.timeout(timeoutMs()); + const combined = AbortSignal.any([signal, controller.signal, deadline]); try { if (!modelRef) { - return { text: null }; + return { text: null, reason: "model_unavailable" }; } const provider = findNormalizedProviderValue( api.runtime.config.current().models?.providers, modelRef.provider, ); const model = provider?.models.find((candidate) => candidate.id === modelRef.model); - if (!provider || !model || (model.api ?? provider.api) !== "openai-completions") { - return { text: null }; + if (!provider || !model) { + return { text: null, reason: "model_unavailable" }; + } + if ((model.api ?? provider.api) !== "openai-completions") { + return { text: null, reason: "unsupported_api" }; + } + let url: URL; + try { + url = new URL( + `${(model.baseUrl ?? provider.baseUrl).replace(/\/$/u, "")}/chat/completions`, + ); + } catch { + return { text: null, reason: "invalid_provider" }; } - const url = new URL( - `${(model.baseUrl ?? provider.baseUrl).replace(/\/$/u, "")}/chat/completions`, - ); if (url.username || url.password || url.search || url.hash) { - return { text: null }; + return { text: null, reason: "invalid_provider" }; } const addresses = await lookup(url.hostname.replace(/^\[|\]$/gu, ""), { all: true }); combined.throwIfAborted(); // Pin the verified private addresses; a redirect or DNS change must not send context outside. if (!addresses.length || addresses.some(({ address }) => !isPrivateOrLoopbackHost(address))) { - return { text: null }; + return { text: null, reason: "destination_rejected" }; } const headers = new Headers({ "Content-Type": "application/json" }); for (const [name, value] of Object.entries({ ...provider.headers, ...model.headers })) { if (typeof value !== "string") { - return { text: null }; + return { text: null, reason: "credentials_unready" }; } headers.set(name, value); } if (provider.apiKey !== undefined && typeof provider.apiKey !== "string") { - return { text: null }; + return { text: null, reason: "credentials_unready" }; } if (provider.apiKey && provider.authHeader !== false) { headers.set("Authorization", `Bearer ${provider.apiKey}`); @@ -149,11 +167,11 @@ export function createComposerPredictions(api: OpenClawPluginApi) { try { if (!response.ok) { await response.body?.cancel(); - return { text: null }; + return { text: null, reason: "provider_http_error", providerStatus: response.status }; } const reader = response.body?.getReader(); if (!reader) { - return { text: null }; + return { text: null, reason: "response_missing" }; } let raw = ""; let bytes = 0; @@ -166,7 +184,7 @@ export function createComposerPredictions(api: OpenClawPluginApi) { } bytes += value.byteLength; if (bytes > 16_384) { - return { text: null }; + return { text: null, reason: "response_oversized" }; } raw += decoder.decode(value, { stream: true }); } @@ -174,34 +192,53 @@ export function createComposerPredictions(api: OpenClawPluginApi) { } finally { await reader.cancel().catch(() => undefined); } - const body: unknown = JSON.parse(raw); + if (!raw.trim()) { + return { text: null, reason: "response_empty" }; + } + let body: unknown; + try { + body = JSON.parse(raw); + } catch { + return { text: null, reason: "response_invalid" }; + } const choice = isRecord(body) && Array.isArray(body.choices) ? body.choices[0] : null; const message = isRecord(choice) ? choice.message : null; - const text = - isRecord(message) && typeof message.content === "string" - ? message.content - .trim() - .replace(/^["'“”]+|["'“”]+$/gu, "") - .trim() - : ""; + if (!isRecord(message) || typeof message.content !== "string") { + return { text: null, reason: "response_invalid" }; + } + const text = message.content + .trim() + .replace(/^["'“”]+|["'“”]+$/gu, "") + .trim(); combined.throwIfAborted(); - return { - text: - enabled() && - (await preferences.lookup(userId)) === true && - text && - text !== "NO_SUGGESTION" && - text.length <= 300 && - !/[\n\r`#*]/u.test(text) - ? text - : null, - }; + if (!enabled() || (await preferences.lookup(userId)) !== true) { + return { text: null, reason: "disabled" }; + } + if (!text) { + return { text: null, reason: "response_empty" }; + } + if (text === "NO_SUGGESTION") { + return { text: null, reason: "no_suggestion" }; + } + if (text.length > 300 || /[\n\r`#*]/u.test(text)) { + return { text: null, reason: "format_rejected" }; + } + return { text, reason: "suggested" }; } finally { await release(); } } catch { - // Provider errors may contain credentials or prompt text; never log/return them. - return { text: null }; + // Provider errors can contain credentials or prompts. Classify the first + // abort from its signal; later cancellation during cleanup must not replace it. + const reason = + controller.signal.aborted && combined.reason === controller.signal.reason + ? "model_preempted" + : deadline.aborted && combined.reason === deadline.reason + ? "timeout" + : signal.aborted && combined.reason === signal.reason + ? "cancelled" + : "provider_error"; + return { text: null, reason }; } finally { if (active === controller) { active = 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 d9ced40f36..801e305f2b 100644 --- a/packages/platformclaw-control-plane/src/browser-composer-http.test.ts +++ b/packages/platformclaw-control-plane/src/browser-composer-http.test.ts @@ -10,6 +10,7 @@ afterEach(async () => { for (const close of cleanups.splice(0)) { await close(); } + vi.restoreAllMocks(); }); async function setup( @@ -33,19 +34,22 @@ async function setup( }, ], }); - const call = vi.fn(async (_method: string, params: unknown) => { + const log = vi.spyOn(console, "info").mockImplementation(() => {}); + const call = vi.fn(async (_method: string, params: unknown): Promise => { const request = params as { action: string }; return request.action === "preferences" ? { available: true, enabled: true, timeoutMs: 30_000 } - : { text: "다음 질문" }; + : { text: "다음 질문", reason: "suggested" }; }); const handler = createBrowserComposerHandler({ gatewayProxy: fixture.proxy, adminRpc: { call: call as never }, isOriginAllowed: (req) => req.headers.origin === "http://browser.test", }); + const handledRequests = vi.fn(); const server = createServer((req, res) => { void handler(req, res).then((handled) => { + handledRequests(); if (!handled) { res.statusCode = 404; res.end(); @@ -78,14 +82,18 @@ async function setup( body: JSON.stringify(body), signal, }); - return { ...fixture, call, post }; + return { ...fixture, call, post, log, handledRequests }; } describe("authenticated Web-only prediction ingress", () => { it("loads authorized history, excludes tools/reasoning, and pins the user", async () => { const { post, call, binding, user } = await setup(); const response = await post({ action: "predict", sessionKey: `agent:${binding.agentId}:main` }); - expect(await response.json()).toEqual({ text: "다음 질문" }); + expect(await response.json()).toEqual({ + text: "다음 질문", + reason: "suggested", + elapsedMs: expect.any(Number), + }); expect(call).toHaveBeenCalledWith( "platformclaw.composer", { @@ -128,7 +136,7 @@ describe("authenticated Web-only prediction ingress", () => { call.mockResolvedValueOnce({ available: false, enabled: false } as never); expect( await (await post({ action: "predict", sessionKey: `agent:${binding.agentId}:main` })).json(), - ).toEqual({ text: null }); + ).toEqual({ text: null, reason: "server_disabled", elapsedMs: expect.any(Number) }); expect(request).not.toHaveBeenCalled(); expect(call).toHaveBeenCalledTimes(1); }); @@ -147,7 +155,11 @@ describe("authenticated Web-only prediction ingress", () => { token: f.alice.token, }); const body = { action: "predict", sessionKey: conversation.sessionKey }; - expect(await (await post(body)).json()).toEqual({ text: "다음 질문" }); + expect(await (await post(body)).json()).toEqual({ + text: "다음 질문", + reason: "suggested", + elapsedMs: expect.any(Number), + }); call.mockClear(); await post(body, { cookie: "platformclaw_session=bob" }); expect( @@ -156,14 +168,20 @@ describe("authenticated Web-only prediction ingress", () => { call.mockImplementation(async (_method, value) => { if ((value as { action: string }).action === "predict") { f.store.spaces.setMember(f.bob.user.id, f.space.id, f.alice.user.id, null, 2); - return { text: "must not leak" }; + return { text: "must not leak", reason: "suggested" }; } return { available: true, enabled: true, timeoutMs: 30_000 }; }); - expect(await (await post(body)).json()).toEqual({ text: null }); + const denied = await post(body); + expect(denied.status).toBe(200); + expect(await denied.json()).toEqual({ + text: null, + reason: "forbidden", + elapsedMs: expect.any(Number), + }); }); it("caps the whole request at the configured deadline while Gateway history is waiting", async () => { - const { post, request, call, binding } = await setup(); + const { post, request, call, binding, log, handledRequests } = await setup(); call.mockResolvedValueOnce({ available: true, enabled: true, timeoutMs: 1000 } as never); let release!: (value: unknown) => void; request.mockImplementationOnce( @@ -174,11 +192,17 @@ describe("authenticated Web-only prediction ingress", () => { ); expect( await (await post({ action: "predict", sessionKey: `agent:${binding.agentId}:main` })).json(), - ).toEqual({ text: null }); + ).toEqual({ text: null, reason: "timeout", elapsedMs: expect.any(Number) }); release({ messages: [] }); + await vi.waitFor(() => expect(handledRequests).toHaveBeenCalledTimes(1)); expect( call.mock.calls.some(([, value]) => (value as { action: string }).action === "predict"), ).toBe(false); + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith("PlatformClaw composer prediction", { + reason: "timeout", + elapsedMs: expect.any(Number), + }); }); it("allows authorized history to finish after the old ten-second deadline", async () => { const { post, request, call, binding } = await setup(); @@ -201,6 +225,8 @@ describe("authenticated Web-only prediction ingress", () => { await (await post({ action: "predict", sessionKey: `agent:${binding.agentId}:main` })).json(), ).toEqual({ text: "다음 질문", + reason: "suggested", + elapsedMs: expect.any(Number), }); expect(call).toHaveBeenCalledWith( "platformclaw.composer", @@ -210,7 +236,7 @@ describe("authenticated Web-only prediction ingress", () => { ); }, 15_000); it("aborts model work when the browser cancels its prediction request", async () => { - const { post, call, binding } = await setup(); + const { post, call, binding, log } = await setup(); let inferenceSignal: AbortSignal | undefined; call.mockImplementation(async (_method, value, ...args: unknown[]) => { if ((value as { action: string }).action === "preferences") { @@ -232,5 +258,122 @@ describe("authenticated Web-only prediction ingress", () => { controller.abort(); await expect(pending).rejects.toMatchObject({ name: "AbortError" }); await vi.waitFor(() => expect(inferenceSignal?.aborted).toBe(true)); + expect(log).toHaveBeenCalledTimes(1); + expect(log).toHaveBeenCalledWith("PlatformClaw composer prediction", { + reason: "cancelled", + elapsedMs: expect.any(Number), + }); + }); + + it("keeps preference responses unchanged and does not log ordinary settings reads", async () => { + const { post, log } = await setup(); + expect(await (await post({ action: "preferences" })).json()).toEqual({ + available: true, + enabled: true, + timeoutMs: 30_000, + }); + expect(log).not.toHaveBeenCalled(); + }); + + it("reports the Gateway's fixed reason and provider status without logging conversation text", async () => { + const { post, call, binding, log } = await setup(); + call.mockResolvedValueOnce({ available: true, enabled: true, timeoutMs: 30_000 }); + call.mockResolvedValueOnce({ + text: null, + reason: "provider_http_error", + providerStatus: 401, + }); + const response = await post({ action: "predict", sessionKey: `agent:${binding.agentId}:main` }); + expect(response.status).toBe(200); + const diagnostic = { + reason: "provider_http_error", + providerStatus: 401, + elapsedMs: expect.any(Number), + }; + expect(await response.json()).toEqual({ text: null, ...diagnostic }); + expect(log).toHaveBeenCalledExactlyOnceWith("PlatformClaw composer prediction", diagnostic); + }); + + it("does not copy arbitrary diagnostics and marks an older Gateway's missing reason explicitly", async () => { + const { post, call, binding, log } = await setup(); + const body = { action: "predict", sessionKey: `agent:${binding.agentId}:main` }; + const secret = "private-upstream-value-must-not-be-logged"; + call.mockResolvedValueOnce({ available: true, enabled: true, timeoutMs: 30_000 }); + call.mockResolvedValueOnce({ + text: null, + reason: secret, + providerStatus: secret, + endpoint: secret, + }); + expect(await (await post(body)).json()).toEqual({ + text: null, + reason: "diagnostics_unavailable", + elapsedMs: expect.any(Number), + }); + expect(log).toHaveBeenCalledExactlyOnceWith("PlatformClaw composer prediction", { + reason: "diagnostics_unavailable", + elapsedMs: expect.any(Number), + }); + expect(JSON.stringify(log.mock.calls)).not.toContain(secret); + call.mockResolvedValueOnce({ available: true, enabled: true, timeoutMs: 30_000 }); + call.mockResolvedValueOnce({ text: "older Gateway suggestion" }); + expect(await (await post(body)).json()).toEqual({ + text: "older Gateway suggestion", + reason: "diagnostics_unavailable", + elapsedMs: expect.any(Number), + }); + }); + + it.each(["preferences_error", "history_error", "gateway_error"])( + "reports %s without exposing upstream exception contents", + async (reason) => { + const { post, call, request, binding, log } = await setup(); + const secret = "private-provider-error-with-credential-canary"; + if (reason === "preferences_error") { + call.mockRejectedValueOnce(new Error(secret)); + } else if (reason === "history_error") { + request.mockRejectedValueOnce(new Error(secret)); + } else { + call.mockResolvedValueOnce({ available: true, enabled: true, timeoutMs: 30_000 }); + call.mockRejectedValueOnce(new Error(secret)); + } + const response = await post({ + action: "predict", + sessionKey: `agent:${binding.agentId}:main`, + }); + expect(await response.json()).toEqual({ text: null, reason, elapsedMs: expect.any(Number) }); + expect(log).toHaveBeenCalledExactlyOnceWith("PlatformClaw composer prediction", { + reason, + elapsedMs: expect.any(Number), + }); + expect(JSON.stringify(log.mock.calls)).not.toContain(secret); + }, + ); + + it("explains missing text context and transcript changes without inference or stale output", async () => { + const { post, call, request, binding } = await setup(); + const body = { action: "predict", sessionKey: `agent:${binding.agentId}:main` }; + request.mockResolvedValueOnce({ messages: [{ role: "user", content: "질문" }] }); + expect(await (await post(body)).json()).toEqual({ + text: null, + reason: "history_ineligible", + elapsedMs: expect.any(Number), + }); + expect( + call.mock.calls.some(([, value]) => (value as { action: string }).action === "predict"), + ).toBe(false); + call.mockClear(); + request.mockResolvedValueOnce({ + messages: [ + { role: "user", content: "질문" }, + { role: "assistant", content: "답변" }, + ], + }); + request.mockResolvedValueOnce({ messages: [{ role: "user", content: "new user message" }] }); + expect(await (await post(body)).json()).toEqual({ + text: null, + reason: "history_changed", + elapsedMs: expect.any(Number), + }); }); }); diff --git a/packages/platformclaw-control-plane/src/browser-composer-http.ts b/packages/platformclaw-control-plane/src/browser-composer-http.ts index ef27e3e004..6c83d90163 100644 --- a/packages/platformclaw-control-plane/src/browser-composer-http.ts +++ b/packages/platformclaw-control-plane/src/browser-composer-http.ts @@ -3,10 +3,42 @@ import { asNullableRecord as asRecord } from "@openclaw/normalization-core/recor import { readPlatformClawSessionCookie } from "./browser-auth-http.js"; import { BrowserGatewayProxyError } from "./browser-gateway-contracts.js"; import { readBrowserJsonBody, sendBrowserJson as sendJson } from "./browser-http-shared.js"; +import { ControlPlaneAuthorizationError } from "./contracts.js"; import type { GatewayAdminRpc } from "./gateway-admin-rpc-client.js"; import type { PlatformClawBrowserGatewayPolicy } from "./web-ingress-contracts.js"; const PATH = "/platformclaw/api/composer-predictions"; +// The private RPC owns these codes. Never copy arbitrary upstream strings into logs. +const GATEWAY_REASONS = new Set([ + "suggested", + "disabled", + "prediction_busy", + "model_busy", + "invalid_request", + "invalid_context", + "model_unavailable", + "invalid_provider", + "unsupported_api", + "credentials_unready", + "destination_rejected", + "provider_http_error", + "response_missing", + "response_invalid", + "response_oversized", + "response_empty", + "no_suggestion", + "format_rejected", + "timeout", + "cancelled", + "model_preempted", + "provider_error", +]); + +type PredictionOutcome = { + text: string | null; + reason: string; + providerStatus?: number; +}; // Only text already authorized by the browser session policy crosses this boundary. // Tool output, reasoning, attachments, and personal memory never become prediction context. @@ -65,11 +97,35 @@ export function createBrowserComposerHandler(options: { return true; } const controller = new AbortController(); - const cancel = () => controller.abort(); const startedAt = performance.now(); + let completed = false; + let failureReason = "access_error"; + const finish = (status: number, outcome: PredictionOutcome, respond = true) => { + // Timeout, disconnect, and late inference share one terminal outcome. + // Log only fixed diagnostic fields, never conversation or exception content. + if (completed) { + return; + } + completed = true; + const diagnostic = { + reason: outcome.reason, + elapsedMs: Math.max(0, Math.round(performance.now() - startedAt)), + ...(outcome.providerStatus === undefined ? {} : { providerStatus: outcome.providerStatus }), + }; + console.info("PlatformClaw composer prediction", diagnostic); + if (respond) { + sendBrowserJson(res, status, { text: outcome.text, ...diagnostic }); + } + }; + const cancel = () => { + controller.abort(); + if (!res.writableEnded) { + finish(200, { text: null, reason: "cancelled" }, false); + } + }; const expire = () => { - cancel(); - sendBrowserJson(res, 200, { text: null }); + controller.abort(); + finish(200, { text: null, reason: "timeout" }); }; // Settings lookup stays bounded until the Gateway supplies its prediction budget. let timer = setTimeout(expire, 10_000); @@ -94,6 +150,7 @@ export function createBrowserComposerHandler(options: { const signal = controller.signal; signal.throwIfAborted(); const userId = access.user.id; + failureReason = "preferences_error"; if (params.action === "preferences") { if (params.enabled !== undefined && typeof params.enabled !== "boolean") { sendBrowserJson(res, 400, { error: "invalid preference" }); @@ -126,7 +183,10 @@ export function createBrowserComposerHandler(options: { timeoutMs: number; }>("platformclaw.composer", { action: "preferences", userId }, signal); if (!preference.available || !preference.enabled) { - sendBrowserJson(res, 200, { text: null }); + finish(200, { + text: null, + reason: preference.available ? "preference_disabled" : "server_disabled", + }); return true; } signal.throwIfAborted(); @@ -139,36 +199,61 @@ export function createBrowserComposerHandler(options: { } timer = setTimeout(expire, remainingMs); const sessionKey = params.sessionKey; - const load = async () => - conversationText( + const load = async () => { + failureReason = "history_error"; + return conversationText( await options.gatewayProxy.request(token, "chat.history", { sessionKey, limit: 12, maxChars: 2000, }), ); + }; const messages = await load(); signal.throwIfAborted(); if ( messages.at(-1)?.role !== "assistant" || !messages.some((message) => message.role === "user") ) { - sendBrowserJson(res, 200, { text: null }); + finish(200, { text: null, reason: "history_ineligible" }); return true; } - const result = await options.adminRpc.call( - "platformclaw.composer", - { action: "predict", userId, messages }, - signal, - { timeoutMs: preference.timeoutMs }, + failureReason = "gateway_error"; + const rawResult = asRecord( + await options.adminRpc.call( + "platformclaw.composer", + { action: "predict", userId, messages }, + signal, + { timeoutMs: preference.timeoutMs }, + ), ); + // Keep the shipped text field usable during a rolling Gateway update, but + // explicitly report missing/unknown diagnostics instead of guessing a cause. + const result: PredictionOutcome = { + text: + typeof rawResult?.text === "string" && rawResult.text.length <= 300 + ? rawResult.text + : null, + reason: + typeof rawResult?.reason === "string" && GATEWAY_REASONS.has(rawResult.reason) + ? rawResult.reason + : "diagnostics_unavailable", + ...(rawResult?.reason === "provider_http_error" && + Number.isInteger(rawResult.providerStatus) && + typeof rawResult.providerStatus === "number" && + rawResult.providerStatus >= 100 && + rawResult.providerStatus <= 599 + ? { providerStatus: rawResult.providerStatus } + : {}), + }; // Recheck ACL and the authoritative transcript after inference, including Space membership. const current = await load(); signal.throwIfAborted(); - sendBrowserJson( - res, + finish( 200, - JSON.stringify(current) === JSON.stringify(messages) ? result : { text: null }, + JSON.stringify(current) === JSON.stringify(messages) + ? result + : { text: null, reason: "history_changed" }, ); } catch (error) { const status = @@ -177,7 +262,19 @@ export function createBrowserComposerHandler(options: { ? 401 : 403 : 200; - sendBrowserJson(res, status, { text: null }); + finish(status, { + text: null, + reason: + error instanceof BrowserGatewayProxyError + ? error.code === "unauthenticated" + ? "unauthenticated" + : "forbidden" + : error instanceof ControlPlaneAuthorizationError + ? "forbidden" + : controller.signal.aborted + ? "cancelled" + : failureReason, + }); } finally { clearTimeout(timer); req.off("aborted", cancel);