Something went wrong. Try again.
last activity notifications for opencode
Something went wrong. Try again.
6.6 kB · 216 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217import 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<string, unknown>; readonly session: SessionFixture; readonly synthetic: Mock; prompt(id: string, delivery?: "steer" | "queue"): Promise<void>;}
const at = (ms: number) => DateTime.makeUnsafe(ms);
function fixture(input: { readonly createdMs: number; readonly updatedMs?: number; readonly idleMs?: number; readonly options?: Record<string, unknown>;}): Fixture { const storage = new Map<string, unknown>(); 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<void, never>) | undefined; const context = { options: input.options ?? {}, session: { hook: (_name: "prompt", callback: (event: unknown) => Effect.Effect<void, never>) => 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<void> { 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<void>((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); });});