diff --git a/CHANGELOG.md b/CHANGELOG.md index b80fe19ff7a..5965e9ddfc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Ollama: compose caller abort signals with guarded-fetch timeouts for native `/api/chat` streams, so `/stop` and early cancellation still interrupt local Ollama requests that also carry provider timeout budgets. Refs #74133. Thanks @obviyus. - CLI/logs: fall back to the configured Gateway file log when implicit loopback Gateway connections close or time out before or during `logs.tail`, so `openclaw logs` still works while diagnosing local-model Gateway disconnects. Refs #74078. Thanks @sakalaboator. - MCP/plugins: stringify non-array plugin tool results with chat-content coercion instead of default object stringification, so MCP callers receive useful JSON/text content from plugin tools. Thanks @vincentkoc. - Active Memory/QMD: run QMD boot refresh through a one-shot subprocess path, preserve interactive file watching, and align watcher dependency/build ignores with QMD's scanner so gateway startup avoids arming long-lived QMD watchers. Thanks @codexGW. diff --git a/extensions/ollama/src/stream-runtime.test.ts b/extensions/ollama/src/stream-runtime.test.ts index 840e14f68ca..8206a96ba02 100644 --- a/extensions/ollama/src/stream-runtime.test.ts +++ b/extensions/ollama/src/stream-runtime.test.ts @@ -23,6 +23,7 @@ type GuardedFetchCall = { url: string; init?: RequestInit; policy?: unknown; + signal?: AbortSignal; timeoutMs?: number; auditContext?: string; }; @@ -333,6 +334,29 @@ describe("createConfiguredOllamaCompatStreamWrapper", () => { ); }); + it("passes caller abort signals at guard level when a timeout is present", async () => { + await withMockNdjsonFetch( + [ + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}', + '{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":1}', + ], + async (fetchMock) => { + const signal = new AbortController().signal; + const stream = await createOllamaTestStream({ + baseUrl: "http://ollama-host:11434", + options: { signal, timeoutMs: 123_456 }, + }); + + await collectStreamEvents(stream); + + const request = getGuardedFetchCall(fetchMock); + expect(request.timeoutMs).toBe(123_456); + expect(request.signal).toBe(signal); + expect(request.init?.signal).toBeUndefined(); + }, + ); + }); + it("maps native Ollama max thinking to think=high on the wire", async () => { await withMockNdjsonFetch( [ @@ -1018,6 +1042,7 @@ async function createOllamaTestStream(params: { maxTokens?: number; temperature?: number; signal?: AbortSignal; + timeoutMs?: number; headers?: Record; }; }) { @@ -1401,8 +1426,9 @@ describe("createOllamaStreamFn", () => { const request = getGuardedFetchCall(fetchMock); expect(request.url).toBe("http://ollama-host:11434/api/chat"); expect(request.auditContext).toBe("ollama-stream.chat"); + expect(request.signal).toBe(signal); const requestInit = request.init ?? {}; - expect(requestInit.signal).toBe(signal); + expect(requestInit.signal).toBeUndefined(); if (typeof requestInit.body !== "string") { throw new Error("Expected string request body"); } diff --git a/extensions/ollama/src/stream.ts b/extensions/ollama/src/stream.ts index b5fcd916f7e..bbd6b6e11c0 100644 --- a/extensions/ollama/src/stream.ts +++ b/extensions/ollama/src/stream.ts @@ -1000,9 +1000,9 @@ export function createOllamaStreamFn( method: "POST", headers, body: JSON.stringify(body), - signal: options?.signal, }, policy: ssrfPolicy, + ...(options?.signal ? { signal: options.signal } : {}), timeoutMs: resolveOllamaRequestTimeoutMs( model, options as { requestTimeoutMs?: unknown; timeoutMs?: unknown } | undefined,