- classify transport and API failures into a short state with the next step - count undelivered messages and show them next to the sent counter - toast on owner paired, connection recovered and reply not delivered (throttled) - second status line previews the next queued turns - host spinner names the Telegram prompt while its turn runs - sanitize every Telegram-controlled string that reaches the widget
195 lines
6.4 KiB
TypeScript
195 lines
6.4 KiB
TypeScript
/**
|
|
* Regression tests for the pi SDK adapter boundary
|
|
* Covers narrow bridge-facing helpers over concrete pi context contracts
|
|
*/
|
|
|
|
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
|
|
import {
|
|
canStartPollingInExtensionContext,
|
|
compactExtensionContext,
|
|
createExtensionApiRuntimePorts,
|
|
createScopedModelPatternPersister,
|
|
type ExtensionContext,
|
|
getExtensionContextCwd,
|
|
formatPollingStartBlockedByRunMode,
|
|
getExtensionContextMode,
|
|
getExtensionContextModel,
|
|
hasExtensionContextPendingMessages,
|
|
isExtensionContextIdle,
|
|
isExtensionContextPassiveRunMode,
|
|
notifyExtensionContext,
|
|
setExtensionWorkingMessage,
|
|
} from "../lib/pi.ts";
|
|
|
|
type PiRuntimeApiHarness = Parameters<
|
|
typeof createExtensionApiRuntimePorts
|
|
>[0] & {
|
|
events: string[];
|
|
};
|
|
|
|
type PiRuntimeModel = Parameters<PiRuntimeApiHarness["setModel"]>[0];
|
|
|
|
function createHarnessModel(id: string): PiRuntimeModel {
|
|
return { id } as PiRuntimeModel;
|
|
}
|
|
|
|
function getHarnessModelId(model: PiRuntimeModel): string {
|
|
return String(Reflect.get(Object(model), "id"));
|
|
}
|
|
|
|
test("Pi context mode helpers feature-detect passive run modes", () => {
|
|
assert.equal(getExtensionContextMode({ mode: "print" }), "print");
|
|
assert.equal(getExtensionContextMode({ mode: "bogus" }), undefined);
|
|
assert.equal(isExtensionContextPassiveRunMode({ mode: "print" }), true);
|
|
assert.equal(isExtensionContextPassiveRunMode({ mode: "json" }), true);
|
|
assert.equal(isExtensionContextPassiveRunMode({ mode: "rpc" }), false);
|
|
assert.equal(isExtensionContextPassiveRunMode({}), false);
|
|
assert.equal(canStartPollingInExtensionContext({ mode: "tui" }), true);
|
|
assert.equal(canStartPollingInExtensionContext({ mode: "rpc" }), true);
|
|
assert.equal(canStartPollingInExtensionContext({ mode: "json" }), false);
|
|
assert.equal(canStartPollingInExtensionContext({ mode: "print" }), false);
|
|
assert.equal(canStartPollingInExtensionContext({}), true);
|
|
assert.equal(
|
|
formatPollingStartBlockedByRunMode({ mode: "json" }),
|
|
"Telegram polling is unavailable in Pi json mode. Use /telegram-connect from a long-lived Pi session.",
|
|
);
|
|
});
|
|
|
|
test("Pi API runtime ports bind methods without losing receiver context", async () => {
|
|
const api: PiRuntimeApiHarness = {
|
|
events: [],
|
|
sendUserMessage(content, options) {
|
|
this.events.push(
|
|
`send:${String(content)}:${options?.deliverAs ?? "default"}`,
|
|
);
|
|
},
|
|
async exec(command, args) {
|
|
this.events.push(`exec:${command}:${args.join(",")}`);
|
|
return { stdout: "ok", stderr: "", code: 0, killed: false };
|
|
},
|
|
getCommands() {
|
|
this.events.push("commands");
|
|
return [];
|
|
},
|
|
getThinkingLevel() {
|
|
this.events.push("get-thinking");
|
|
return "high";
|
|
},
|
|
setThinkingLevel(level) {
|
|
this.events.push(`thinking:${String(level)}`);
|
|
},
|
|
async setModel(model) {
|
|
this.events.push(`model:${getHarnessModelId(model)}`);
|
|
return true;
|
|
},
|
|
};
|
|
const runtime = createExtensionApiRuntimePorts(api);
|
|
runtime.sendUserMessage("hello", { deliverAs: "followUp" });
|
|
assert.deepEqual(await runtime.exec("cmd", ["arg"]), {
|
|
stdout: "ok",
|
|
stderr: "",
|
|
code: 0,
|
|
killed: false,
|
|
});
|
|
assert.deepEqual(runtime.getCommands(), []);
|
|
assert.equal(runtime.getThinkingLevel(), "high");
|
|
runtime.setThinkingLevel("low");
|
|
assert.equal(await runtime.setModel(createHarnessModel("gpt-5")), true);
|
|
assert.deepEqual(api.events, [
|
|
"send:hello:followUp",
|
|
"exec:cmd:arg",
|
|
"commands",
|
|
"get-thinking",
|
|
"thinking:low",
|
|
"model:gpt-5",
|
|
]);
|
|
});
|
|
|
|
test("Pi scoped model persister invalidates cached inputs without clearing live menus", async () => {
|
|
const events: string[] = [];
|
|
const persist = createScopedModelPatternPersister({
|
|
createSettingsManager: (cwd) => ({
|
|
reload: async () => {},
|
|
flush: async () => {
|
|
events.push("flush");
|
|
},
|
|
getEnabledModels: () => undefined,
|
|
setEnabledModels: (patterns) => {
|
|
events.push(`set:${cwd}:${patterns?.join(",") ?? "all"}`);
|
|
},
|
|
}),
|
|
clearCachedModelMenuInputs: () => {
|
|
events.push("clear-cache");
|
|
},
|
|
});
|
|
await persist(["openai/gpt-5"], { cwd: "/tmp/project" } as ExtensionContext);
|
|
assert.deepEqual(events, [
|
|
"set:/tmp/project:openai/gpt-5",
|
|
"flush",
|
|
"clear-cache",
|
|
]);
|
|
});
|
|
|
|
test("Pi context helpers expose model, idle, pending-message, and compact adapters", () => {
|
|
const model = { provider: "openai", id: "gpt-5", name: "GPT-5" };
|
|
const events: string[] = [];
|
|
const ctx = {
|
|
model,
|
|
isIdle: () => true,
|
|
hasPendingMessages: () => false,
|
|
cwd: "/tmp/project",
|
|
compact: (callbacks: { onComplete: () => void }) => {
|
|
events.push("compact");
|
|
callbacks.onComplete();
|
|
},
|
|
} as unknown as ExtensionContext;
|
|
compactExtensionContext(ctx, {
|
|
onComplete: () => {
|
|
events.push("complete");
|
|
},
|
|
onError: () => {
|
|
events.push("error");
|
|
},
|
|
});
|
|
assert.equal(getExtensionContextModel(ctx), model);
|
|
assert.equal(getExtensionContextCwd(ctx), "/tmp/project");
|
|
assert.equal(isExtensionContextIdle(ctx), true);
|
|
assert.equal(hasExtensionContextPendingMessages(ctx), false);
|
|
assert.deepEqual(events, ["compact", "complete"]);
|
|
});
|
|
|
|
test("Pi context model prefers omp's live models facade over a frozen ctx.model", () => {
|
|
const frozen = { provider: "fake", id: "fake-1" };
|
|
const live = { provider: "fake", id: "fake-2" };
|
|
const ompCtx = {
|
|
model: frozen,
|
|
models: { current: () => live },
|
|
} as unknown as ExtensionContext;
|
|
const piCtx = { model: frozen } as unknown as ExtensionContext;
|
|
assert.equal(getExtensionContextModel(ompCtx), live);
|
|
assert.equal(getExtensionContextModel(piCtx), frozen);
|
|
});
|
|
|
|
test("Host UI helpers forward to the context and tolerate a host without a spinner API", () => {
|
|
const calls: string[] = [];
|
|
const full = {
|
|
ui: {
|
|
notify: (message: string, level: string) => {
|
|
calls.push(`notify:${level}:${message}`);
|
|
},
|
|
setWorkingMessage: (message?: string) => {
|
|
calls.push(`working:${message}`);
|
|
},
|
|
},
|
|
} as unknown as ExtensionContext;
|
|
notifyExtensionContext(full, "hello", "warning");
|
|
setExtensionWorkingMessage(full, "busy");
|
|
setExtensionWorkingMessage(full, undefined);
|
|
assert.deepEqual(calls, ["notify:warning:hello", "working:busy", "working:undefined"]);
|
|
|
|
const withoutSpinner = { ui: {} } as unknown as ExtensionContext;
|
|
assert.doesNotThrow(() => setExtensionWorkingMessage(withoutSpinner, "busy"));
|
|
});
|