Files
oh-my-pi-telegram/tests/lifecycle.test.ts
evandrodevbr 2aefc7ef5a
Validate / validate (push) Successful in 9m53s
fix: adapt the bridge to omp 18.4.8 host differences
- resolve the agent dir through the host getAgentDir() (registered by the composition root) instead of sniffing execPath/argv
- synthesize agent_settled from the first terminal agent_end on omp
- read the live model through ctx.models.current() and prefer it over the stored model on omp
- adapt omp Settings (reloadFromDisk, global enabledModels) at the SDK boundary and drop scoped-model persistence on omp
2026-10-01 10:07:47 -03:00

598 lines
17 KiB
TypeScript

/**
* Regression tests for Telegram lifecycle hook helpers
* Covers pi lifecycle hook registration and hook composition ordering
*/
import assert from "node:assert/strict";
import test from "node:test";
import {
appendTelegramLifecycleHooks,
createTelegramCompactionObserverRuntime,
createTelegramSessionContextStore,
createTelegramSessionGenerationFence,
createTelegramMessageActivityTypingHooks,
registerTelegramLifecycleHooks,
} from "../lib/lifecycle.ts";
import type { ExtensionAPI, ExtensionContext } from "../lib/pi.ts";
import {
createTelegramBridgeRuntime,
createTelegramTypingLoopStarter,
} from "../lib/runtime.ts";
type RegisteredLifecycleHandler = (
event: unknown,
ctx: ExtensionContext,
) => Promise<unknown> | unknown;
function createLifecycleApiHarness() {
const handlers = new Map<string, RegisteredLifecycleHandler>();
const api = {
on: (event: string, handler: RegisteredLifecycleHandler) => {
handlers.set(event, handler);
},
} as unknown as ExtensionAPI;
return { api, handlers };
}
function getRequiredLifecycleHandler(
handlers: Map<string, RegisteredLifecycleHandler>,
name: string,
): RegisteredLifecycleHandler {
const handler = handlers.get(name);
assert.ok(handler, `Expected lifecycle handler ${name}`);
return handler;
}
function createLifecycleContext(): ExtensionContext {
return {} as ExtensionContext;
}
test("Session generation fence ignores delayed shutdown from a replaced context", async () => {
const store = createTelegramSessionContextStore<ExtensionContext>();
const shutdowns: ExtensionContext[] = [];
const runtime = createTelegramSessionGenerationFence(store, {
onSessionStart: async () => undefined,
onSessionShutdown: async (_event, ctx) => {
shutdowns.push(ctx);
},
});
const oldContext = { cwd: "/old" } as ExtensionContext;
const newContext = { cwd: "/new" } as ExtensionContext;
await runtime.onSessionStart({} as never, oldContext);
await runtime.onSessionStart({} as never, newContext);
await runtime.onSessionShutdown({} as never, oldContext);
assert.equal(store.get(), newContext);
assert.deepEqual(shutdowns, []);
await runtime.onSessionShutdown({} as never, newContext);
assert.equal(store.get(), undefined);
assert.deepEqual(shutdowns, [newContext]);
});
test("Session context store accepts only the current explicit session identity", () => {
type Context = { session: object };
const store = createTelegramSessionContextStore<Context>({
getIdentity: (ctx) => ctx.session,
});
const currentSession = {};
const currentStart = { session: currentSession };
const currentEvent = { session: currentSession };
const unseenStaleEvent = { session: {} };
const generation = store.set(currentStart);
assert.equal(store.isCurrent(currentEvent, generation), true);
assert.equal(store.isCurrent(unseenStaleEvent, generation), false);
assert.equal(store.isCurrent(unseenStaleEvent), false);
assert.equal(store.getGeneration(), generation);
});
test("Lifecycle composition skips old shutdown extras after replacement during await", async () => {
let active = true;
let releaseBase: (() => void) | undefined;
const baseBlocked = new Promise<void>((resolve) => {
releaseBase = resolve;
});
const events: string[] = [];
const ctx = createLifecycleContext();
const hooks = appendTelegramLifecycleHooks(
{
onSessionStart: async () => {},
onSessionShutdown: async () => {
events.push("base-start");
await baseBlocked;
events.push("base-end");
},
},
{
onSessionShutdown: async () => {
events.push("extra-shutdown");
},
},
() => active,
);
const shutdown = hooks.onSessionShutdown({} as never, ctx);
await Promise.resolve();
active = false;
releaseBase?.();
await shutdown;
assert.deepEqual(events, ["base-start", "base-end"]);
});
test("Lifecycle helpers compose session hooks in order", async () => {
const events: string[] = [];
const hooks = appendTelegramLifecycleHooks(
{
onSessionStart: async () => {
events.push("base-start");
},
onSessionShutdown: async () => {
events.push("base-shutdown");
},
},
{
onSessionStart: async () => {
events.push("extra-start");
},
onSessionShutdown: async () => {
events.push("extra-shutdown");
},
},
);
await hooks.onSessionStart({} as never, createLifecycleContext());
await hooks.onSessionShutdown({} as never, createLifecycleContext());
assert.deepEqual(events, [
"base-start",
"extra-start",
"base-shutdown",
"extra-shutdown",
]);
});
test("Compaction observer mirrors active work with native typing", () => {
const events: string[] = [];
const timers = new Map<number, () => void>();
let nextTimer = 0;
const observer = createTelegramCompactionObserverRuntime({
setCompactionInProgress(inProgress) {
events.push(`compact:${String(inProgress)}`);
},
updateStatus() {
events.push("status");
},
startTypingLoop() {
events.push("typing:start");
},
stopTypingLoop() {
events.push("typing:stop");
},
requestDeferredDispatchNextQueuedTelegramTurn(dispatch) {
events.push("dispatch:request");
dispatch(createLifecycleContext());
},
dispatchNextQueuedTelegramTurn() {
events.push("dispatch");
},
recordRuntimeEvent(category, error) {
events.push(`${category}:${(error as Error).message}`);
},
timeoutMs: 10,
setTimer(callback) {
nextTimer += 1;
timers.set(nextTimer, callback);
return nextTimer;
},
clearTimer(timer) {
timers.delete(timer as number);
},
});
observer.onSessionBeforeCompact({} as never, createLifecycleContext());
observer.onSessionCompact({} as never, createLifecycleContext());
assert.deepEqual(events, [
"compact:true",
"typing:start",
"status",
"compact:false",
"typing:stop",
"status",
"dispatch:request",
"dispatch",
]);
});
test("Compaction observer keeps native typing for non-turn compaction", () => {
const events: string[] = [];
let timerCallback: (() => void) | undefined;
const observer = createTelegramCompactionObserverRuntime({
setCompactionInProgress(inProgress) {
events.push(`compact:${String(inProgress)}`);
},
updateStatus() {
events.push("status");
},
startTypingLoop() {
events.push("typing:start");
},
stopTypingLoop() {
events.push("typing:stop");
},
requestDeferredDispatchNextQueuedTelegramTurn(dispatch) {
events.push("dispatch:request");
dispatch(createLifecycleContext());
},
dispatchNextQueuedTelegramTurn() {
events.push("dispatch");
},
setTimer(callback) {
timerCallback = callback;
return 1;
},
clearTimer() {
timerCallback = undefined;
},
});
observer.onSessionBeforeCompact({} as never, createLifecycleContext());
observer.onSessionCompact({} as never, createLifecycleContext());
observer.onSessionBeforeCompact({} as never, createLifecycleContext());
timerCallback?.();
observer.onSessionShutdown();
assert.deepEqual(events, [
"compact:true",
"typing:start",
"status",
"compact:false",
"typing:stop",
"status",
"dispatch:request",
"dispatch",
"compact:true",
"typing:start",
"status",
"compact:false",
"typing:stop",
"status",
"dispatch:request",
"dispatch",
]);
});
test("Compaction observer sends native typing to the resolved thread and All", async () => {
const runtime = createTelegramBridgeRuntime();
const actions: Array<string> = [];
const startTyping = createTelegramTypingLoopStarter({
typing: runtime.typing,
getDefaultChatId: () => 77,
sendTypingAction: async (chatId, options) => {
actions.push(`thread:${chatId}:${options?.message_thread_id ?? "all"}`);
},
sendAggregateTypingAction: async (chatId) => {
actions.push(`aggregate:${chatId}`);
},
updateStatus: () => {},
intervalMs: 60_000,
});
const observer = createTelegramCompactionObserverRuntime({
setCompactionInProgress: runtime.lifecycle.setCompactionInProgress,
updateStatus: () => {},
startTypingLoop: (ctx) =>
startTyping(ctx, 77, { target: { chatId: 77, threadId: 12 } }),
stopTypingLoop: runtime.typing.stop,
requestDeferredDispatchNextQueuedTelegramTurn: () => {},
dispatchNextQueuedTelegramTurn: () => {},
});
observer.onSessionBeforeCompact({} as never, createLifecycleContext());
await new Promise<void>((resolve) => setImmediate(resolve));
observer.onSessionCompact({} as never, createLifecycleContext());
await runtime.typing.waitForIdle();
assert.deepEqual(actions, ["thread:77:12", "aggregate:77"]);
assert.equal(runtime.lifecycle.isCompactionInProgress(), false);
});
test("Compaction observer unrefs fallback timers when supported", () => {
const events: string[] = [];
const observer = createTelegramCompactionObserverRuntime({
setCompactionInProgress: () => {},
updateStatus: () => {},
requestDeferredDispatchNextQueuedTelegramTurn: () => {},
dispatchNextQueuedTelegramTurn: () => {},
setTimer(callback) {
assert.equal(typeof callback, "function");
return {
unref() {
events.push("unref");
},
} as ReturnType<typeof setTimeout>;
},
clearTimer() {},
});
observer.onSessionBeforeCompact({} as never, createLifecycleContext());
assert.deepEqual(events, ["unref"]);
});
test("Compaction observer stops typing on timeout and shutdown", () => {
const events: string[] = [];
let timerCallback: (() => void) | undefined;
const observer = createTelegramCompactionObserverRuntime({
setCompactionInProgress(inProgress) {
events.push(`compact:${String(inProgress)}`);
},
updateStatus() {
events.push("status");
},
startTypingLoop() {
events.push("typing:start");
},
stopTypingLoop() {
events.push("typing:stop");
},
requestDeferredDispatchNextQueuedTelegramTurn() {
events.push("dispatch:request");
},
dispatchNextQueuedTelegramTurn() {
events.push("dispatch");
},
recordRuntimeEvent(category, error) {
events.push(`${category}:${(error as Error).message}`);
},
onCompactionAbandoned() {
events.push("activity:compact-abandoned");
},
setTimer(callback) {
timerCallback = callback;
return 1;
},
clearTimer() {
timerCallback = undefined;
},
});
observer.onSessionBeforeCompact({} as never, createLifecycleContext());
timerCallback?.();
observer.onSessionBeforeCompact({} as never, createLifecycleContext());
observer.onSessionShutdown();
assert.deepEqual(events, [
"compact:true",
"typing:start",
"status",
"compact:false",
"typing:stop",
"status",
"compact:Compaction observer timed out",
"activity:compact-abandoned",
"dispatch:request",
"compact:true",
"typing:start",
"status",
"typing:stop",
]);
});
test("Message activity hooks re-arm typing for active Telegram turns", async () => {
const events: string[] = [];
let active = true;
const hooks = createTelegramMessageActivityTypingHooks({
hasActiveTurn() {
return active;
},
startTypingLoop() {
events.push("typing:start");
},
async onMessageStart() {
events.push("message:start");
},
async onMessageUpdate() {
events.push("message:update");
},
});
await hooks.onMessageStart(
{ message: {} as never },
createLifecycleContext(),
);
active = false;
await hooks.onMessageUpdate(
{ message: {} as never, assistantMessageEvent: {} as never },
createLifecycleContext(),
);
assert.deepEqual(events, [
"typing:start",
"message:start",
"typing:start",
"message:update",
]);
});
test("Message activity hooks preserve typing after transient preview errors", async () => {
const events: string[] = [];
const hooks = createTelegramMessageActivityTypingHooks({
hasActiveTurn: () => true,
startTypingLoop: () => {
events.push("typing:start");
},
onMessageStart: async () => {
events.push("message:start");
},
onMessageUpdate: async () => {
events.push("message:update");
throw new Error("websocket disconnected");
},
recordRuntimeEvent: (category, error, details) => {
const message = error instanceof Error ? error.message : String(error);
events.push(`${category}:${message}:${details?.phase}`);
},
});
await hooks.onMessageUpdate(
{ message: {} as never, assistantMessageEvent: {} as never },
createLifecycleContext(),
);
assert.deepEqual(events, [
"typing:start",
"message:update",
"message-activity:websocket disconnected:update",
"typing:start",
]);
});
test("Lifecycle helpers register pi hooks and delegate to handlers", async () => {
const harness = createLifecycleApiHarness();
const events: string[] = [];
registerTelegramLifecycleHooks(harness.api, {
onSessionStart: async () => {
events.push("session-start");
},
onSessionShutdown: async () => {
events.push("session-shutdown");
},
onSessionBeforeCompact: () => {
events.push("session-before-compact");
},
onSessionCompact: () => {
events.push("session-compact");
},
onBeforeAgentStart: () => {
events.push("before-agent-start");
return { systemPrompt: "prompt" };
},
onModelSelect: () => {
events.push("model-select");
},
onAgentStart: async () => {
events.push("agent-start");
},
onToolExecutionStart: () => {
events.push("tool-start");
},
onToolExecutionUpdate: () => {
events.push("tool-update");
},
onToolExecutionEnd: () => {
events.push("tool-end");
},
onMessageStart: async () => {
events.push("message-start");
},
onMessageUpdate: async () => {
events.push("message-update");
},
onAgentEnd: async () => {
events.push("agent-end");
},
});
assert.deepEqual(
[...harness.handlers.keys()],
[
"input",
"session_start",
"session_shutdown",
"session_before_compact",
"session_compact",
"before_agent_start",
"model_select",
"agent_start",
"tool_execution_start",
"tool_execution_update",
"tool_execution_end",
"message_start",
"message_update",
"agent_end",
"agent_settled",
],
);
const ctx = createLifecycleContext();
await getRequiredLifecycleHandler(harness.handlers, "input")({}, ctx);
await getRequiredLifecycleHandler(harness.handlers, "session_start")({}, ctx);
await getRequiredLifecycleHandler(harness.handlers, "session_shutdown")(
{},
ctx,
);
await getRequiredLifecycleHandler(harness.handlers, "session_before_compact")(
{},
ctx,
);
await getRequiredLifecycleHandler(harness.handlers, "session_compact")(
{},
ctx,
);
const beforeAgentStartResult = await getRequiredLifecycleHandler(
harness.handlers,
"before_agent_start",
)({}, ctx);
await getRequiredLifecycleHandler(harness.handlers, "model_select")({}, ctx);
await getRequiredLifecycleHandler(harness.handlers, "agent_start")({}, ctx);
await getRequiredLifecycleHandler(harness.handlers, "tool_execution_start")(
{},
ctx,
);
await getRequiredLifecycleHandler(harness.handlers, "tool_execution_update")(
{},
ctx,
);
await getRequiredLifecycleHandler(harness.handlers, "tool_execution_end")(
{},
ctx,
);
await getRequiredLifecycleHandler(harness.handlers, "message_start")({}, ctx);
await getRequiredLifecycleHandler(harness.handlers, "message_update")(
{},
ctx,
);
await getRequiredLifecycleHandler(harness.handlers, "agent_end")({}, ctx);
await getRequiredLifecycleHandler(harness.handlers, "agent_settled")({}, ctx);
assert.deepEqual(beforeAgentStartResult, { systemPrompt: "prompt" });
assert.deepEqual(events, [
"session-start",
"session-shutdown",
"session-before-compact",
"session-compact",
"before-agent-start",
"model-select",
"agent-start",
"tool-start",
"tool-update",
"tool-end",
"message-start",
"message-update",
"agent-end",
]);
});
test("Terminal agent_end synthesizes agent_settled only for hosts that never emit it", async () => {
const cases = [
{ settle: true, event: { messages: [] }, expected: ["agent-end", "agent-settled"] },
{ settle: true, event: { messages: [], willContinue: true }, expected: ["agent-end"] },
{ settle: false, event: { messages: [] }, expected: ["agent-end"] },
];
for (const { settle, event, expected } of cases) {
const harness = createLifecycleApiHarness();
const events: string[] = [];
registerTelegramLifecycleHooks(harness.api, {
settleOnTerminalAgentEnd: settle,
onSessionStart: async () => {},
onSessionShutdown: async () => {},
onBeforeAgentStart: () => undefined,
onModelSelect: () => {},
onAgentStart: async () => {},
onToolExecutionStart: () => {},
onToolExecutionEnd: () => {},
onMessageStart: async () => {},
onMessageUpdate: async () => {},
onAgentEnd: async () => {
events.push("agent-end");
},
onAgentSettled: () => {
events.push("agent-settled");
},
});
await getRequiredLifecycleHandler(harness.handlers, "agent_end")(
event,
createLifecycleContext(),
);
assert.deepEqual(events, expected, JSON.stringify({ settle, event }));
}
});