diff --git a/src/plugin/build-team-idle-wake-hint-client.test.ts b/src/plugin/build-team-idle-wake-hint-client.test.ts new file mode 100644 index 000000000..f845e35a5 --- /dev/null +++ b/src/plugin/build-team-idle-wake-hint-client.test.ts @@ -0,0 +1,100 @@ +/// +import { describe, test, expect } from "bun:test" + +import { buildTeamIdleWakeHintClient } from "./build-team-idle-wake-hint-client" + +type FakeSdkHttp = { + post: (args: { url: string; body?: unknown }) => Promise<{ url: string; body?: unknown }> +} + +type SdkLikeSession = { + _client: FakeSdkHttp + promptAsync: (options: { path: { id: string }; body?: unknown }) => Promise<{ url: string; body?: unknown }> + status: () => Promise<{ url: string; _client: FakeSdkHttp }> +} + +function createSdkLikeSession(http: FakeSdkHttp): SdkLikeSession { + return { + _client: http, + async promptAsync(options) { + return this._client.post({ url: `/session/${options.path.id}/prompt_async`, body: options.body }) + }, + async status() { + return { url: "/session", _client: this._client } + }, + } +} + +describe("buildTeamIdleWakeHintClient", () => { + test("#given a real-SDK-like session whose promptAsync reads this._client #when the wrapper dispatches the bound method #then the SDK receives the call with _client preserved", async () => { + // given + const calls: Array<{ url: string; body?: unknown }> = [] + const http: FakeSdkHttp = { + post: async (args) => { + calls.push(args) + return args + }, + } + const session = createSdkLikeSession(http) + const sdkClient = { session } as unknown as Parameters[0] + + // when + const wrapped = buildTeamIdleWakeHintClient(sdkClient) + await wrapped.session.promptAsync?.({ path: { id: "ses_regression" }, body: { hello: "world" } } as never) + + // then + expect(calls).toHaveLength(1) + expect(calls[0]?.url).toBe("/session/ses_regression/prompt_async") + expect(calls[0]?.body).toEqual({ hello: "world" }) + }) + + test("#given a real-SDK-like session whose status reads this._client #when the wrapper dispatches the bound status #then _client is preserved", async () => { + // given + const http: FakeSdkHttp = { + post: async (args) => args, + } + const session = createSdkLikeSession(http) + const sdkClient = { session } as unknown as Parameters[0] + + // when + const wrapped = buildTeamIdleWakeHintClient(sdkClient) + const result = (await wrapped.session.status?.()) as { _client?: FakeSdkHttp } | undefined + + // then + expect(result?._client).toBe(http) + }) + + test("#given a session without optional methods #when the wrapper is built #then it gracefully exposes undefined entries", async () => { + // given + const partial = { session: {} } as unknown as Parameters[0] + + // when + const wrapped = buildTeamIdleWakeHintClient(partial) + + // then + expect(wrapped.session.promptAsync).toBeUndefined() + expect(wrapped.session.status).toBeUndefined() + }) + + test("#given a destructure-without-bind pattern #when promptAsync is invoked via a plain wrapper #then this._client is undefined (historical bug)", async () => { + // given + const http: FakeSdkHttp = { post: async (args) => args } + const session = createSdkLikeSession(http) + const brokenWrapper = { + session: { + promptAsync: session.promptAsync, + }, + } + + // when + let caughtMessage = "" + try { + await brokenWrapper.session.promptAsync({ path: { id: "ses_x" } } as never) + } catch (error) { + caughtMessage = error instanceof Error ? error.message : String(error) + } + + // then + expect(caughtMessage).toContain("_client") + }) +}) diff --git a/src/plugin/build-team-idle-wake-hint-client.ts b/src/plugin/build-team-idle-wake-hint-client.ts new file mode 100644 index 000000000..53250e904 --- /dev/null +++ b/src/plugin/build-team-idle-wake-hint-client.ts @@ -0,0 +1,25 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +type SdkSession = PluginInput["client"]["session"] +type SdkPromptAsync = SdkSession["promptAsync"] +type SdkStatus = SdkSession["status"] + +export type TeamIdleWakeHintNarrowClient = { + session: { + promptAsync?: SdkPromptAsync + status?: SdkStatus + } +} + +export function buildTeamIdleWakeHintClient(client: PluginInput["client"]): TeamIdleWakeHintNarrowClient { + const session = client.session + const promptAsync = typeof session.promptAsync === "function" + ? session.promptAsync.bind(session) as SdkPromptAsync + : undefined + const status = typeof session.status === "function" + ? session.status.bind(session) as SdkStatus + : undefined + return { + session: { promptAsync, status }, + } +} diff --git a/src/plugin/event.ts b/src/plugin/event.ts index 62a0f877c..89f6fb668 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -39,6 +39,7 @@ import { deleteSessionTools } from "../shared/session-tools-store"; import { lspManager } from "../tools"; import { dispatchOpenClawEvent } from "../openclaw/runtime-dispatch"; import { createTeamIdleWakeHint } from "../hooks/team-session-events/team-idle-wake-hint"; +import { buildTeamIdleWakeHintClient } from "./build-team-idle-wake-hint-client"; import { createTeamLeadOrphanHandler } from "../hooks/team-session-events/team-lead-orphan-handler"; import { createTeamMemberErrorHandler } from "../hooks/team-session-events/team-member-error-handler"; import { createTeamMemberStatusHandler } from "../hooks/team-session-events/team-member-status-handler"; @@ -343,15 +344,10 @@ export function createEventHandler(args: { const teamMemberStatusHandler = teamModeConfig ? createTeamMemberStatusHandler(teamModeConfig) : undefined; - const teamIdleWakeHint = teamModeConfig && pluginContext.client.session?.promptAsync + const teamIdleWakeHint = teamModeConfig && typeof pluginContext.client.session?.promptAsync === "function" ? createTeamIdleWakeHint({ directory: pluginContext.directory, - client: { - session: { - promptAsync: pluginContext.client.session.promptAsync, - status: pluginContext.client.session.status, - }, - }, + client: buildTeamIdleWakeHintClient(pluginContext.client), }, teamModeConfig) : undefined; const TMUX_ACTIVITY_EVENT_TYPES = new Set([