diff --git a/src/index.ts b/src/index.ts index c9ace13..20c8f5a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -71,6 +71,32 @@ export const WarpPlugin: Plugin = async ({ client, directory }) => { console.error("[opencode-warp] failed to emit init log:", err) }) + // `session.created` can fire before this plugin's handlers finish + // registering (plugin init is async), so Warp never sees the + // session_start handshake and keeps showing the "setup plugin" + // instructions even though the plugin is loaded (#17, #18). + // Re-emit session_start shortly after init for the most recent + // top-level session, unless the handler already sent one. + // Deferred past the event-bus attach; client.session.list can hang if + // called synchronously during plugin init. + let sessionStartSent = false + const initCwd = directory || "" + setTimeout(async () => { + if (sessionStartSent) return + try { + const result = await client.session.list() + const mainSession = result.data?.find((s) => !s.parentID) + if (!mainSession) return + sessionStartSent = true + const body = buildPayload("session_start", mainSession.id, initCwd, { + plugin_version: PLUGIN_VERSION, + }) + warpNotify(NOTIFICATION_TITLE, body) + } catch { + // best-effort handshake; ignore failures + } + }, 2000) + const subagentCache = new Map() async function isSubagentSession(sessionId?: string): Promise { @@ -102,6 +128,7 @@ export const WarpPlugin: Plugin = async ({ client, directory }) => { case "session.created": { const info = event.properties.info if (info.parentID) return + sessionStartSent = true const body = buildPayload("session_start", info.id, cwd, { plugin_version: PLUGIN_VERSION, }) diff --git a/tests/deferred-session-start.test.ts b/tests/deferred-session-start.test.ts new file mode 100644 index 0000000..84e548a --- /dev/null +++ b/tests/deferred-session-start.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, mock, beforeEach, afterEach } from "bun:test" + +const notifySpy = mock(() => {}) +mock.module("../src/notify", () => ({ + warpNotify: notifySpy, +})) + +const { WarpPlugin } = await import("../src/index") + +// Delay used by the deferred session_start emit in src/index.ts. +const DEFERRED_DELAY_MS = 2000 + +function makeClient() { + return { + app: { log: async () => ({}) }, + session: { + get: async () => ({ data: {} }), + list: async () => ({ data: [{ id: "sess-main" }] }), + messages: async () => ({ data: [] }), + }, + } as any +} + +async function waitForDeferredEmit() { + await new Promise((resolve) => setTimeout(resolve, DEFERRED_DELAY_MS + 300)) +} + +describe("deferred session_start handshake", () => { + const originalProtocol = process.env.WARP_CLI_AGENT_PROTOCOL_VERSION + + beforeEach(() => { + process.env.WARP_CLI_AGENT_PROTOCOL_VERSION = "1" + notifySpy.mockClear() + }) + + afterEach(() => { + if (originalProtocol === undefined) { + delete process.env.WARP_CLI_AGENT_PROTOCOL_VERSION + } else { + process.env.WARP_CLI_AGENT_PROTOCOL_VERSION = originalProtocol + } + }) + + it( + "re-emits session_start after init when session.created was never handled", + async () => { + await WarpPlugin({ client: makeClient(), directory: "/tmp/proj" } as any) + + // Nothing yet: the emit is deferred past plugin init. + expect(notifySpy).not.toHaveBeenCalled() + + await waitForDeferredEmit() + + expect(notifySpy).toHaveBeenCalledTimes(1) + const [title, body] = notifySpy.mock.calls[0] as [string, string] + expect(title).toBe("warp://cli-agent") + const payload = JSON.parse(body) + expect(payload.event).toBe("session_start") + expect(payload.session_id).toBe("sess-main") + expect(payload.plugin_version).toBeDefined() + }, + 10000, + ) + + it( + "does not duplicate session_start when session.created already fired", + async () => { + const handlers = (await WarpPlugin({ + client: makeClient(), + directory: "/tmp/proj", + } as any)) as any + + await handlers.event({ + event: { + type: "session.created", + properties: { info: { id: "sess-main" } }, + }, + }) + expect(notifySpy).toHaveBeenCalledTimes(1) + + // The deferred emit must be suppressed by the dedup flag. + await waitForDeferredEmit() + expect(notifySpy).toHaveBeenCalledTimes(1) + }, + 10000, + ) + + it( + "skips subagent sessions when picking the handshake session", + async () => { + const client = makeClient() + client.session.list = async () => ({ + data: [{ id: "sess-sub", parentID: "sess-main-parent" }, { id: "sess-top" }], + }) + + await WarpPlugin({ client, directory: "/tmp/proj" } as any) + await waitForDeferredEmit() + + expect(notifySpy).toHaveBeenCalledTimes(1) + const [, body] = notifySpy.mock.calls[0] as [string, string] + expect(JSON.parse(body).session_id).toBe("sess-top") + }, + 10000, + ) +})