diff --git a/extensions/ollama/src/stream-runtime.test.ts b/extensions/ollama/src/stream-runtime.test.ts index 6d8256a16fc..fd8ad1baaea 100644 --- a/extensions/ollama/src/stream-runtime.test.ts +++ b/extensions/ollama/src/stream-runtime.test.ts @@ -1,14 +1,23 @@ // Ollama tests cover stream runtime plugin behavior. import { afterEach, describe, expect, it, vi } from "vitest"; -const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({ +const { fetchWithSsrFGuardMock, ollamaStreamWarnMock } = vi.hoisted(() => ({ fetchWithSsrFGuardMock: vi.fn(), + ollamaStreamWarnMock: vi.fn(), })); vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ fetchWithSsrFGuard: fetchWithSsrFGuardMock, })); +vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createSubsystemLogger: () => ({ warn: ollamaStreamWarnMock }), + }; +}); + import { OLLAMA_INCOMPLETE_STREAM_ERROR, buildOllamaChatRequest, @@ -73,6 +82,7 @@ function expectIteratorEvent( afterEach(() => { fetchWithSsrFGuardMock.mockReset(); + ollamaStreamWarnMock.mockReset(); }); describe("buildOllamaChatRequest", () => { @@ -1433,9 +1443,12 @@ describe("buildAssistantMessage", () => { }); // Helper: build a ReadableStreamDefaultReader from NDJSON lines -function mockNdjsonReader(lines: string[]): ReadableStreamDefaultReader { +function mockNdjsonReader( + lines: string[], + options: { trailingNewline?: boolean } = {}, +): ReadableStreamDefaultReader { const encoder = new TextEncoder(); - const payload = lines.join("\n") + "\n"; + const payload = lines.join("\n") + (options.trailingNewline === false ? "" : "\n"); let consumed = false; return { read: async () => { @@ -1465,7 +1478,37 @@ async function expectDoneEventContent(lines: string[], expectedContent: unknown) }); } +async function expectNoParsedChunks(reader: ReadableStreamDefaultReader) { + const chunks = []; + for await (const chunk of parseNdjsonStream(reader)) { + chunks.push(chunk); + } + expect(chunks).toEqual([]); +} + describe("parseNdjsonStream", () => { + it("does not log a dangling surrogate for a malformed complete line", async () => { + const prefix = "x".repeat(119); + const reader = mockNdjsonReader([`${prefix}😀tail`]); + + await expectNoParsedChunks(reader); + + expect(ollamaStreamWarnMock).toHaveBeenCalledExactlyOnceWith( + `Skipping malformed NDJSON line: ${prefix}`, + ); + }); + + it("does not log a dangling surrogate for malformed trailing data", async () => { + const prefix = "x".repeat(119); + const reader = mockNdjsonReader([`${prefix}😀tail`], { trailingNewline: false }); + + await expectNoParsedChunks(reader); + + expect(ollamaStreamWarnMock).toHaveBeenCalledExactlyOnceWith( + `Skipping malformed trailing data: ${prefix}`, + ); + }); + it("parses text-only streaming chunks", async () => { const reader = mockNdjsonReader([ '{"model":"m","created_at":"t","message":{"role":"assistant","content":"Hello"},"done":false}', diff --git a/extensions/ollama/src/stream.ts b/extensions/ollama/src/stream.ts index 31deace2bab..13fcac565f7 100644 --- a/extensions/ollama/src/stream.ts +++ b/extensions/ollama/src/stream.ts @@ -36,6 +36,7 @@ import { normalizeLowercaseStringOrEmpty, readStringValue, } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime"; import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js"; import { shouldWrapOllamaCompatMoonshotThinking } from "./model-behavior.js"; import { normalizeOllamaWireModelId } from "./model-id.js"; @@ -52,8 +53,7 @@ import { const log = createSubsystemLogger("ollama-stream"); export const OLLAMA_NATIVE_BASE_URL = OLLAMA_DEFAULT_BASE_URL; -export const OLLAMA_INCOMPLETE_STREAM_ERROR = - "Ollama API stream ended without a final response"; +export const OLLAMA_INCOMPLETE_STREAM_ERROR = "Ollama API stream ended without a final response"; const OLLAMA_STREAM_COOPERATIVE_YIELD_INTERVAL_MS = 12; const OLLAMA_STREAM_COOPERATIVE_YIELD_MAX_EVENTS = 64; @@ -1067,7 +1067,7 @@ export async function* parseNdjsonStream( try { yield parseJsonPreservingUnsafeIntegers(trimmed) as OllamaChatResponse; } catch { - log.warn(`Skipping malformed NDJSON line: ${trimmed.slice(0, 120)}`); + log.warn(`Skipping malformed NDJSON line: ${truncateUtf16Safe(trimmed, 120)}`); } } } @@ -1076,7 +1076,7 @@ export async function* parseNdjsonStream( try { yield parseJsonPreservingUnsafeIntegers(buffer.trim()) as OllamaChatResponse; } catch { - log.warn(`Skipping malformed trailing data: ${buffer.trim().slice(0, 120)}`); + log.warn(`Skipping malformed trailing data: ${truncateUtf16Safe(buffer.trim(), 120)}`); } } }