mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-15 02:30:49 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
235
openclaw/extensions/ollama/src/stream.test.ts
Normal file
235
openclaw/extensions/ollama/src/stream.test.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuardMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
||||
}));
|
||||
|
||||
import { buildAssistantMessage, createOllamaStreamFn } from "./stream.js";
|
||||
|
||||
function makeOllamaResponse(params: {
|
||||
content?: string;
|
||||
thinking?: string;
|
||||
reasoning?: string;
|
||||
tool_calls?: Array<{ function: { name: string; arguments: Record<string, unknown> } }>;
|
||||
}) {
|
||||
return {
|
||||
model: "qwen3.5",
|
||||
created_at: new Date().toISOString(),
|
||||
message: {
|
||||
role: "assistant" as const,
|
||||
content: params.content ?? "",
|
||||
...(params.thinking != null ? { thinking: params.thinking } : {}),
|
||||
...(params.reasoning != null ? { reasoning: params.reasoning } : {}),
|
||||
...(params.tool_calls ? { tool_calls: params.tool_calls } : {}),
|
||||
},
|
||||
done: true,
|
||||
prompt_eval_count: 100,
|
||||
eval_count: 50,
|
||||
};
|
||||
}
|
||||
|
||||
const MODEL_INFO = { api: "ollama", provider: "ollama", id: "qwen3.5" };
|
||||
|
||||
describe("buildAssistantMessage", () => {
|
||||
it("includes thinking block when response has thinking field", () => {
|
||||
const response = makeOllamaResponse({
|
||||
thinking: "Let me think about this",
|
||||
content: "The answer is 42",
|
||||
});
|
||||
const msg = buildAssistantMessage(response, MODEL_INFO);
|
||||
expect(msg.content).toHaveLength(2);
|
||||
expect(msg.content[0]).toEqual({ type: "thinking", thinking: "Let me think about this" });
|
||||
expect(msg.content[1]).toEqual({ type: "text", text: "The answer is 42" });
|
||||
});
|
||||
|
||||
it("includes thinking block when response has reasoning field", () => {
|
||||
const response = makeOllamaResponse({
|
||||
reasoning: "Step by step analysis",
|
||||
content: "Result is 7",
|
||||
});
|
||||
const msg = buildAssistantMessage(response, MODEL_INFO);
|
||||
expect(msg.content).toHaveLength(2);
|
||||
expect(msg.content[0]).toEqual({ type: "thinking", thinking: "Step by step analysis" });
|
||||
expect(msg.content[1]).toEqual({ type: "text", text: "Result is 7" });
|
||||
});
|
||||
|
||||
it("prefers thinking over reasoning when both are present", () => {
|
||||
const response = makeOllamaResponse({
|
||||
thinking: "From thinking field",
|
||||
reasoning: "From reasoning field",
|
||||
content: "Answer",
|
||||
});
|
||||
const msg = buildAssistantMessage(response, MODEL_INFO);
|
||||
expect(msg.content[0]).toEqual({ type: "thinking", thinking: "From thinking field" });
|
||||
});
|
||||
|
||||
it("omits thinking block when no thinking or reasoning field", () => {
|
||||
const response = makeOllamaResponse({
|
||||
content: "Just text",
|
||||
});
|
||||
const msg = buildAssistantMessage(response, MODEL_INFO);
|
||||
expect(msg.content).toHaveLength(1);
|
||||
expect(msg.content[0]).toEqual({ type: "text", text: "Just text" });
|
||||
});
|
||||
|
||||
it("omits thinking block when thinking field is empty", () => {
|
||||
const response = makeOllamaResponse({
|
||||
thinking: "",
|
||||
content: "Just text",
|
||||
});
|
||||
const msg = buildAssistantMessage(response, MODEL_INFO);
|
||||
expect(msg.content).toHaveLength(1);
|
||||
expect(msg.content[0]).toEqual({ type: "text", text: "Just text" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOllamaStreamFn thinking events", () => {
|
||||
afterEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
});
|
||||
|
||||
function makeNdjsonBody(chunks: Array<Record<string, unknown>>): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
const lines = chunks.map((c) => JSON.stringify(c) + "\n").join("");
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(lines));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("emits thinking_start, thinking_delta, and thinking_end events for thinking content", async () => {
|
||||
const thinkingChunks = [
|
||||
{
|
||||
model: "qwen3.5",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
message: { role: "assistant", content: "", thinking: "Step 1" },
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
model: "qwen3.5",
|
||||
created_at: "2026-01-01T00:00:01Z",
|
||||
message: { role: "assistant", content: "", thinking: " and step 2" },
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
model: "qwen3.5",
|
||||
created_at: "2026-01-01T00:00:02Z",
|
||||
message: { role: "assistant", content: "The answer", thinking: "" },
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
model: "qwen3.5",
|
||||
created_at: "2026-01-01T00:00:03Z",
|
||||
message: { role: "assistant", content: "" },
|
||||
done: true,
|
||||
done_reason: "stop",
|
||||
prompt_eval_count: 10,
|
||||
eval_count: 5,
|
||||
},
|
||||
];
|
||||
|
||||
const body = makeNdjsonBody(thinkingChunks);
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response(body, { status: 200 }),
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
const streamFn = createOllamaStreamFn("http://localhost:11434");
|
||||
const stream = streamFn(
|
||||
{ api: "ollama", provider: "ollama", id: "qwen3.5", contextWindow: 65536 } as never,
|
||||
{ messages: [{ role: "user", content: "test" }] } as never,
|
||||
{},
|
||||
);
|
||||
|
||||
const events: Array<{ type: string; [key: string]: unknown }> = [];
|
||||
for await (const event of stream as AsyncIterable<{ type: string; [key: string]: unknown }>) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const eventTypes = events.map((e) => e.type);
|
||||
|
||||
expect(eventTypes).toContain("thinking_start");
|
||||
expect(eventTypes).toContain("thinking_delta");
|
||||
expect(eventTypes).toContain("thinking_end");
|
||||
expect(eventTypes).toContain("text_start");
|
||||
expect(eventTypes).toContain("text_delta");
|
||||
expect(eventTypes).toContain("done");
|
||||
|
||||
const thinkingStartIndex = eventTypes.indexOf("thinking_start");
|
||||
const textStartIndex = eventTypes.indexOf("text_start");
|
||||
expect(thinkingStartIndex).toBeLessThan(textStartIndex);
|
||||
|
||||
const thinkingEndIndex = eventTypes.indexOf("thinking_end");
|
||||
expect(thinkingEndIndex).toBeLessThan(textStartIndex);
|
||||
|
||||
const thinkingDeltas = events.filter((e) => e.type === "thinking_delta");
|
||||
expect(thinkingDeltas).toHaveLength(2);
|
||||
expect(thinkingDeltas[0].delta).toBe("Step 1");
|
||||
expect(thinkingDeltas[1].delta).toBe(" and step 2");
|
||||
|
||||
const thinkingStart = events.find((e) => e.type === "thinking_start");
|
||||
expect(thinkingStart?.contentIndex).toBe(0);
|
||||
const textStart = events.find((e) => e.type === "text_start");
|
||||
expect(textStart?.contentIndex).toBe(1);
|
||||
|
||||
const done = events.find((e) => e.type === "done") as { message?: { content: unknown[] } };
|
||||
const content = done?.message?.content ?? [];
|
||||
expect(content[0]).toMatchObject({ type: "thinking", thinking: "Step 1 and step 2" });
|
||||
expect(content[1]).toMatchObject({ type: "text", text: "The answer" });
|
||||
});
|
||||
|
||||
it("streams without thinking events when no thinking content is present", async () => {
|
||||
const chunks = [
|
||||
{
|
||||
model: "qwen3.5",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
message: { role: "assistant", content: "Hello" },
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
model: "qwen3.5",
|
||||
created_at: "2026-01-01T00:00:01Z",
|
||||
message: { role: "assistant", content: "" },
|
||||
done: true,
|
||||
done_reason: "stop",
|
||||
prompt_eval_count: 10,
|
||||
eval_count: 5,
|
||||
},
|
||||
];
|
||||
|
||||
const body = makeNdjsonBody(chunks);
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response(body, { status: 200 }),
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
const streamFn = createOllamaStreamFn("http://localhost:11434");
|
||||
const stream = streamFn(
|
||||
{ api: "ollama", provider: "ollama", id: "qwen3.5", contextWindow: 65536 } as never,
|
||||
{ messages: [{ role: "user", content: "test" }] } as never,
|
||||
{},
|
||||
);
|
||||
|
||||
const events: Array<{ type: string }> = [];
|
||||
for await (const event of stream as AsyncIterable<{ type: string }>) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const eventTypes = events.map((e) => e.type);
|
||||
expect(eventTypes).not.toContain("thinking_start");
|
||||
expect(eventTypes).not.toContain("thinking_delta");
|
||||
expect(eventTypes).not.toContain("thinking_end");
|
||||
expect(eventTypes).toContain("text_start");
|
||||
expect(eventTypes).toContain("text_delta");
|
||||
expect(eventTypes).toContain("done");
|
||||
|
||||
const textStart = events.find((e) => e.type === "text_start") as { contentIndex?: number };
|
||||
expect(textStart?.contentIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue