mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-10-26 18:55:22 +00:00
从官方 Git 仓库导入 chandra447/pi-hermes-memory 在 tag v0.9.10 (df624056a2e8c68bc89ba0a81e1c120856ec9665,2026-10-02)的源码快照。 本次仅导入源码,尚未接入组合包:package.json、加载顺序、依赖与权限策略 均未改动。 - 原样导入 src/、tests/、docs/、scripts/、README、CHANGELOG、PLAN、 AGENTS 与 LICENSE;该快照共 156 个上游文件。 - 剔除嵌套 .git、上游 .github 工作流、.ralph/、.claude/、node_modules、 覆盖率与构建产物。 - 新增中文 UPSTREAM.md,记录来源,以及启用前必须完成的待决事项: 在 pi-compaction 之前注册加载顺序、better-sqlite3 版本与根 allowScripts 对齐、strip-ansi 解析确认。 - 说明选择 tag 而非 main 的原因:导入时上游 main 已领先至 24fcd8ad26fc27badb8d7dbaf8034645de07b893(2026-10-11),选 tag 是为 对应一个已发布、可复现的版本。 尚未接线。启用前还需反转 tests/extension-load.test.ts 中"不得加载 Hermes"的断言,并同步 pi-tool-search 分组、权限策略与文档。 验证:快照 156 个上游文件全部导入,目录内仅多出 UPSTREAM.md 一份; 已确认与上游 main 内容存在差异,证明取的是 tag 而非 main。
1910 lines
66 KiB
TypeScript
1910 lines
66 KiB
TypeScript
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
import assert from "node:assert";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as http from "node:http";
|
|
import * as path from "node:path";
|
|
import { MemoryStore } from "../../src/store/memory-store.js";
|
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
import {
|
|
applyReviewOperations,
|
|
buildDirectReviewCompletionOptions,
|
|
isAuthRejection,
|
|
parseReviewOperations,
|
|
runDirectMemoryCompletion,
|
|
} from "../../src/handlers/review-memory-ops.js";
|
|
import { DatabaseManager } from "../../src/store/db.js";
|
|
import { getMemories, reconcileMarkdownMemoryScope } from "../../src/store/sqlite-memory-store.js";
|
|
import { acquireMarkdownMutationLock } from "../../src/store/markdown-mutation-lock.js";
|
|
import {
|
|
DIRECT_CONSOLIDATION_SYSTEM_PROMPT,
|
|
DIRECT_CORRECTION_SYSTEM_PROMPT,
|
|
DIRECT_FLUSH_SYSTEM_PROMPT,
|
|
DIRECT_REVIEW_SYSTEM_PROMPT,
|
|
MEMORY_FILE,
|
|
} from "../../src/constants.js";
|
|
|
|
function mockModel(reasoning: boolean): Model<Api> {
|
|
return {
|
|
id: "test-model",
|
|
provider: "test",
|
|
api: "openai-completions",
|
|
reasoning,
|
|
} as Model<Api>;
|
|
}
|
|
|
|
describe("buildDirectReviewCompletionOptions", () => {
|
|
it("forwards auth env and preserves reasoning level", () => {
|
|
const signal = new AbortController().signal;
|
|
const options = buildDirectReviewCompletionOptions(
|
|
mockModel(true),
|
|
{
|
|
apiKey: "sk-test",
|
|
headers: { "X-Test": "1" },
|
|
env: { CUSTOM_BASE_URL: "https://proxy.example" },
|
|
},
|
|
"minimal",
|
|
signal,
|
|
undefined,
|
|
);
|
|
|
|
assert.strictEqual(options.apiKey, "sk-test");
|
|
assert.deepStrictEqual(options.headers, { "X-Test": "1" });
|
|
assert.deepStrictEqual(options.env, { CUSTOM_BASE_URL: "https://proxy.example" });
|
|
assert.strictEqual(options.reasoning, "minimal");
|
|
assert.strictEqual(options.signal, signal);
|
|
});
|
|
|
|
it("omits reasoning when thinking is off or model does not support it", () => {
|
|
const signal = new AbortController().signal;
|
|
const off = buildDirectReviewCompletionOptions(
|
|
mockModel(true),
|
|
{ apiKey: "sk-test" },
|
|
"off",
|
|
signal,
|
|
undefined,
|
|
);
|
|
const nonReasoning = buildDirectReviewCompletionOptions(
|
|
mockModel(false),
|
|
{ apiKey: "sk-test" },
|
|
"high",
|
|
signal,
|
|
undefined,
|
|
);
|
|
|
|
assert.strictEqual(off.reasoning, undefined);
|
|
assert.strictEqual(nonReasoning.reasoning, undefined);
|
|
});
|
|
});
|
|
|
|
describe("provider auth resolution", () => {
|
|
function registryWithAuthResponses(...keys: string[]) {
|
|
let authCalls = 0;
|
|
const modelRegistry = {
|
|
getApiKeyAndHeaders: async () => ({
|
|
ok: true as const,
|
|
apiKey: keys[Math.min(authCalls++, keys.length - 1)],
|
|
}),
|
|
getAll: () => [mockModel(false)],
|
|
getAvailable: () => [mockModel(false)],
|
|
};
|
|
return { get authCalls() { return authCalls; }, modelRegistry };
|
|
}
|
|
|
|
function completionStub(behaviour: (apiKey: string | undefined, attempt: number) => unknown) {
|
|
const usedKeys: Array<string | undefined> = [];
|
|
const complete = async (_model: unknown, _request: unknown, options: { apiKey?: string }) => {
|
|
usedKeys.push(options.apiKey);
|
|
const outcome = behaviour(options.apiKey, usedKeys.length);
|
|
if (outcome instanceof Error) throw outcome;
|
|
return outcome;
|
|
};
|
|
return { usedKeys, complete };
|
|
}
|
|
|
|
function registryWithHeaderAuth(...headers: Array<Record<string, string | null>>) {
|
|
let authCalls = 0;
|
|
return {
|
|
getApiKeyAndHeaders: async () => ({
|
|
ok: true as const,
|
|
headers: headers[Math.min(authCalls++, headers.length - 1)],
|
|
}),
|
|
hasConfiguredAuth: () => false,
|
|
isUsingOAuth: () => false,
|
|
getAll: () => [mockModel(false)],
|
|
getAvailable: () => [mockModel(false)],
|
|
get authCalls() { return authCalls; },
|
|
};
|
|
}
|
|
|
|
const emptyOperations = {
|
|
stopReason: "stop",
|
|
content: [{ type: "text", text: JSON.stringify({ operations: [] }) }],
|
|
};
|
|
|
|
function directOptions() {
|
|
return { userPrompt: "u", systemPrompt: "s", config: {} };
|
|
}
|
|
|
|
async function runReview(modelRegistry: unknown, complete: unknown) {
|
|
return runDirectMemoryCompletion(
|
|
{ model: mockModel(false), modelRegistry } as never,
|
|
null as never,
|
|
null,
|
|
directOptions(),
|
|
null,
|
|
null,
|
|
{ completeSimple: complete as never },
|
|
);
|
|
}
|
|
|
|
it("resolves credentials through the public registry API", async () => {
|
|
const registry = registryWithAuthResponses("current-key");
|
|
const { usedKeys, complete } = completionStub(() => emptyOperations);
|
|
|
|
const result = await runDirectMemoryCompletion(
|
|
{ model: mockModel(false), modelRegistry: registry.modelRegistry } as never,
|
|
null as never,
|
|
null,
|
|
directOptions(),
|
|
null,
|
|
null,
|
|
{ completeSimple: complete as never },
|
|
);
|
|
|
|
assert.strictEqual(result.ok, true);
|
|
assert.strictEqual(registry.authCalls, 1);
|
|
assert.deepStrictEqual(usedKeys, ["current-key"]);
|
|
});
|
|
|
|
it("runs direct review with header-only OAuth request auth", async () => {
|
|
const headers = {
|
|
Authorization: "Bearer kimi-oauth-token",
|
|
"User-Agent": "pi-coding-agent",
|
|
"X-Drop": null,
|
|
};
|
|
const usedHeaders: Array<Record<string, string | null> | undefined> = [];
|
|
const complete = async (_model: unknown, _request: unknown, options: { headers?: Record<string, string | null> }) => {
|
|
usedHeaders.push(options.headers);
|
|
return emptyOperations;
|
|
};
|
|
|
|
const result = await runReview(registryWithHeaderAuth(headers), complete);
|
|
|
|
assert.strictEqual(result.ok, true);
|
|
assert.deepStrictEqual(usedHeaders, [headers]);
|
|
});
|
|
|
|
for (const { name, headers } of [
|
|
{ name: "empty headers", headers: {} },
|
|
{ name: "User-Agent-only headers", headers: { "User-Agent": "pi-coding-agent" } },
|
|
{ name: "null credential headers", headers: { Authorization: null, "x-api-key": null } },
|
|
]) {
|
|
it(`rejects ${name} as missing request authentication`, async () => {
|
|
let completionCalls = 0;
|
|
const complete = async () => {
|
|
completionCalls++;
|
|
return emptyOperations;
|
|
};
|
|
|
|
const result = await runReview(registryWithHeaderAuth(headers), complete);
|
|
|
|
assert.strictEqual(result.ok, false);
|
|
assert.strictEqual(result.fallbackReason, "no_auth");
|
|
assert.strictEqual(completionCalls, 0);
|
|
});
|
|
}
|
|
|
|
// real shape for amazon-bedrock with AWS_PROFILE: pi returns { ok: true } and nothing else
|
|
function ambientRegistry(isUsingOAuth: boolean) {
|
|
return {
|
|
getApiKeyAndHeaders: async () => ({ ok: true as const }),
|
|
hasConfiguredAuth: () => true,
|
|
isUsingOAuth: () => isUsingOAuth,
|
|
getAll: () => [mockModel(false)],
|
|
getAvailable: () => [mockModel(false)],
|
|
};
|
|
}
|
|
|
|
it("runs direct review for providers with ambient credentials", async () => {
|
|
const { usedKeys, complete } = completionStub(() => emptyOperations);
|
|
|
|
const result = await runReview(ambientRegistry(false), complete);
|
|
|
|
assert.strictEqual(result.ok, true);
|
|
assert.deepStrictEqual(usedKeys, [undefined]);
|
|
});
|
|
|
|
it("rejects configured OAuth that resolves to no credentials", async () => {
|
|
const { usedKeys, complete } = completionStub(() => emptyOperations);
|
|
|
|
const result = await runReview(ambientRegistry(true), complete);
|
|
|
|
assert.strictEqual(result.ok, false);
|
|
assert.strictEqual(result.fallbackReason, "no_auth");
|
|
assert.deepStrictEqual(usedKeys, []);
|
|
});
|
|
|
|
it("re-resolves credentials after a provider auth rejection", async () => {
|
|
const { modelRegistry } = registryWithAuthResponses("revoked-key", "rotated-key");
|
|
const { usedKeys, complete } = completionStub((_key, attempt) => {
|
|
if (attempt > 1) return emptyOperations;
|
|
return new Error("HTTP 401 Unauthorized: invalid api key");
|
|
});
|
|
|
|
const result = await runDirectMemoryCompletion(
|
|
{ model: mockModel(false), modelRegistry } as never,
|
|
null as never,
|
|
null,
|
|
directOptions(),
|
|
null,
|
|
null,
|
|
{ completeSimple: complete as never },
|
|
);
|
|
|
|
assert.strictEqual(result.ok, true);
|
|
assert.deepStrictEqual(usedKeys, ["revoked-key", "rotated-key"]);
|
|
});
|
|
|
|
it("re-resolves rotated header-only OAuth credentials after rejection", async () => {
|
|
const usedHeaders: Array<Record<string, string> | undefined> = [];
|
|
const complete = async (_model: unknown, _request: unknown, options: { headers?: Record<string, string> }) => {
|
|
usedHeaders.push(options.headers);
|
|
if (usedHeaders.length === 1) throw new Error("HTTP 401 Unauthorized: token expired");
|
|
return emptyOperations;
|
|
};
|
|
const modelRegistry = registryWithHeaderAuth(
|
|
{ Authorization: "Bearer stale-token" },
|
|
{ Authorization: "Bearer fresh-token" },
|
|
);
|
|
|
|
const result = await runDirectMemoryCompletion(
|
|
{ model: mockModel(false), modelRegistry } as never,
|
|
null as never,
|
|
null,
|
|
directOptions(),
|
|
null,
|
|
null,
|
|
{ completeSimple: complete as never },
|
|
);
|
|
|
|
assert.strictEqual(result.ok, true);
|
|
assert.deepStrictEqual(usedHeaders, [
|
|
{ Authorization: "Bearer stale-token" },
|
|
{ Authorization: "Bearer fresh-token" },
|
|
]);
|
|
});
|
|
|
|
it("does not retry when the refreshed key is the same one the provider rejected", async () => {
|
|
const { modelRegistry } = registryWithAuthResponses("only-key");
|
|
const { usedKeys, complete } = completionStub(() => new Error("HTTP 401 Unauthorized"));
|
|
|
|
const result = await runDirectMemoryCompletion(
|
|
{ model: mockModel(false), modelRegistry } as never,
|
|
null as never,
|
|
null,
|
|
directOptions(),
|
|
null,
|
|
null,
|
|
{ completeSimple: complete as never },
|
|
);
|
|
|
|
assert.strictEqual(result.ok, false);
|
|
assert.strictEqual(result.fallbackReason, "provider_error");
|
|
assert.strictEqual(usedKeys.length, 1, "an unchanged key means a real auth problem, not a rotation race");
|
|
});
|
|
|
|
it("does not retry an unchanged header-only OAuth credential", async () => {
|
|
let completionCalls = 0;
|
|
const complete = async () => {
|
|
completionCalls++;
|
|
throw new Error("HTTP 401 Unauthorized");
|
|
};
|
|
const modelRegistry = registryWithHeaderAuth({ Authorization: "Bearer unchanged-token" });
|
|
|
|
const result = await runDirectMemoryCompletion(
|
|
{ model: mockModel(false), modelRegistry } as never,
|
|
null as never,
|
|
null,
|
|
directOptions(),
|
|
null,
|
|
null,
|
|
{ completeSimple: complete as never },
|
|
);
|
|
|
|
assert.strictEqual(result.ok, false);
|
|
assert.strictEqual(result.fallbackReason, "provider_error");
|
|
assert.strictEqual(completionCalls, 1);
|
|
});
|
|
|
|
it("rotates header-only auth after an error assistant 401 response", async () => {
|
|
const usedHeaders: Array<Record<string, string | null> | undefined> = [];
|
|
const complete = async (_model: unknown, _request: unknown, options: { headers?: Record<string, string | null> }) => {
|
|
usedHeaders.push(options.headers);
|
|
if (usedHeaders.length === 1) {
|
|
return { stopReason: "error", errorMessage: "HTTP 401 Unauthorized: token expired" };
|
|
}
|
|
return emptyOperations;
|
|
};
|
|
const modelRegistry = registryWithHeaderAuth(
|
|
{ Authorization: "Bearer stale-token" },
|
|
{ Authorization: "Bearer fresh-token" },
|
|
);
|
|
|
|
const result = await runDirectMemoryCompletion(
|
|
{ model: mockModel(false), modelRegistry } as never,
|
|
null as never,
|
|
null,
|
|
directOptions(),
|
|
null,
|
|
null,
|
|
{ completeSimple: complete as never },
|
|
);
|
|
|
|
assert.strictEqual(result.ok, true);
|
|
assert.deepStrictEqual(usedHeaders, [
|
|
{ Authorization: "Bearer stale-token" },
|
|
{ Authorization: "Bearer fresh-token" },
|
|
]);
|
|
});
|
|
|
|
it("does not retry when only the credential header name casing changed", async () => {
|
|
let completionCalls = 0;
|
|
const complete = async () => {
|
|
completionCalls++;
|
|
throw new Error("HTTP 401 Unauthorized");
|
|
};
|
|
const modelRegistry = registryWithHeaderAuth(
|
|
{ Authorization: "Bearer same-token" },
|
|
{ authorization: "Bearer same-token" },
|
|
);
|
|
|
|
const result = await runDirectMemoryCompletion(
|
|
{ model: mockModel(false), modelRegistry } as never,
|
|
null as never,
|
|
null,
|
|
directOptions(),
|
|
null,
|
|
null,
|
|
{ completeSimple: complete as never },
|
|
);
|
|
|
|
assert.strictEqual(result.ok, false);
|
|
assert.strictEqual(result.fallbackReason, "provider_error");
|
|
assert.strictEqual(completionCalls, 1);
|
|
});
|
|
|
|
it("retries once when a duplicate-case credential header value changes", async () => {
|
|
const stale = { Authorization: "Bearer stale", authorization: "Bearer shared" };
|
|
const fresh = { Authorization: "Bearer fresh", authorization: "Bearer shared" };
|
|
const usedHeaders: Array<Record<string, string | null> | undefined> = [];
|
|
const complete = async (_model: unknown, _request: unknown, options: { headers?: Record<string, string | null> }) => {
|
|
usedHeaders.push(options.headers);
|
|
if (usedHeaders.length === 1) {
|
|
return { stopReason: "error", errorMessage: "HTTP 401 Unauthorized: token expired" };
|
|
}
|
|
return emptyOperations;
|
|
};
|
|
const modelRegistry = registryWithHeaderAuth(stale, fresh);
|
|
|
|
const result = await runReview(modelRegistry, complete);
|
|
|
|
assert.strictEqual(result.ok, true);
|
|
assert.strictEqual(modelRegistry.authCalls, 2);
|
|
assert.deepStrictEqual(usedHeaders, [stale, fresh]);
|
|
});
|
|
|
|
it("returns provider_error after a second auth failure without a third completion", async () => {
|
|
let completionCalls = 0;
|
|
const complete = async () => {
|
|
completionCalls++;
|
|
if (completionCalls <= 2) {
|
|
return { stopReason: "error", errorMessage: "HTTP 401 Unauthorized" };
|
|
}
|
|
return emptyOperations;
|
|
};
|
|
const modelRegistry = registryWithHeaderAuth(
|
|
{ Authorization: "Bearer stale-token" },
|
|
{ Authorization: "Bearer still-bad-token" },
|
|
{ Authorization: "Bearer should-not-be-used" },
|
|
);
|
|
|
|
const result = await runReview(modelRegistry, complete);
|
|
|
|
assert.strictEqual(result.ok, false);
|
|
assert.strictEqual(result.fallbackReason, "provider_error");
|
|
assert.strictEqual(completionCalls, 2);
|
|
assert.strictEqual(modelRegistry.authCalls, 2);
|
|
});
|
|
|
|
it("classifies provider auth rejections without swallowing other failures", () => {
|
|
for (const message of [
|
|
"HTTP 401 Unauthorized",
|
|
"403 Forbidden",
|
|
"invalid_api_key",
|
|
"Invalid API key provided",
|
|
"authentication failed",
|
|
"token expired",
|
|
"subscription key revoked",
|
|
]) {
|
|
assert.strictEqual(isAuthRejection(message), true, message);
|
|
}
|
|
|
|
for (const message of [
|
|
"HTTP 500 Internal Server Error",
|
|
"429 rate limit exceeded",
|
|
"socket hang up",
|
|
"context length exceeded",
|
|
]) {
|
|
assert.strictEqual(isAuthRejection(message), false, message);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("fallback model chain", () => {
|
|
function twoModelRegistry() {
|
|
const m1 = { id: "m1", provider: "p1", api: "openai-completions", reasoning: false } as Model<Api>;
|
|
const m2 = { id: "m2", provider: "p2", api: "openai-completions", reasoning: false } as Model<Api>;
|
|
let authCalls = 0;
|
|
const registry = {
|
|
getApiKeyAndHeaders: async () => {
|
|
authCalls++;
|
|
return { ok: true as const, apiKey: "k" };
|
|
},
|
|
getAll: () => [m1, m2],
|
|
getAvailable: () => [m1, m2],
|
|
};
|
|
return { registry, get authCalls() { return authCalls; } };
|
|
}
|
|
|
|
function chainOptions(signal?: AbortSignal) {
|
|
return {
|
|
userPrompt: "u",
|
|
systemPrompt: "s",
|
|
config: { llmModelOverride: "p1/m1", llmFallbackModels: ["p2/m2"] },
|
|
...(signal ? { signal } : {}),
|
|
};
|
|
}
|
|
|
|
const emptyReview = {
|
|
stopReason: "stop",
|
|
content: [{ type: "text", text: JSON.stringify({ operations: [] }) }],
|
|
};
|
|
|
|
it("stops the chain when the caller aborts instead of trying the next model", async () => {
|
|
const caller = new AbortController();
|
|
const attempted: string[] = [];
|
|
const complete = async (model: Model<Api>) => {
|
|
attempted.push(model.id);
|
|
caller.abort();
|
|
return { stopReason: "aborted" };
|
|
};
|
|
const chain = twoModelRegistry();
|
|
|
|
const result = await runDirectMemoryCompletion(
|
|
{ model: undefined, modelRegistry: chain.registry } as never,
|
|
null as never,
|
|
null,
|
|
chainOptions(caller.signal),
|
|
null,
|
|
null,
|
|
{ completeSimple: complete as never },
|
|
);
|
|
|
|
assert.strictEqual(result.ok, false);
|
|
assert.strictEqual(result.fallbackReason, "aborted");
|
|
assert.deepStrictEqual(attempted, ["m1"]);
|
|
assert.strictEqual(chain.authCalls, 1);
|
|
});
|
|
|
|
it("still tries the next model after a per-model timeout while the caller is alive", async () => {
|
|
const caller = new AbortController();
|
|
const attempted: string[] = [];
|
|
const complete = async (model: Model<Api>) => {
|
|
attempted.push(model.id);
|
|
if (attempted.length === 1) return { stopReason: "aborted" };
|
|
return emptyReview;
|
|
};
|
|
|
|
const chain = twoModelRegistry();
|
|
const result = await runDirectMemoryCompletion(
|
|
{ model: undefined, modelRegistry: chain.registry } as never,
|
|
null as never,
|
|
null,
|
|
chainOptions(caller.signal),
|
|
null,
|
|
null,
|
|
{ completeSimple: complete as never },
|
|
);
|
|
|
|
assert.deepStrictEqual(attempted, ["m1", "m2"]);
|
|
assert.strictEqual(result.ok, true);
|
|
});
|
|
});
|
|
|
|
describe("parseReviewOperations", () => {
|
|
it("parses valid JSON operations", () => {
|
|
const parsed = parseReviewOperations(JSON.stringify({
|
|
operations: [
|
|
{ action: "add", target: "memory", content: "uses pnpm" },
|
|
],
|
|
}));
|
|
|
|
assert.deepStrictEqual(parsed, [
|
|
{ action: "add", target: "memory", content: "uses pnpm" },
|
|
]);
|
|
});
|
|
|
|
it("returns empty array for nothing-to-save text", () => {
|
|
assert.deepStrictEqual(parseReviewOperations("Nothing to save."), []);
|
|
});
|
|
|
|
it("returns null for invalid JSON", () => {
|
|
assert.strictEqual(parseReviewOperations("not json at all"), null);
|
|
});
|
|
|
|
it("extracts JSON from fenced blocks", () => {
|
|
const parsed = parseReviewOperations("```json\n{\"operations\":[{\"action\":\"add\",\"target\":\"user\",\"content\":\"prefers dark mode\"}]}\n```");
|
|
assert.deepStrictEqual(parsed, [
|
|
{ action: "add", target: "user", content: "prefers dark mode" },
|
|
]);
|
|
});
|
|
|
|
it("prefers the last operations object when CoT restates the schema first (#197)", () => {
|
|
const parsed = parseReviewOperations(
|
|
'The schema is {"operations":[]} but I will save:\n{"operations":[{"action":"add","target":"user","content":"prefers dark mode"}]}',
|
|
);
|
|
|
|
assert.deepStrictEqual(parsed, [
|
|
{ action: "add", target: "user", content: "prefers dark mode" },
|
|
]);
|
|
});
|
|
|
|
it("still parses a single object surrounded by prose via the first-to-last slice", () => {
|
|
const parsed = parseReviewOperations(
|
|
'Sure — here it is:\n{"operations":[{"action":"add","target":"user","content":"prefers dark mode"}]}\nDone.',
|
|
);
|
|
|
|
assert.deepStrictEqual(parsed, [
|
|
{ action: "add", target: "user", content: "prefers dark mode" },
|
|
]);
|
|
});
|
|
|
|
it("returns null when no candidate object carries an operations array", () => {
|
|
assert.strictEqual(parseReviewOperations("checked {\"a\":1} and {\"b\":2} — nothing worth saving"), null);
|
|
});
|
|
|
|
it("does not parse live operations out of any direct prompt (schema echo, #197)", () => {
|
|
for (const prompt of [
|
|
DIRECT_REVIEW_SYSTEM_PROMPT,
|
|
DIRECT_FLUSH_SYSTEM_PROMPT,
|
|
DIRECT_CONSOLIDATION_SYSTEM_PROMPT,
|
|
DIRECT_CORRECTION_SYSTEM_PROMPT,
|
|
]) {
|
|
const parsed = parseReviewOperations(prompt);
|
|
assert.ok(
|
|
parsed === null || parsed.length === 0,
|
|
`direct prompt must not contain a parseable operations example, got ${JSON.stringify(parsed)}`,
|
|
);
|
|
}
|
|
});
|
|
|
|
it("parses a trailing answer when a fenced non-ops object comes first (#235)", () => {
|
|
// The fence holds a well-formed object without an `operations` array. It
|
|
// must be declined so the slice/scan paths can reach the real answer —
|
|
// claiming it here would recreate the parse_error → subprocess
|
|
// double-spend on a response that contains a valid answer.
|
|
const parsed = parseReviewOperations(
|
|
'```json\n{"note":"no ops here"}\n```\nFinal:\n{"operations":[{"action":"add","target":"user","content":"real answer"}]}',
|
|
);
|
|
|
|
assert.deepStrictEqual(parsed, [
|
|
{ action: "add", target: "user", content: "real answer" },
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("applyReviewOperations", () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "review-ops-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("applies add operations to memory store", async () => {
|
|
const store = new MemoryStore({
|
|
memoryDir: tmpDir,
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
autoConsolidate: true,
|
|
});
|
|
await store.loadFromDisk();
|
|
|
|
const result = await applyReviewOperations(store, null, [
|
|
{ action: "add", target: "memory", content: "prefers biome over eslint" },
|
|
]);
|
|
|
|
assert.strictEqual(result.appliedCount, 1);
|
|
assert.strictEqual(result.skippedCount, 0);
|
|
assert.ok(store.getMemoryEntries().some((entry) => entry.includes("prefers biome over eslint")));
|
|
});
|
|
|
|
it("skips project operations when project store is unavailable", async () => {
|
|
const store = new MemoryStore({
|
|
memoryDir: tmpDir,
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
autoConsolidate: true,
|
|
});
|
|
await store.loadFromDisk();
|
|
|
|
const result = await applyReviewOperations(store, null, [
|
|
{ action: "add", target: "project", content: "api uses /v2" },
|
|
]);
|
|
|
|
assert.strictEqual(result.appliedCount, 0);
|
|
assert.strictEqual(result.skippedCount, 1);
|
|
});
|
|
|
|
it("rolls back the entire atomic plan when a later operation fails", async () => {
|
|
const store = new MemoryStore({
|
|
memoryDir: tmpDir,
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
autoConsolidate: true,
|
|
});
|
|
await store.loadFromDisk();
|
|
await store.add("memory", "keep this original entry");
|
|
const memoryPath = path.join(tmpDir, "MEMORY.md");
|
|
const beforeEntries = store.getMemoryEntries();
|
|
const beforeDisk = await fs.readFile(memoryPath, "utf8");
|
|
|
|
const result = await applyReviewOperations(
|
|
store,
|
|
null,
|
|
[
|
|
{ action: "remove", target: "memory", old_text: "keep this" },
|
|
{ action: "remove", target: "memory", old_text: "missing later entry" },
|
|
],
|
|
null,
|
|
null,
|
|
{ requireAtomicShrink: true, expectedTarget: "memory" },
|
|
);
|
|
|
|
assert.strictEqual(result.appliedCount, 0);
|
|
assert.strictEqual(result.skippedCount, 2);
|
|
assert.match(result.error ?? "", /No entry matched 'missing later entry'/);
|
|
assert.deepStrictEqual(store.getMemoryEntries(), beforeEntries);
|
|
assert.strictEqual(await fs.readFile(memoryPath, "utf8"), beforeDisk);
|
|
});
|
|
|
|
it("refuses an atomic review replacement that would discard sibling facts", async () => {
|
|
const store = new MemoryStore({
|
|
memoryDir: tmpDir,
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
autoConsolidate: true,
|
|
});
|
|
await store.loadFromDisk();
|
|
await store.add("user", "Name: Cataldo\nOS: Arch Linux\nPreference: concise replies");
|
|
const beforeDisk = await fs.readFile(path.join(tmpDir, "USER.md"), "utf8");
|
|
|
|
const result = await applyReviewOperations(
|
|
store,
|
|
null,
|
|
[{ action: "replace", target: "user", old_text: "Name: Cataldo", content: "Name: Aldo" }],
|
|
null,
|
|
null,
|
|
{ requireAtomicShrink: true, expectedTarget: "user" },
|
|
);
|
|
|
|
assert.deepStrictEqual(
|
|
{ appliedCount: result.appliedCount, skippedCount: result.skippedCount },
|
|
{ appliedCount: 0, skippedCount: 1 },
|
|
);
|
|
assert.match(result.error ?? "", /Refusing replace/);
|
|
assert.strictEqual(await fs.readFile(path.join(tmpDir, "USER.md"), "utf8"), beforeDisk);
|
|
});
|
|
|
|
it("rejects mixed and unexpected atomic targets before mutation", async () => {
|
|
const store = new MemoryStore({
|
|
memoryDir: tmpDir,
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
autoConsolidate: true,
|
|
});
|
|
await store.loadFromDisk();
|
|
await store.add("memory", "global source entry");
|
|
|
|
const mixed = await applyReviewOperations(
|
|
store,
|
|
null,
|
|
[
|
|
{ action: "remove", target: "memory", old_text: "global source" },
|
|
{ action: "remove", target: "user", old_text: "anything" },
|
|
],
|
|
null,
|
|
null,
|
|
{ requireAtomicShrink: true, expectedTarget: "memory" },
|
|
);
|
|
const unexpected = await applyReviewOperations(
|
|
store,
|
|
null,
|
|
[{ action: "remove", target: "memory", old_text: "global source" }],
|
|
null,
|
|
null,
|
|
{ requireAtomicShrink: true, expectedTarget: "user" },
|
|
);
|
|
|
|
assert.deepStrictEqual(
|
|
{ appliedCount: mixed.appliedCount, skippedCount: mixed.skippedCount },
|
|
{ appliedCount: 0, skippedCount: 2 },
|
|
);
|
|
assert.match(mixed.error ?? "", /exactly one target/);
|
|
assert.deepStrictEqual(
|
|
{ appliedCount: unexpected.appliedCount, skippedCount: unexpected.skippedCount },
|
|
{ appliedCount: 0, skippedCount: 1 },
|
|
);
|
|
assert.match(unexpected.error ?? "", /targeted 'memory', expected 'user'/);
|
|
assert.deepStrictEqual(store.getMemoryEntries().map((entry) => entry.replace(/\s*<!--.*$/, "")), [
|
|
"global source entry",
|
|
]);
|
|
});
|
|
|
|
it("rejects an empty atomic plan and an unavailable atomic project store", async () => {
|
|
const store = new MemoryStore({
|
|
memoryDir: tmpDir,
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
autoConsolidate: true,
|
|
});
|
|
await store.loadFromDisk();
|
|
|
|
const empty = await applyReviewOperations(
|
|
store,
|
|
null,
|
|
[],
|
|
null,
|
|
"project-a",
|
|
{ requireAtomicShrink: true, expectedTarget: "project" },
|
|
);
|
|
const unavailable = await applyReviewOperations(
|
|
store,
|
|
null,
|
|
[{ action: "remove", target: "project", old_text: "project source" }],
|
|
null,
|
|
"project-a",
|
|
{ requireAtomicShrink: true, expectedTarget: "project" },
|
|
);
|
|
|
|
assert.deepStrictEqual(
|
|
{ appliedCount: empty.appliedCount, skippedCount: empty.skippedCount },
|
|
{ appliedCount: 0, skippedCount: 0 },
|
|
);
|
|
assert.match(empty.error ?? "", /requires at least one operation/i);
|
|
assert.deepStrictEqual(
|
|
{ appliedCount: unavailable.appliedCount, skippedCount: unavailable.skippedCount },
|
|
{ appliedCount: 0, skippedCount: 1 },
|
|
);
|
|
assert.match(unavailable.error ?? "", /project memory is unavailable/i);
|
|
assert.deepStrictEqual(store.getMemoryEntries(), []);
|
|
});
|
|
|
|
it("applies an atomic project plan only to the isolated project store", async () => {
|
|
const globalDir = path.join(tmpDir, "global");
|
|
const projectDir = path.join(tmpDir, "project");
|
|
const store = new MemoryStore({
|
|
memoryDir: globalDir,
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
autoConsolidate: true,
|
|
});
|
|
const projectStore = new MemoryStore({
|
|
memoryDir: projectDir,
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
autoConsolidate: true,
|
|
});
|
|
await Promise.all([store.loadFromDisk(), projectStore.loadFromDisk()]);
|
|
await store.add("memory", "global source stays intact");
|
|
await projectStore.add("memory", "project source has a long implementation detail");
|
|
|
|
const result = await applyReviewOperations(
|
|
store,
|
|
projectStore,
|
|
[
|
|
{ action: "remove", target: "project", old_text: "project source" },
|
|
{ action: "add", target: "project", content: "project rule" },
|
|
],
|
|
null,
|
|
"project-a",
|
|
{ requireAtomicShrink: true, expectedTarget: "project" },
|
|
);
|
|
|
|
assert.deepStrictEqual(result, { appliedCount: 2, skippedCount: 0 });
|
|
assert.deepStrictEqual(store.getMemoryEntries().map((entry) => entry.replace(/\s*<!--.*$/, "")), [
|
|
"global source stays intact",
|
|
]);
|
|
assert.deepStrictEqual(projectStore.getMemoryEntries().map((entry) => entry.replace(/\s*<!--.*$/, "")), [
|
|
"project rule",
|
|
]);
|
|
assert.doesNotMatch(projectStore.getRawEntriesForSync("memory")[0] ?? "", /project64=/);
|
|
});
|
|
|
|
it("defaults failure formatting and preserves project attribution in atomic plans", async () => {
|
|
const store = new MemoryStore({
|
|
memoryDir: tmpDir,
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
failureCharLimit: 5000,
|
|
autoConsolidate: true,
|
|
});
|
|
await store.loadFromDisk();
|
|
await store.addFailure("obsolete failure detail that is intentionally long", {
|
|
category: "failure",
|
|
project: "project-a",
|
|
});
|
|
|
|
const result = await applyReviewOperations(
|
|
store,
|
|
null,
|
|
[
|
|
{ action: "remove", target: "failure", old_text: "obsolete failure detail" },
|
|
{
|
|
action: "add",
|
|
target: "failure",
|
|
content: "concise lesson",
|
|
failure_reason: "tool used stale state",
|
|
},
|
|
],
|
|
null,
|
|
"project-a",
|
|
{ requireAtomicShrink: true, expectedTarget: "failure" },
|
|
);
|
|
|
|
assert.deepStrictEqual(result, { appliedCount: 2, skippedCount: 0 });
|
|
assert.deepStrictEqual(store.getFailureEntries(), [
|
|
"[failure] concise lesson — Failed: tool used stale state",
|
|
]);
|
|
assert.match(store.getRawEntriesForSync("failure")[0] ?? "", /project64=cHJvamVjdC1h/);
|
|
});
|
|
|
|
it("attributes ordinary non-atomic failures to the current project", async () => {
|
|
const store = new MemoryStore({
|
|
memoryDir: tmpDir,
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
failureCharLimit: 5000,
|
|
autoConsolidate: true,
|
|
});
|
|
await store.loadFromDisk();
|
|
const dbManager = new DatabaseManager(path.join(tmpDir, "db"));
|
|
store.setMutationObserver((target, entries) => {
|
|
reconcileMarkdownMemoryScope(dbManager, entries, target, target === "failure" ? "project-a" : null);
|
|
return null;
|
|
});
|
|
|
|
try {
|
|
const result = await applyReviewOperations(
|
|
store,
|
|
null,
|
|
[{
|
|
action: "add",
|
|
target: "failure",
|
|
content: "ordinary scoped lesson",
|
|
category: "correction",
|
|
failure_reason: "user corrected the command",
|
|
}],
|
|
dbManager,
|
|
"project-a",
|
|
);
|
|
|
|
assert.deepStrictEqual(result, { appliedCount: 1, skippedCount: 0 });
|
|
assert.deepStrictEqual(store.getFailureEntries(), [
|
|
"[correction] ordinary scoped lesson — Failed: user corrected the command",
|
|
]);
|
|
const memories = getMemories(dbManager, { target: "failure", project: "project-a" });
|
|
assert.strictEqual(memories.length, 1);
|
|
assert.strictEqual(getMemories(dbManager, { target: "failure", project: null }).length, 0);
|
|
assert.strictEqual(memories[0].category, "correction");
|
|
assert.match(memories[0].content, /ordinary scoped lesson/);
|
|
} finally {
|
|
dbManager.close();
|
|
}
|
|
});
|
|
|
|
it("returns an actionable direct-completion error without partial atomic changes", async () => {
|
|
const store = new MemoryStore({
|
|
memoryDir: tmpDir,
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
autoConsolidate: true,
|
|
});
|
|
await store.loadFromDisk();
|
|
await store.add("memory", "keep this direct-review source");
|
|
const beforeEntries = store.getMemoryEntries();
|
|
const modelRegistry = {
|
|
authStorage: { reload: () => undefined },
|
|
getApiKeyAndHeaders: async () => ({ ok: true as const, apiKey: "test-key" }),
|
|
getAll: () => [mockModel(false)],
|
|
getAvailable: () => [mockModel(false)],
|
|
};
|
|
const complete = async () => ({
|
|
stopReason: "stop",
|
|
content: [{
|
|
type: "text",
|
|
text: JSON.stringify({
|
|
operations: [
|
|
{ action: "remove", target: "memory", old_text: "keep this" },
|
|
{ action: "remove", target: "memory", old_text: "missing later entry" },
|
|
],
|
|
}),
|
|
}],
|
|
});
|
|
|
|
const result = await runDirectMemoryCompletion(
|
|
{ model: mockModel(false), modelRegistry } as never,
|
|
store,
|
|
null,
|
|
{
|
|
userPrompt: "consolidate",
|
|
systemPrompt: "return operations",
|
|
config: {},
|
|
requireAtomicShrink: true,
|
|
expectedTarget: "memory",
|
|
},
|
|
null,
|
|
null,
|
|
{ completeSimple: complete as never },
|
|
);
|
|
|
|
assert.strictEqual(result.ok, false);
|
|
assert.strictEqual(result.appliedCount, 0);
|
|
assert.match(result.error ?? "", /No entry matched 'missing later entry'/);
|
|
assert.deepStrictEqual(store.getMemoryEntries(), beforeEntries);
|
|
});
|
|
|
|
it("skips auth, provider, and store work when the external signal is already aborted", async () => {
|
|
let authCalls = 0;
|
|
let completeCalls = 0;
|
|
let mutated = false;
|
|
const controller = new AbortController();
|
|
controller.abort();
|
|
const store = {
|
|
add: async () => {
|
|
mutated = true;
|
|
return { success: true };
|
|
},
|
|
};
|
|
const modelRegistry = {
|
|
getApiKeyAndHeaders: async () => {
|
|
authCalls++;
|
|
return { ok: true as const, apiKey: "test-key" };
|
|
},
|
|
getAll: () => [mockModel(false)],
|
|
getAvailable: () => [mockModel(false)],
|
|
};
|
|
|
|
const result = await runDirectMemoryCompletion(
|
|
{ model: mockModel(false), modelRegistry } as never,
|
|
store as never,
|
|
null,
|
|
{
|
|
userPrompt: "u",
|
|
systemPrompt: "s",
|
|
config: {},
|
|
signal: controller.signal,
|
|
},
|
|
null,
|
|
null,
|
|
{
|
|
completeSimple: (async () => {
|
|
completeCalls++;
|
|
return {
|
|
stopReason: "stop",
|
|
content: [{ type: "text", text: JSON.stringify({ operations: [{ action: "add", target: "memory", content: "late" }] }) }],
|
|
};
|
|
}) as never,
|
|
},
|
|
);
|
|
|
|
assert.deepStrictEqual(result, { ok: false, appliedCount: 0, fallbackReason: "aborted" });
|
|
assert.equal(authCalls, 0);
|
|
assert.equal(completeCalls, 0);
|
|
assert.equal(mutated, false);
|
|
});
|
|
|
|
it("does not apply operations when the provider ignores abort and returns success", async () => {
|
|
const store = new MemoryStore({
|
|
memoryDir: tmpDir,
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
autoConsolidate: true,
|
|
});
|
|
await store.loadFromDisk();
|
|
const controller = new AbortController();
|
|
const modelRegistry = {
|
|
getApiKeyAndHeaders: async () => ({ ok: true as const, apiKey: "test-key" }),
|
|
getAll: () => [mockModel(false)],
|
|
getAvailable: () => [mockModel(false)],
|
|
};
|
|
const complete = async () => {
|
|
controller.abort();
|
|
return {
|
|
stopReason: "stop",
|
|
content: [{
|
|
type: "text",
|
|
text: JSON.stringify({
|
|
operations: [{ action: "add", target: "memory", content: "should not persist after cancel" }],
|
|
}),
|
|
}],
|
|
};
|
|
};
|
|
|
|
const result = await runDirectMemoryCompletion(
|
|
{ model: mockModel(false), modelRegistry } as never,
|
|
store,
|
|
null,
|
|
{
|
|
userPrompt: "u",
|
|
systemPrompt: "s",
|
|
config: {},
|
|
signal: controller.signal,
|
|
},
|
|
null,
|
|
null,
|
|
{ completeSimple: complete as never },
|
|
);
|
|
|
|
assert.deepStrictEqual(result, { ok: false, appliedCount: 0, fallbackReason: "aborted" });
|
|
assert.equal(store.getMemoryEntries().some((entry) => entry.includes("should not persist after cancel")), false);
|
|
});
|
|
|
|
it("does not write after abort while waiting for the markdown mutation lock", async () => {
|
|
const store = new MemoryStore({
|
|
memoryDir: tmpDir,
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
autoConsolidate: true,
|
|
});
|
|
await store.loadFromDisk();
|
|
const controller = new AbortController();
|
|
const modelRegistry = {
|
|
getApiKeyAndHeaders: async () => ({ ok: true as const, apiKey: "test-key" }),
|
|
getAll: () => [mockModel(false)],
|
|
getAvailable: () => [mockModel(false)],
|
|
};
|
|
const completeEntered = Promise.withResolvers<void>();
|
|
const continueComplete = Promise.withResolvers<void>();
|
|
const complete = async () => {
|
|
completeEntered.resolve();
|
|
await continueComplete.promise;
|
|
return {
|
|
stopReason: "stop",
|
|
content: [{
|
|
type: "text",
|
|
text: JSON.stringify({
|
|
operations: [{ action: "add", target: "memory", content: "late-after-shutdown" }],
|
|
}),
|
|
}],
|
|
};
|
|
};
|
|
const lease = await acquireMarkdownMutationLock(path.join(tmpDir, MEMORY_FILE));
|
|
try {
|
|
const completion = runDirectMemoryCompletion(
|
|
{ model: mockModel(false), modelRegistry } as never,
|
|
store,
|
|
null,
|
|
{
|
|
userPrompt: "u",
|
|
systemPrompt: "s",
|
|
config: {},
|
|
signal: controller.signal,
|
|
},
|
|
null,
|
|
null,
|
|
{ completeSimple: complete as never },
|
|
);
|
|
await completeEntered.promise;
|
|
continueComplete.resolve();
|
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
controller.abort();
|
|
lease.release();
|
|
const result = await completion;
|
|
assert.deepStrictEqual(result, { ok: false, appliedCount: 0, fallbackReason: "aborted" });
|
|
assert.equal(store.getMemoryEntries().some((entry) => entry.includes("late-after-shutdown")), false);
|
|
} finally {
|
|
lease.release();
|
|
}
|
|
});
|
|
|
|
it("uses the in-lock mutation observer as the sole SQLite reconciliation path", async () => {
|
|
const store = new MemoryStore({
|
|
memoryDir: tmpDir,
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
autoConsolidate: true,
|
|
});
|
|
await store.loadFromDisk();
|
|
|
|
const dbManager = new DatabaseManager(path.join(tmpDir, "db"));
|
|
const originalGetDb = dbManager.getDb.bind(dbManager);
|
|
let insideObserver = false;
|
|
(dbManager as any).getDb = () => {
|
|
if (!insideObserver) throw new Error("out-of-lock SQLite access");
|
|
return originalGetDb();
|
|
};
|
|
store.setMutationObserver((_target, entries) => {
|
|
insideObserver = true;
|
|
try {
|
|
reconcileMarkdownMemoryScope(dbManager, entries, "memory", null);
|
|
} finally {
|
|
insideObserver = false;
|
|
}
|
|
return null;
|
|
});
|
|
|
|
try {
|
|
const result = await applyReviewOperations(store, null, [
|
|
{ action: "add", target: "memory", content: "observer owns reconciliation" },
|
|
], dbManager);
|
|
|
|
assert.strictEqual(result.appliedCount, 1);
|
|
} finally {
|
|
dbManager.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("response channel fallbacks (#197)", () => {
|
|
function registry() {
|
|
return {
|
|
getApiKeyAndHeaders: async () => ({ ok: true as const, apiKey: "sk-test" }),
|
|
getAll: () => [mockModel(true)],
|
|
getAvailable: () => [mockModel(true)],
|
|
};
|
|
}
|
|
|
|
async function runReview(complete: unknown) {
|
|
return runDirectMemoryCompletion(
|
|
{ model: mockModel(true), modelRegistry: registry() } as never,
|
|
null as never,
|
|
null,
|
|
{ userPrompt: "u", systemPrompt: "s", config: {} },
|
|
null,
|
|
null,
|
|
{ completeSimple: complete as never },
|
|
);
|
|
}
|
|
|
|
it("parses ops from thinking blocks when content has no text blocks", async () => {
|
|
const complete = async () => ({
|
|
stopReason: "stop",
|
|
content: [
|
|
{ type: "thinking", thinking: JSON.stringify({ operations: [] }) },
|
|
],
|
|
});
|
|
|
|
const result = await runReview(complete);
|
|
|
|
// "empty" (not "empty_response") proves the ops JSON inside the thinking
|
|
// block reached the parser and parsed cleanly.
|
|
assert.deepStrictEqual(result, { ok: true, appliedCount: 0, fallbackReason: "empty" });
|
|
});
|
|
|
|
it("prefers text blocks over thinking blocks when both are present", async () => {
|
|
const complete = async () => ({
|
|
stopReason: "stop",
|
|
content: [
|
|
{ type: "text", text: "not json at all" },
|
|
{ type: "thinking", thinking: JSON.stringify({ operations: [] }) },
|
|
],
|
|
});
|
|
|
|
const result = await runReview(complete);
|
|
|
|
// Valid ops in thinking must not mask an unparseable text answer.
|
|
assert.deepStrictEqual(result, { ok: false, appliedCount: 0, fallbackReason: "parse_error" });
|
|
});
|
|
|
|
it("returns empty_response on a clean stop with neither text nor thinking", async () => {
|
|
const complete = async () => ({ stopReason: "stop", content: [] });
|
|
|
|
const result = await runReview(complete);
|
|
|
|
assert.deepStrictEqual(result, { ok: true, appliedCount: 0, fallbackReason: "empty_response" });
|
|
});
|
|
|
|
it("keeps parse_error when a truncated (length) response has no content", async () => {
|
|
const complete = async () => ({ stopReason: "length", content: [] });
|
|
|
|
const result = await runReview(complete);
|
|
|
|
// Truncation means the model may not have finished; the fallback chain
|
|
// (next model / subprocess) must still get a chance to retry.
|
|
assert.deepStrictEqual(result, { ok: false, appliedCount: 0, fallbackReason: "parse_error" });
|
|
});
|
|
|
|
it("logs the provider-misconfiguration notice once per state for thinking-only answers", async () => {
|
|
const complete = async () => ({
|
|
stopReason: "stop",
|
|
content: [{ type: "thinking", thinking: JSON.stringify({ operations: [] }) }],
|
|
});
|
|
const lines: string[] = [];
|
|
const state = { logged: false };
|
|
const runWithDeps = (overrides: Record<string, unknown> = {}) =>
|
|
runDirectMemoryCompletion(
|
|
{ model: mockModel(true), modelRegistry: registry() } as never,
|
|
null as never,
|
|
null,
|
|
{ userPrompt: "u", systemPrompt: "s", config: {} },
|
|
null,
|
|
null,
|
|
{
|
|
completeSimple: complete as never,
|
|
onProviderNotice: (message: string) => lines.push(message),
|
|
providerNoticeState: state,
|
|
...overrides,
|
|
},
|
|
);
|
|
|
|
await runWithDeps();
|
|
assert.strictEqual(lines.length, 1);
|
|
assert.match(lines[0]!, /Provider misconfiguration/);
|
|
assert.match(lines[0]!, /thinking channel/);
|
|
assert.match(lines[0]!, /Ask your Pi to fix this as well/);
|
|
|
|
// A second review sharing the state stays silent: the notice is log-once.
|
|
await runWithDeps();
|
|
assert.strictEqual(lines.length, 1);
|
|
|
|
// A fresh state (a new process or session) logs again.
|
|
await runWithDeps({ providerNoticeState: { logged: false } });
|
|
assert.strictEqual(lines.length, 2);
|
|
});
|
|
|
|
it("does not log the provider notice for a normal text-channel answer", async () => {
|
|
const complete = async () => ({
|
|
stopReason: "stop",
|
|
content: [{ type: "text", text: JSON.stringify({ operations: [] }) }],
|
|
});
|
|
const lines: string[] = [];
|
|
|
|
await runDirectMemoryCompletion(
|
|
{ model: mockModel(true), modelRegistry: registry() } as never,
|
|
null as never,
|
|
null,
|
|
{ userPrompt: "u", systemPrompt: "s", config: {} },
|
|
null,
|
|
null,
|
|
{
|
|
completeSimple: complete as never,
|
|
onProviderNotice: (message: string) => lines.push(message),
|
|
providerNoticeState: { logged: false },
|
|
},
|
|
);
|
|
|
|
assert.strictEqual(lines.length, 0);
|
|
});
|
|
|
|
it("treats a redacted-only completion as empty_response, not parse_error", async () => {
|
|
const complete = async () => ({
|
|
stopReason: "stop",
|
|
content: [
|
|
{ type: "thinking", thinking: "encrypted redacted payload", redacted: true },
|
|
],
|
|
});
|
|
|
|
const result = await runReview(complete);
|
|
|
|
// The redacted block is skipped — nothing parseable, nothing emitted in
|
|
// the clear: same clean-stop empty_response as a silent model, rather
|
|
// than a parse_error that would burn the subprocess fallback (#197).
|
|
assert.deepStrictEqual(result, { ok: true, appliedCount: 0, fallbackReason: "empty_response" });
|
|
});
|
|
|
|
it("skips redacted thinking blocks but still recovers a clear one", async () => {
|
|
const complete = async () => ({
|
|
stopReason: "stop",
|
|
content: [
|
|
{ type: "thinking", thinking: "encrypted redacted payload", redacted: true },
|
|
{ type: "thinking", thinking: JSON.stringify({ operations: [] }) },
|
|
],
|
|
});
|
|
|
|
const result = await runReview(complete);
|
|
|
|
assert.deepStrictEqual(result, { ok: true, appliedCount: 0, fallbackReason: "empty" });
|
|
});
|
|
|
|
it("falls back to thinking when the text block is whitespace only", async () => {
|
|
const complete = async () => ({
|
|
stopReason: "stop",
|
|
content: [
|
|
{ type: "text", text: " \n\t" },
|
|
{ type: "thinking", thinking: JSON.stringify({ operations: [] }) },
|
|
],
|
|
});
|
|
|
|
const result = await runReview(complete);
|
|
|
|
// Whitespace-only text must not mask recoverable thinking output.
|
|
assert.deepStrictEqual(result, { ok: true, appliedCount: 0, fallbackReason: "empty" });
|
|
});
|
|
|
|
it("settles empty_response when thinking output parses to nothing on a clean stop (#235)", async () => {
|
|
const complete = async () => ({
|
|
stopReason: "stop",
|
|
content: [
|
|
{ type: "thinking", thinking: 'reasoned about { braces } and "quotes" but produced no operations' },
|
|
],
|
|
});
|
|
|
|
const result = await runReview(complete);
|
|
|
|
// Brace-heavy prose with no ops object: the answer never left the
|
|
// reasoning channel, and a subprocess would run the same model against
|
|
// the same server-side thinking default and fail the same way.
|
|
assert.deepStrictEqual(result, { ok: true, appliedCount: 0, fallbackReason: "empty_response" });
|
|
});
|
|
|
|
it("keeps parse_error when unparseable thinking output is truncated (#235)", async () => {
|
|
const complete = async () => ({
|
|
stopReason: "length",
|
|
content: [
|
|
{ type: "thinking", thinking: 'reasoned about { braces } and got cut off mid-obj' },
|
|
],
|
|
});
|
|
|
|
const result = await runReview(complete);
|
|
|
|
// Truncation means the model may not have finished; the chain (next
|
|
// model / subprocess) must still get a chance to retry.
|
|
assert.deepStrictEqual(result, { ok: false, appliedCount: 0, fallbackReason: "parse_error" });
|
|
});
|
|
});
|
|
|
|
describe("thinking-channel CoT recovery (#197)", () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "review-cot-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function registry() {
|
|
return {
|
|
getApiKeyAndHeaders: async () => ({ ok: true as const, apiKey: "sk-test" }),
|
|
getAll: () => [mockModel(true)],
|
|
getAvailable: () => [mockModel(true)],
|
|
};
|
|
}
|
|
|
|
it("applies the trailing answer when CoT restates the schema first", async () => {
|
|
const store = new MemoryStore({
|
|
memoryDir: tmpDir,
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
autoConsolidate: true,
|
|
});
|
|
await store.loadFromDisk();
|
|
|
|
const complete = async () => ({
|
|
stopReason: "stop",
|
|
content: [
|
|
{
|
|
type: "thinking",
|
|
thinking:
|
|
'The schema is {"operations":[]} but I will save:\n{"operations":[{"action":"add","target":"user","content":"prefers dark mode"}]}',
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await runDirectMemoryCompletion(
|
|
{ model: mockModel(true), modelRegistry: registry() } as never,
|
|
store,
|
|
null,
|
|
{ userPrompt: "u", systemPrompt: "s", config: {} },
|
|
null,
|
|
null,
|
|
{ completeSimple: complete as never },
|
|
);
|
|
|
|
assert.strictEqual(result.ok, true);
|
|
assert.strictEqual(result.appliedCount, 1);
|
|
assert.ok(store.getUserEntries().some((entry) => entry.includes("prefers dark mode")));
|
|
});
|
|
});
|
|
|
|
describe("thinking-channel trust rules (#235)", () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "review-235-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function registry() {
|
|
return {
|
|
getApiKeyAndHeaders: async () => ({ ok: true as const, apiKey: "sk-test" }),
|
|
getAll: () => [mockModel(true)],
|
|
getAvailable: () => [mockModel(true)],
|
|
};
|
|
}
|
|
|
|
const makeStore = async () => {
|
|
const store = new MemoryStore({
|
|
memoryDir: tmpDir,
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
autoConsolidate: true,
|
|
});
|
|
await store.loadFromDisk();
|
|
return store;
|
|
};
|
|
|
|
async function runThinking(thinking: string, store: MemoryStore) {
|
|
const complete = async () => ({
|
|
stopReason: "stop",
|
|
content: [{ type: "thinking", thinking }],
|
|
});
|
|
return runDirectMemoryCompletion(
|
|
{ model: mockModel(true), modelRegistry: registry() } as never,
|
|
store,
|
|
null,
|
|
{ userPrompt: "u", systemPrompt: "s", config: {} },
|
|
null,
|
|
null,
|
|
{ completeSimple: complete as never },
|
|
);
|
|
}
|
|
|
|
it("picks the trailing answer over an earlier fenced draft, and the draft's remove is not applied", async () => {
|
|
const store = await makeStore();
|
|
await applyReviewOperations(store, null, [
|
|
{ action: "add", target: "memory", content: "draft old entry" },
|
|
]);
|
|
|
|
// The shared cascade would let the fenced draft (a remove) win before
|
|
// the end-scan runs. The thinking path must pick the trailing answer,
|
|
// and the draft's remove must never execute.
|
|
const result = await runThinking(
|
|
'```json\n{"operations":[{"action":"remove","target":"memory","old_text":"draft old entry"}]}\n```\nFinal answer:\n{"operations":[{"action":"add","target":"user","content":"prefers dark mode"}]}',
|
|
store,
|
|
);
|
|
|
|
assert.strictEqual(result.ok, true);
|
|
assert.strictEqual(result.appliedCount, 1);
|
|
assert.ok(store.getUserEntries().some((entry) => entry.includes("prefers dark mode")));
|
|
assert.ok(store.getMemoryEntries().some((entry) => entry.includes("draft old entry")));
|
|
});
|
|
|
|
it("applies only adds from a non-trailing (draft-grade) candidate", async () => {
|
|
const store = await makeStore();
|
|
await applyReviewOperations(store, null, [
|
|
{ action: "add", target: "memory", content: "draft old entry" },
|
|
]);
|
|
|
|
// The candidate is followed by more reasoning, so it is draft-grade:
|
|
// the add is recovered, the remove is dropped.
|
|
const result = await runThinking(
|
|
'{"operations":[{"action":"add","target":"user","content":"prefers dark mode"},{"action":"remove","target":"memory","old_text":"draft old entry"}]}\nOn reflection, keep the store as it is.',
|
|
store,
|
|
);
|
|
|
|
assert.strictEqual(result.ok, true);
|
|
assert.strictEqual(result.appliedCount, 1);
|
|
assert.ok(store.getUserEntries().some((entry) => entry.includes("prefers dark mode")));
|
|
assert.ok(store.getMemoryEntries().some((entry) => entry.includes("draft old entry")));
|
|
});
|
|
|
|
it("recovers the answer when an unbalanced brace in prose precedes it", async () => {
|
|
const store = await makeStore();
|
|
|
|
// A top-level-only scanner loses every object after an unclosed brace;
|
|
// recording balanced regions at any depth still finds the answer.
|
|
const result = await runThinking(
|
|
'thinking about { this brace never closes\n{"operations":[{"action":"add","target":"user","content":"prefers dark mode"}]}',
|
|
store,
|
|
);
|
|
|
|
assert.strictEqual(result.ok, true);
|
|
assert.strictEqual(result.appliedCount, 1);
|
|
assert.ok(store.getUserEntries().some((entry) => entry.includes("prefers dark mode")));
|
|
});
|
|
|
|
it("settles empty when the trailing candidate is empty, without reaching back to an earlier draft", async () => {
|
|
const store = await makeStore();
|
|
|
|
const result = await runThinking(
|
|
'draft {"operations":[{"action":"add","target":"user","content":"never applied"}]} but finally:\n{"operations":[]}',
|
|
store,
|
|
);
|
|
|
|
// The trailing empty candidate is the model's final word: applying the
|
|
// earlier draft would execute an operation it may have rejected.
|
|
assert.deepStrictEqual(result, { ok: true, appliedCount: 0, fallbackReason: "empty" });
|
|
assert.ok(!store.getUserEntries().some((entry) => entry.includes("never applied")));
|
|
});
|
|
|
|
it("walks to a healthy fallback model after a silent primary and applies its operations", async () => {
|
|
const store = await makeStore();
|
|
const m1 = { id: "m1", provider: "p1", api: "openai-completions", reasoning: true } as Model<Api>;
|
|
const m2 = { id: "m2", provider: "p2", api: "openai-completions", reasoning: true } as Model<Api>;
|
|
const chainRegistry = {
|
|
getApiKeyAndHeaders: async () => ({ ok: true as const, apiKey: "sk-test" }),
|
|
getAll: () => [m1, m2],
|
|
getAvailable: () => [m1, m2],
|
|
};
|
|
const attempted: string[] = [];
|
|
const complete = async (model: Model<Api>) => {
|
|
attempted.push(model.id);
|
|
if (model.id === "m1") {
|
|
// Silent primary: clean stop, nothing in either channel.
|
|
return { stopReason: "stop", content: [] };
|
|
}
|
|
return {
|
|
stopReason: "stop",
|
|
content: [{ type: "text", text: JSON.stringify({ operations: [{ action: "add", target: "user", content: "prefers dark mode" }] }) }],
|
|
};
|
|
};
|
|
|
|
const result = await runDirectMemoryCompletion(
|
|
{ model: m1, modelRegistry: chainRegistry } as never,
|
|
store,
|
|
null,
|
|
{ userPrompt: "u", systemPrompt: "s", config: { llmModelOverride: "p1/m1", llmFallbackModels: ["p2/m2"] } },
|
|
null,
|
|
null,
|
|
{ completeSimple: complete as never },
|
|
);
|
|
|
|
// empty_response walks llmFallbackModels like parse_error does: a silent
|
|
// primary must not end the review while a configured fallback may answer.
|
|
assert.deepStrictEqual(attempted, ["m1", "m2"]);
|
|
assert.strictEqual(result.ok, true);
|
|
assert.strictEqual(result.appliedCount, 1);
|
|
assert.ok(store.getUserEntries().some((entry) => entry.includes("prefers dark mode")));
|
|
});
|
|
});
|
|
|
|
describe("opencode session header (#250)", () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(async () => {
|
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "review-250-"));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
async function makeStore() {
|
|
const store = new MemoryStore({
|
|
memoryDir: tmpDir,
|
|
memoryCharLimit: 5000,
|
|
userCharLimit: 5000,
|
|
autoConsolidate: true,
|
|
});
|
|
await store.loadFromDisk();
|
|
return store;
|
|
}
|
|
|
|
// Stands in for the OpenCode Go gateway, which rejects any request without
|
|
// x-opencode-session before the model is ever reached.
|
|
async function startGateway() {
|
|
const seen: Array<string | string[] | undefined> = [];
|
|
const server = http.createServer((req, res) => {
|
|
const session = req.headers["x-opencode-session"];
|
|
seen.push(session);
|
|
if (session === undefined) {
|
|
res.writeHead(400, { "content-type": "application/json" });
|
|
res.end(JSON.stringify({ type: "error", error: { type: "invalid_request_error", message: "missing_session_id" } }));
|
|
return;
|
|
}
|
|
res.writeHead(200, { "content-type": "text/event-stream" });
|
|
const send = (event: string, data: unknown) => res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
const body = JSON.stringify({ operations: [{ action: "add", target: "memory", content: "gateway requires a session" }] });
|
|
send("message_start", {
|
|
type: "message_start",
|
|
message: { id: "msg_1", type: "message", role: "assistant", model: "mimo", content: [], stop_reason: null, stop_sequence: null, usage: { input_tokens: 1, output_tokens: 1 } },
|
|
});
|
|
send("content_block_start", { type: "content_block_start", index: 0, content_block: { type: "text", text: "" } });
|
|
send("content_block_delta", { type: "content_block_delta", index: 0, delta: { type: "text_delta", text: body } });
|
|
send("content_block_stop", { type: "content_block_stop", index: 0 });
|
|
send("message_delta", { type: "message_delta", delta: { stop_reason: "end_turn", stop_sequence: null }, usage: { output_tokens: 5 } });
|
|
send("message_stop", { type: "message_stop" });
|
|
res.end();
|
|
});
|
|
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
const address = server.address();
|
|
if (address === null || typeof address === "string") throw new Error("gateway did not bind a TCP port");
|
|
return { server, seen, baseUrl: `http://127.0.0.1:${address.port}` };
|
|
}
|
|
|
|
it("saves the memory entry the gateway accepted, without stubbing the transport", async () => {
|
|
const { server, seen, baseUrl } = await startGateway();
|
|
const store = await makeStore();
|
|
const model = {
|
|
id: "mimo-v2.6",
|
|
name: "mimo-v2.6",
|
|
provider: "opencode-go",
|
|
api: "anthropic-messages",
|
|
baseUrl,
|
|
reasoning: false,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 200000,
|
|
maxTokens: 4096,
|
|
} as Model<Api>;
|
|
|
|
try {
|
|
const result = await runDirectMemoryCompletion(
|
|
{
|
|
model,
|
|
modelRegistry: {
|
|
getApiKeyAndHeaders: async () => ({ ok: true as const, apiKey: "sk-test" }),
|
|
hasConfiguredAuth: () => true,
|
|
isUsingOAuth: () => false,
|
|
getAll: () => [model],
|
|
getAvailable: () => [model],
|
|
},
|
|
sessionManager: { getSessionId: () => "ses_opencode_route" },
|
|
} as never,
|
|
store,
|
|
null,
|
|
{ userPrompt: "u", systemPrompt: "s", config: {} },
|
|
null,
|
|
null,
|
|
);
|
|
|
|
assert.ok(seen.length > 0, "gateway saw no request at all");
|
|
assert.ok(
|
|
seen.every((value) => value === "ses_opencode_route"),
|
|
`every request must carry the pi session id, saw ${JSON.stringify(seen)}`,
|
|
);
|
|
assert.strictEqual(result.ok, true);
|
|
assert.strictEqual(result.appliedCount, 1);
|
|
assert.ok(store.getMemoryEntries().some((entry) => entry.includes("gateway requires a session")));
|
|
} finally {
|
|
server.close();
|
|
}
|
|
});
|
|
|
|
it("leaves other providers and the registry's own header object untouched", async () => {
|
|
const registryHeaders = { Authorization: "Bearer k" };
|
|
const captured: Array<Record<string, string | null> | undefined> = [];
|
|
const model = { ...mockModel(false), provider: "anthropic", api: "anthropic-messages" } as Model<Api>;
|
|
|
|
const result = await runDirectMemoryCompletion(
|
|
{
|
|
model,
|
|
modelRegistry: {
|
|
getApiKeyAndHeaders: async () => ({ ok: true as const, apiKey: "sk-test", headers: registryHeaders }),
|
|
hasConfiguredAuth: () => true,
|
|
isUsingOAuth: () => false,
|
|
getAll: () => [model],
|
|
getAvailable: () => [model],
|
|
},
|
|
sessionManager: { getSessionId: () => "ses_opencode_route" },
|
|
} as never,
|
|
null as never,
|
|
null,
|
|
{ userPrompt: "u", systemPrompt: "s", config: {} },
|
|
null,
|
|
null,
|
|
{
|
|
completeSimple: (async (_model: unknown, _request: unknown, options: { headers?: Record<string, string | null> }) => {
|
|
captured.push(options.headers);
|
|
return { stopReason: "stop", content: [{ type: "text", text: JSON.stringify({ operations: [] }) }] };
|
|
}) as never,
|
|
},
|
|
);
|
|
|
|
assert.strictEqual(result.ok, true);
|
|
assert.deepStrictEqual(captured, [registryHeaders]);
|
|
assert.deepStrictEqual(registryHeaders, { Authorization: "Bearer k" });
|
|
});
|
|
|
|
it("keeps an operator-configured session header instead of overwriting it", async () => {
|
|
const configured = { "X-OpenCode-Session": "operator-chosen" };
|
|
const captured: Array<Record<string, string | null> | undefined> = [];
|
|
const model = { ...mockModel(false), provider: "opencode-go", api: "openai-completions" } as Model<Api>;
|
|
|
|
const result = await runDirectMemoryCompletion(
|
|
{
|
|
model,
|
|
modelRegistry: {
|
|
getApiKeyAndHeaders: async () => ({ ok: true as const, apiKey: "sk-test", headers: configured }),
|
|
hasConfiguredAuth: () => true,
|
|
isUsingOAuth: () => false,
|
|
getAll: () => [model],
|
|
getAvailable: () => [model],
|
|
},
|
|
sessionManager: { getSessionId: () => "ses_opencode_route" },
|
|
} as never,
|
|
null as never,
|
|
null,
|
|
{ userPrompt: "u", systemPrompt: "s", config: {} },
|
|
null,
|
|
null,
|
|
{
|
|
completeSimple: (async (_model: unknown, _request: unknown, options: { headers?: Record<string, string | null> }) => {
|
|
captured.push(options.headers);
|
|
return { stopReason: "stop", content: [{ type: "text", text: JSON.stringify({ operations: [] }) }] };
|
|
}) as never,
|
|
},
|
|
);
|
|
|
|
assert.strictEqual(result.ok, true);
|
|
assert.deepStrictEqual(captured, [configured]);
|
|
assert.deepStrictEqual(configured, { "X-OpenCode-Session": "operator-chosen" });
|
|
});
|
|
|
|
it("scopes the header per model so a mixed-provider chain sends it only to opencode", async () => {
|
|
const primary = { ...mockModel(false), id: "claude-x", provider: "anthropic", api: "anthropic-messages" } as Model<Api>;
|
|
const fallback = { ...mockModel(false), id: "deepseek", provider: "opencode-go", api: "openai-completions" } as Model<Api>;
|
|
const attempted: Array<{ model: string; headers: Record<string, string | null> | undefined }> = [];
|
|
const store = await makeStore();
|
|
|
|
const result = await runDirectMemoryCompletion(
|
|
{
|
|
model: primary,
|
|
modelRegistry: {
|
|
getApiKeyAndHeaders: async () => ({ ok: true as const, apiKey: "sk-test" }),
|
|
hasConfiguredAuth: () => true,
|
|
isUsingOAuth: () => false,
|
|
getAll: () => [primary, fallback],
|
|
getAvailable: () => [primary, fallback],
|
|
},
|
|
sessionManager: { getSessionId: () => "ses_opencode_route" },
|
|
} as never,
|
|
store,
|
|
null,
|
|
{
|
|
userPrompt: "u",
|
|
systemPrompt: "s",
|
|
config: { llmModelOverride: "anthropic/claude-x", llmFallbackModels: ["opencode-go/deepseek"] },
|
|
},
|
|
null,
|
|
null,
|
|
{
|
|
completeSimple: (async (model: { id: string }, _request: unknown, options: { headers?: Record<string, string | null> }) => {
|
|
attempted.push({ model: model.id, headers: options.headers });
|
|
if (model.id === "claude-x") return { stopReason: "error", content: [], errorMessage: "upstream busy" };
|
|
return {
|
|
stopReason: "stop",
|
|
content: [{ type: "text", text: JSON.stringify({ operations: [{ action: "add", target: "memory", content: "chain hop reached opencode" }] }) }],
|
|
};
|
|
}) as never,
|
|
},
|
|
);
|
|
|
|
assert.deepStrictEqual(attempted.map((a) => a.model), ["claude-x", "deepseek"]);
|
|
assert.strictEqual(attempted[0]?.headers?.["x-opencode-session"], undefined);
|
|
assert.strictEqual(attempted[1]?.headers?.["x-opencode-session"], "ses_opencode_route");
|
|
assert.ok(store.getMemoryEntries().some((entry) => entry.includes("chain hop reached opencode")));
|
|
});
|
|
});
|
|
|
|
describe("opencode scoping and session-id degradation", () => {
|
|
function capturingRun(model: Model<Api>, sessionManager: unknown) {
|
|
const captured: Array<Record<string, string | null> | undefined> = [];
|
|
const run = runDirectMemoryCompletion(
|
|
{
|
|
model,
|
|
modelRegistry: {
|
|
getApiKeyAndHeaders: async () => ({ ok: true as const, apiKey: "sk-test" }),
|
|
hasConfiguredAuth: () => true,
|
|
isUsingOAuth: () => false,
|
|
getAll: () => [model],
|
|
getAvailable: () => [model],
|
|
},
|
|
sessionManager,
|
|
} as never,
|
|
null as never,
|
|
null,
|
|
{ userPrompt: "u", systemPrompt: "s", config: {} },
|
|
null,
|
|
null,
|
|
{
|
|
completeSimple: (async (_model: unknown, _request: unknown, options: { headers?: Record<string, string | null> }) => {
|
|
captured.push(options.headers);
|
|
return { stopReason: "stop", content: [{ type: "text", text: JSON.stringify({ operations: [] }) }] };
|
|
}) as never,
|
|
},
|
|
);
|
|
return { run, captured };
|
|
}
|
|
|
|
function modelWith(provider: string, baseUrl: string): Model<Api> {
|
|
return {
|
|
...mockModel(false),
|
|
provider,
|
|
baseUrl,
|
|
input: ["text"],
|
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
contextWindow: 200000,
|
|
maxTokens: 4096,
|
|
} as Model<Api>;
|
|
}
|
|
|
|
const ok = { getSessionId: () => "ses_opencode_route" };
|
|
|
|
// Removing the baseUrl clause of the scoping rule leaves every other test in
|
|
// this file green, so it needs its own coverage.
|
|
it("scopes by baseUrl host when the provider id is not an opencode one", async () => {
|
|
const { run, captured } = capturingRun(
|
|
modelWith("my-opencode-proxy", "https://opencode.ai/zen/go/v1"),
|
|
ok,
|
|
);
|
|
|
|
const result = await run;
|
|
|
|
assert.strictEqual(result.ok, true);
|
|
assert.strictEqual(captured[0]?.["x-opencode-session"], "ses_opencode_route");
|
|
assert.strictEqual(captured[0]?.["x-opencode-client"], "pi");
|
|
});
|
|
|
|
it("scopes by provider id even when the host is somewhere else", async () => {
|
|
const { run, captured } = capturingRun(
|
|
modelWith("opencode-go", "https://gateway.internal.example/v1"),
|
|
ok,
|
|
);
|
|
|
|
await run;
|
|
|
|
assert.strictEqual(captured[0]?.["x-opencode-session"], "ses_opencode_route");
|
|
});
|
|
|
|
it("leaves a foreign provider on a foreign host untouched", async () => {
|
|
const { run, captured } = capturingRun(
|
|
modelWith("my-proxy", "https://gateway.internal.example/v1"),
|
|
ok,
|
|
);
|
|
|
|
await run;
|
|
|
|
assert.strictEqual(captured[0]?.["x-opencode-session"], undefined);
|
|
assert.strictEqual(captured[0]?.["x-opencode-client"], undefined);
|
|
});
|
|
|
|
it("adds no header when the session id is empty", async () => {
|
|
const { run, captured } = capturingRun(
|
|
modelWith("opencode-go", "https://opencode.ai/zen/go/v1"),
|
|
{ getSessionId: () => "" },
|
|
);
|
|
|
|
const result = await run;
|
|
|
|
assert.strictEqual(result.ok, true, "the attempt still goes out, degraded");
|
|
assert.strictEqual(captured[0]?.["x-opencode-session"], undefined);
|
|
});
|
|
|
|
it("adds no header and still completes when reading the session id throws", async () => {
|
|
const { run, captured } = capturingRun(
|
|
modelWith("opencode-go", "https://opencode.ai/zen/go/v1"),
|
|
{
|
|
getSessionId: () => {
|
|
throw new Error("session gone");
|
|
},
|
|
},
|
|
);
|
|
|
|
const result = await run;
|
|
|
|
assert.strictEqual(result.ok, true, "a throw here must not break the job");
|
|
assert.strictEqual(captured[0]?.["x-opencode-session"], undefined);
|
|
});
|
|
});
|