import type { Context } from "@opencode-ai/plugin/effect/plugin"; import { DateTime, Effect, Exit, Scope } from "effect"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { Mock } from "vitest"; import plugin from "../src/server/plugin.ts"; import { DEFAULT_GAP_THRESHOLD_MS } from "../src/time/shift.ts"; interface SessionFixture { readonly id: string; readonly time: { readonly created: DateTime.Utc; readonly updated: DateTime.Utc; readonly idle?: DateTime.Utc; }; } interface Fixture { readonly context: Context; readonly storage: Map; readonly session: SessionFixture; readonly synthetic: Mock; prompt(id: string, delivery?: "steer" | "queue"): Promise; } const at = (ms: number) => DateTime.makeUnsafe(ms); function fixture(input: { readonly createdMs: number; readonly updatedMs?: number; readonly idleMs?: number; readonly options?: Record; }): Fixture { const storage = new Map(); const session: SessionFixture = { id: "ses_test", time: { created: at(input.createdMs), updated: at(input.updatedMs ?? input.createdMs), ...(input.idleMs === undefined ? {} : { idle: at(input.idleMs) }), }, }; const synthetic = vi.fn(() => Effect.succeed(undefined)); let hook: ((event: unknown) => Effect.Effect) | undefined; const context = { options: input.options ?? {}, session: { hook: (_name: "prompt", callback: (event: unknown) => Effect.Effect) => Effect.sync(() => { hook = callback; }).pipe(Effect.as({ dispose: Effect.void })), get: () => Effect.succeed(session), synthetic, }, storage: { get: (key: string) => Effect.succeed(storage.get(key)), set: (key: string, value: unknown) => Effect.sync(() => { storage.set(key, value); }), }, } as unknown as Context; return { context, storage, session, synthetic, prompt: async (id, delivery = "steer") => { if (hook === undefined) throw new Error("prompt hook was not registered"); await Effect.runPromise( hook({ sessionID: "ses_test", messageID: `msg_${id}`, prompt: { text: "Continue" }, delivery, }), ); }, }; } const scopes: Scope.Scope[] = []; async function load(input: Fixture): Promise { const scope = Effect.runSync(Scope.make()); scopes.push(scope); await Effect.runPromise(plugin.effect(input.context).pipe(Effect.provideService(Scope.Scope, scope))); } beforeEach(() => { scopes.length = 0; }); afterEach(async () => { vi.restoreAllMocks(); for (const scope of scopes.splice(0)) { await Effect.runPromise(Scope.close(scope, Exit.succeed(undefined))); } }); describe("server time-shift records", () => { const nowMs = Date.parse("2026-09-01T12:00:00.000Z"); beforeEach(() => { vi.spyOn(Date, "now").mockReturnValue(nowMs); }); it("does not treat an old but empty session as a gap", async () => { const input = fixture({ createdMs: nowMs - 24 * 60 * 60_000 }); await load(input); await input.prompt("first"); expect(input.synthetic).not.toHaveBeenCalled(); expect(input.storage.get("session-prompt/ses_test")).toEqual({ atMs: nowMs }); }); it("admits a from/to/duration record before a resumed prompt", async () => { const idleMs = nowMs - DEFAULT_GAP_THRESHOLD_MS; const input = fixture({ createdMs: idleMs - 60_000, updatedMs: nowMs - 1_000, idleMs, }); await load(input); await input.prompt("resumed", "queue"); const text = '[time-shift from="2026-09-01T08:00:00.000Z" to="2026-09-01T12:00:00.000Z" duration="4h"]'; expect(input.synthetic).toHaveBeenCalledWith({ id: "msg_lastact_resumed", sessionID: "ses_test", text, description: text, metadata: { source: "opencode-lastact", kind: "time-shift", from: "2026-09-01T08:00:00.000Z", to: "2026-09-01T12:00:00.000Z", durationMs: DEFAULT_GAP_THRESHOLD_MS, }, delivery: "queue", resume: false, }); }); it("keeps short gaps out of the transcript", async () => { const input = fixture({ createdMs: nowMs - 60_000, idleMs: nowMs - DEFAULT_GAP_THRESHOLD_MS + 1, }); await load(input); await input.prompt("short"); expect(input.synthetic).not.toHaveBeenCalled(); }); it("uses the configured threshold", async () => { const input = fixture({ createdMs: nowMs - 2 * 60 * 60_000, idleMs: nowMs - 60 * 60_000, options: { gapThresholdMs: 60 * 60_000 }, }); await load(input); await input.prompt("custom"); expect(input.synthetic).toHaveBeenCalledOnce(); }); it("uses the later of the last settled turn and observed prompt", async () => { const input = fixture({ createdMs: nowMs - 12 * 60 * 60_000, idleMs: nowMs - 8 * 60 * 60_000, }); input.storage.set("session-prompt/ses_test", { atMs: nowMs - 60 * 60_000 }); await load(input); await input.prompt("later"); expect(input.synthetic).not.toHaveBeenCalled(); }); it("serializes overlapping prompts so only one records the gap", async () => { const input = fixture({ createdMs: nowMs - 8 * 60 * 60_000, idleMs: nowMs - DEFAULT_GAP_THRESHOLD_MS, }); let release: (() => void) | undefined; const gate = new Promise((resolve) => { release = resolve; }); input.synthetic.mockImplementationOnce(() => Effect.promise(() => gate)); await load(input); const first = input.prompt("first"); const second = input.prompt("second"); await vi.waitFor(() => expect(input.synthetic).toHaveBeenCalledTimes(1)); release?.(); await Promise.all([first, second]); expect(input.synthetic).toHaveBeenCalledTimes(1); }); it("does not advance its prompt clock when synthetic admission fails", async () => { const input = fixture({ createdMs: nowMs - 8 * 60 * 60_000, idleMs: nowMs - DEFAULT_GAP_THRESHOLD_MS, }); input.synthetic.mockImplementationOnce(() => Effect.die(new Error("admission failed"))); await load(input); await expect(input.prompt("failed")).rejects.toThrow("admission failed"); expect(input.storage.get("session-prompt/ses_test")).toBeUndefined(); await input.prompt("retry"); expect(input.synthetic).toHaveBeenCalledTimes(2); }); });