diff --git a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt index 56a3aba1865..4f92c58f3b9 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt @@ -456,6 +456,8 @@ enum class GatewayMethod( ApprovalHistory("approval.history"), PluginSurfaceRefresh("plugin.surface.refresh"), ConversationsList("conversations.list"), + SessionDiscussionInfo("session.discussion.info"), + SessionDiscussionOpen("session.discussion.open"), } enum class GatewayEvent( diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 8580c598283..a3f1fb5f582 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -93,6 +93,12 @@ public enum SessionPlacementState: String, Codable, Sendable { case failed = "failed" } +public enum SessionDiscussionState: String, Codable, Sendable { + case none = "none" + case available = "available" + case _open = "open" +} + public enum SessionFileKind: String, Codable, Sendable { case modified = "modified" case read = "read" @@ -5024,6 +5030,100 @@ public struct SessionsReclaimResult: Codable, Sendable { } } +public struct SessionDiscussionInfo: Codable, Sendable { + public let state: SessionDiscussionState + public let embedurl: String? + public let openurl: String? + + public init( + state: SessionDiscussionState, + embedurl: String? = nil, + openurl: String? = nil) + { + self.state = state + self.embedurl = embedurl + self.openurl = openurl + } + + private enum CodingKeys: String, CodingKey { + case state + case embedurl = "embedUrl" + case openurl = "openUrl" + } +} + +public struct SessionDiscussionInfoParams: Codable, Sendable { + public let sessionkey: String + + public init( + sessionkey: String) + { + self.sessionkey = sessionkey + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + } +} + +public struct SessionDiscussionInfoResult: Codable, Sendable { + public let state: SessionDiscussionState + public let embedurl: String? + public let openurl: String? + + public init( + state: SessionDiscussionState, + embedurl: String? = nil, + openurl: String? = nil) + { + self.state = state + self.embedurl = embedurl + self.openurl = openurl + } + + private enum CodingKeys: String, CodingKey { + case state + case embedurl = "embedUrl" + case openurl = "openUrl" + } +} + +public struct SessionDiscussionOpenParams: Codable, Sendable { + public let sessionkey: String + + public init( + sessionkey: String) + { + self.sessionkey = sessionkey + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + } +} + +public struct SessionDiscussionOpenResult: Codable, Sendable { + public let state: SessionDiscussionState + public let embedurl: String? + public let openurl: String? + + public init( + state: SessionDiscussionState, + embedurl: String? = nil, + openurl: String? = nil) + { + self.state = state + self.embedurl = embedurl + self.openurl = openurl + } + + private enum CodingKeys: String, CodingKey { + case state + case embedurl = "embedUrl" + case openurl = "openUrl" + } +} + public struct SessionsCompactionListParams: Codable, Sendable { public let key: String public let agentid: String? diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index aa51bc80b13..ca1c34cb122 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -273,6 +273,7 @@ e9c4398b04d04fead0e46ec618589d8f597a0b6087805ab132138e941b76e190 module/sandbox 50beebb77e461deaccdbff038f6a461dff1d5773426322dc6d7991f6e05a7c37 module/self-hosted-provider-setup 250476d121ffa4ed67d497c59d9c7bb1886973e92ebed39325a02609217bade0 module/session-binding-runtime ed35a448c8f7650acb34739a591334267371f1ffe352505fc2808330d78580bd module/session-catalog +31b785e74f1f8f56241b7756ef6a5d86199c5ce177cbb1c234a261866972f270 module/session-discussion f07839f5b8929a179857a0b4b89f8448864078cd5972dd53f9a30e50bf55408d module/session-key-runtime f59099aa2d536246d4b1297f01bcea66796351a9259debdd45909afeb7a42d86 module/session-store-runtime b1d0a76337122cb9dbb0c89fbb8bfacc1a88ce689270d3d684019c9a03ce669a module/session-transcript-hit diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index 948d6f4431c..81a5b47a4a4 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -86,6 +86,13 @@ deprecated re-export barrels are tracked in The `register(api)` callback receives an `OpenClawPluginApi` object with these methods: +Plugins that provide an external team-chat surface for a session can register +the single process-wide provider exported by +`openclaw/plugin-sdk/session-discussion`. Its `info({ sessionKey })` method +reports whether a discussion is unavailable, ready to open, or already open; +`open({ sessionKey })` creates or resolves the discussion and returns its embed +and external URLs. Registering another provider replaces the current provider. + ### Capability registration | Method | What it registers | diff --git a/package.json b/package.json index 400d00d6f8b..3ee0e92d9a5 100644 --- a/package.json +++ b/package.json @@ -1028,6 +1028,10 @@ "types": "./dist/plugin-sdk/session-catalog.d.ts", "default": "./dist/plugin-sdk/session-catalog.js" }, + "./plugin-sdk/session-discussion": { + "types": "./dist/plugin-sdk/session-discussion.d.ts", + "default": "./dist/plugin-sdk/session-discussion.js" + }, "./plugin-sdk/session-key-runtime": { "types": "./dist/plugin-sdk/session-key-runtime.d.ts", "default": "./dist/plugin-sdk/session-key-runtime.js" diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 88000485627..7b91ef02bd1 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -465,6 +465,12 @@ import { SessionsSearchResultSchema, SessionsSendParamsSchema, SessionsUsageParamsSchema, + SessionDiscussionInfoParamsSchema, + SessionDiscussionInfoResultSchema, + SessionDiscussionInfoSchema, + SessionDiscussionOpenParamsSchema, + SessionDiscussionOpenResultSchema, + SessionDiscussionStateSchema, TaskSuggestionEventSchema, TaskSuggestionResolutionSchema, TaskSuggestionSchema, @@ -769,6 +775,10 @@ export const validateSessionsBranchesSwitchParams = lazyCompile(SessionsBranches export const validateSessionsRewindParams = lazyCompile(SessionsRewindParamsSchema); export const validateSessionsForkParams = lazyCompile(SessionsForkParamsSchema); export const validateSessionsUsageParams = lazyCompile(SessionsUsageParamsSchema); +export const validateSessionDiscussionInfoParams = lazyCompile(SessionDiscussionInfoParamsSchema); +export const validateSessionDiscussionInfoResult = lazyCompile(SessionDiscussionInfoResultSchema); +export const validateSessionDiscussionOpenParams = lazyCompile(SessionDiscussionOpenParamsSchema); +export const validateSessionDiscussionOpenResult = lazyCompile(SessionDiscussionOpenResultSchema); export const validateTaskSuggestionsListParams = lazyCompile(TaskSuggestionsListParamsSchema); export const validateTaskSuggestionsCreateParams = lazyCompile(TaskSuggestionsCreateParamsSchema); export const validateTaskSuggestionsAcceptParams = lazyCompile(TaskSuggestionsAcceptParamsSchema); @@ -1122,6 +1132,12 @@ export { SessionsGroupsMutationResultSchema, SessionsCompactParamsSchema, SessionsUsageParamsSchema, + SessionDiscussionStateSchema, + SessionDiscussionInfoSchema, + SessionDiscussionInfoParamsSchema, + SessionDiscussionInfoResultSchema, + SessionDiscussionOpenParamsSchema, + SessionDiscussionOpenResultSchema, ArtifactSummarySchema, ArtifactsListParamsSchema, ArtifactsGetParamsSchema, @@ -1615,6 +1631,12 @@ export type { SessionDiffFileStatus, SessionsDiffParams, SessionsDiffResult, + SessionDiscussionState, + SessionDiscussionInfo, + SessionDiscussionInfoParams, + SessionDiscussionInfoResult, + SessionDiscussionOpenParams, + SessionDiscussionOpenResult, ArtifactSummary, ArtifactsListParams, ArtifactsListResult, diff --git a/packages/gateway-protocol/src/schema.ts b/packages/gateway-protocol/src/schema.ts index 80943e84d65..1c83a5b425e 100644 --- a/packages/gateway-protocol/src/schema.ts +++ b/packages/gateway-protocol/src/schema.ts @@ -36,6 +36,7 @@ export * from "./schema/push.js"; export * from "./schema/questions.js"; export * from "./schema/secrets.js"; export * from "./schema/session-placement.js"; +export * from "./schema/session-discussion.js"; export * from "./schema/sessions.js"; export * from "./schema/sessions-catalog.js"; export * from "./schema/skill-history.js"; diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index efbc408d2bd..f4640faf427 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -433,6 +433,14 @@ import { SecretsResolveParamsSchema, SecretsResolveResultSchema, } from "./secrets.js"; +import { + SessionDiscussionInfoParamsSchema, + SessionDiscussionInfoResultSchema, + SessionDiscussionInfoSchema, + SessionDiscussionOpenParamsSchema, + SessionDiscussionOpenResultSchema, + SessionDiscussionStateSchema, +} from "./session-discussion.js"; import { SessionPlacementProtocolSchemas } from "./session-placement.js"; import { SessionCatalogCapabilitiesSchema, @@ -747,6 +755,12 @@ export const ProtocolSchemas = { SessionCompactionCheckpoint: SessionCompactionCheckpointSchema, SessionOperationEvent: SessionOperationEventSchema, ...SessionPlacementProtocolSchemas, + SessionDiscussionState: SessionDiscussionStateSchema, + SessionDiscussionInfo: SessionDiscussionInfoSchema, + SessionDiscussionInfoParams: SessionDiscussionInfoParamsSchema, + SessionDiscussionInfoResult: SessionDiscussionInfoResultSchema, + SessionDiscussionOpenParams: SessionDiscussionOpenParamsSchema, + SessionDiscussionOpenResult: SessionDiscussionOpenResultSchema, SessionsCompactionListParams: SessionsCompactionListParamsSchema, SessionsCompactionGetParams: SessionsCompactionGetParamsSchema, SessionsCompactionBranchParams: SessionsCompactionBranchParamsSchema, diff --git a/packages/gateway-protocol/src/schema/session-discussion.ts b/packages/gateway-protocol/src/schema/session-discussion.ts new file mode 100644 index 00000000000..04f993a2c5e --- /dev/null +++ b/packages/gateway-protocol/src/schema/session-discussion.ts @@ -0,0 +1,35 @@ +// Gateway Protocol schema module defines session discussion validation shapes. +import type { Static } from "typebox"; +import { Type } from "typebox"; +import { closedObject } from "./closed-object.js"; +import { NonEmptyString } from "./primitives.js"; + +export const SessionDiscussionStateSchema = Type.Union([ + Type.Literal("none"), + Type.Literal("available"), + Type.Literal("open"), +]); + +export const SessionDiscussionInfoSchema = closedObject({ + state: SessionDiscussionStateSchema, + embedUrl: Type.Optional(Type.String()), + openUrl: Type.Optional(Type.String()), +}); + +export const SessionDiscussionInfoParamsSchema = closedObject({ + sessionKey: NonEmptyString, +}); + +export const SessionDiscussionOpenParamsSchema = closedObject({ + sessionKey: NonEmptyString, +}); + +export const SessionDiscussionInfoResultSchema = SessionDiscussionInfoSchema; +export const SessionDiscussionOpenResultSchema = SessionDiscussionInfoSchema; + +export type SessionDiscussionState = Static; +export type SessionDiscussionInfo = Static; +export type SessionDiscussionInfoParams = Static; +export type SessionDiscussionOpenParams = Static; +export type SessionDiscussionInfoResult = Static; +export type SessionDiscussionOpenResult = Static; diff --git a/packages/gateway-protocol/src/session-discussion-validators.test.ts b/packages/gateway-protocol/src/session-discussion-validators.test.ts new file mode 100644 index 00000000000..803acb2de28 --- /dev/null +++ b/packages/gateway-protocol/src/session-discussion-validators.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + validateSessionDiscussionInfoParams, + validateSessionDiscussionInfoResult, + validateSessionDiscussionOpenParams, + validateSessionDiscussionOpenResult, +} from "./index.js"; + +describe("session discussion protocol validators", () => { + it.each([ + ["info", validateSessionDiscussionInfoParams], + ["open", validateSessionDiscussionOpenParams], + ])("requires a non-empty session key for %s", (_name, validate) => { + expect(validate({ sessionKey: "agent:main:thread" })).toBe(true); + expect(validate({ sessionKey: "" })).toBe(false); + expect(validate({})).toBe(false); + expect(validate({ sessionKey: "thread", extra: true })).toBe(false); + }); + + it.each([ + ["info", validateSessionDiscussionInfoResult], + ["open", validateSessionDiscussionOpenResult], + ])("validates discussion states and optional URLs for %s", (_name, validate) => { + expect(validate({ state: "none" })).toBe(true); + expect( + validate({ + state: "open", + embedUrl: "https://chat.example/embed/thread", + openUrl: "https://chat.example/thread", + }), + ).toBe(true); + expect(validate({ state: "unknown" })).toBe(false); + expect(validate({ state: "open", extra: true })).toBe(false); + }); +}); diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index 81f9004bdc9..5b15e8565ae 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -225,6 +225,7 @@ "response-limit-runtime", "session-binding-runtime", "session-catalog", + "session-discussion", "session-key-runtime", "session-store-runtime", "session-transcript-runtime", diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs index 38f981c1ab7..10f9b1395f4 100644 --- a/scripts/plugin-sdk-surface-report.mjs +++ b/scripts/plugin-sdk-surface-report.mjs @@ -218,7 +218,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +1: meeting-runtime barrel: browser meeting-bot core behind MeetingPlatformAdapter. // +1: question-gateway-runtime resolves ask_user choices for channel plugins. // +1: ingress-effect-once gives drained channels a narrow durable side-effect guard. - 332, + // +1: session-discussion binds one external discussion provider to sessions. + 333, env, ), // ScopeTree adds six channel-policy exports, mirrored by compat, including three functions. @@ -292,7 +293,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +2: lifecycle-owned prepared model catalog sync and async readers. // Harvest: retired tuning-knob config types -10. // Harvest: removed process-global API-provider publication functions -2. - 8180, + // +4: session discussion state, info, provider, and registration contracts. + 8184, env, ), publicFunctionExports: readPluginSdkSurfaceBudgetEnv( @@ -342,7 +344,8 @@ export function readPluginSdkSurfaceBudgets(env = process.env) { // +6: outbound echo record/query helpers across channel-outbound and mirrors. // +2: lifecycle-owned prepared model catalog sync and async readers. // Harvest: removed process-global API-provider publication functions -2. - 4555, + // +1: session discussion provider registration. + 4556, env, ), publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv( diff --git a/src/gateway/method-scopes.test.ts b/src/gateway/method-scopes.test.ts index 9ba552443be..bb7828be239 100644 --- a/src/gateway/method-scopes.test.ts +++ b/src/gateway/method-scopes.test.ts @@ -78,6 +78,8 @@ describe("method scope resolution", () => { ["sessions.catalog.read", ["operator.read"]], ["sessions.catalog.continue", ["operator.write"]], ["sessions.catalog.archive", ["operator.write"]], + ["session.discussion.info", ["operator.read"]], + ["session.discussion.open", ["operator.write"]], ["environments.status", ["operator.read"]], ["diagnostics.stability", ["operator.read"]], ["skills.curator.status", ["operator.read"]], diff --git a/src/gateway/methods/core-descriptors.since.test.ts b/src/gateway/methods/core-descriptors.since.test.ts index 1da5f7b4982..ce3405b5fe3 100644 --- a/src/gateway/methods/core-descriptors.since.test.ts +++ b/src/gateway/methods/core-descriptors.since.test.ts @@ -7,6 +7,8 @@ const CURRENT_TRAIN_METHODS = [ "question.resolve", "question.get", "question.list", + "session.discussion.info", + "session.discussion.open", "terminal.open", "terminal.input", "terminal.resize", diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index a9d48b5eef2..11ccbcc84c2 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -440,6 +440,8 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "approval.history", scope: "operator.approvals", since: "2026.7" }, { name: "plugin.surface.refresh", scope: "operator.read", since: "<=2026.7" }, { name: "conversations.list", scope: "operator.admin", since: "<=2026.7" }, + { name: "session.discussion.info", scope: "operator.read", since: "2026.7" }, + { name: "session.discussion.open", scope: "operator.write", since: "2026.7" }, ] as const; const CORE_GATEWAY_METHOD_SPEC_BY_NAME: ReadonlyMap = new Map( diff --git a/src/gateway/server-methods-list.test.ts b/src/gateway/server-methods-list.test.ts index a41a9761f03..693e819c295 100644 --- a/src/gateway/server-methods-list.test.ts +++ b/src/gateway/server-methods-list.test.ts @@ -58,7 +58,7 @@ describe("listGatewayMethods", () => { }); it("appends new methods after model probing without shifting older method indices", () => { - expect(listGatewayMethods().slice(-7)).toEqual([ + expect(listGatewayMethods().slice(-9)).toEqual([ "models.probe", "migrations.memory.plan", "migrations.memory.apply", @@ -66,6 +66,8 @@ describe("listGatewayMethods", () => { "approval.history", "plugin.surface.refresh", "conversations.list", + "session.discussion.info", + "session.discussion.open", ]); const methods = listGatewayMethods(); expect(methods.indexOf("node.pluginSurface.refresh")).toBe( @@ -126,7 +128,7 @@ describe("listGatewayMethods", () => { "exec.approval.get", ]); expect(methods).toContain("tts.speak"); - expect(coreMethods.slice(-14)).toEqual([ + expect(coreMethods.slice(-16)).toEqual([ "sessions.catalog.continue", "sessions.catalog.archive", "approval.get", @@ -141,6 +143,8 @@ describe("listGatewayMethods", () => { "approval.history", "plugin.surface.refresh", "conversations.list", + "session.discussion.info", + "session.discussion.open", ]); expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak")); expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1); diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index fd35a77adeb..0270c2025cb 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -225,6 +225,10 @@ const loadSessionCatalogHandlers = lazyHandlerModule( () => import("./server-methods/session-catalog.js"), (module) => module.sessionCatalogHandlers, ); +const loadSessionDiscussionHandlers = lazyHandlerModule( + () => import("./server-methods/session-discussion.js"), + (module) => module.sessionDiscussionHandlers, +); const loadSkillsHandlers = lazyHandlerModule( () => import("./server-methods/skills.js"), (module) => module.skillsHandlers, @@ -676,6 +680,10 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { ], loadHandlers: loadSessionCatalogHandlers, }), + ...createLazyCoreHandlers({ + methods: ["session.discussion.info", "session.discussion.open"], + loadHandlers: loadSessionDiscussionHandlers, + }), ...createLazyCoreHandlers({ methods: [ "sessions.list", diff --git a/src/gateway/server-methods/session-discussion.test.ts b/src/gateway/server-methods/session-discussion.test.ts new file mode 100644 index 00000000000..e08925a7c85 --- /dev/null +++ b/src/gateway/server-methods/session-discussion.test.ts @@ -0,0 +1,126 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SessionDiscussionProvider } from "../../plugins/session-discussion-registry.js"; +import { sessionDiscussionHandlers } from "./session-discussion.js"; + +const mocks = vi.hoisted(() => ({ getProvider: vi.fn() })); + +vi.mock("../../plugins/session-discussion-registry.js", () => ({ + getSessionDiscussionProvider: mocks.getProvider, +})); + +type Method = "session.discussion.info" | "session.discussion.open"; + +async function invoke(method: Method, params: Record) { + const calls: Array<{ ok: boolean; payload?: unknown; error?: unknown }> = []; + await sessionDiscussionHandlers[method]?.({ + req: { type: "req", id: method, method, params: {} }, + params, + client: null, + isWebchatConnect: () => false, + respond: (ok, payload, error) => calls.push({ ok, payload, error }), + context: {} as never, + }); + return calls[0]; +} + +function provider() { + const info = vi.fn().mockResolvedValue({ + state: "open", + embedUrl: "https://chat.example/embed/thread", + openUrl: "https://chat.example/thread", + }); + const open = vi.fn().mockResolvedValue({ state: "available" }); + return { + value: { id: "test", info, open } satisfies SessionDiscussionProvider, + info, + open, + }; +} + +describe("session discussion gateway methods", () => { + beforeEach(() => { + mocks.getProvider.mockReset(); + }); + + it.each(["session.discussion.info", "session.discussion.open"] as const)( + "returns none when %s has no provider", + async (method) => { + mocks.getProvider.mockReturnValue(undefined); + expect(await invoke(method, { sessionKey: "agent:main:thread" })).toMatchObject({ + ok: true, + payload: { state: "none" }, + }); + }, + ); + + it("passes the session key to info and returns its result", async () => { + const registered = provider(); + mocks.getProvider.mockReturnValue(registered.value); + + const response = await invoke("session.discussion.info", { + sessionKey: "agent:main:thread", + }); + + expect(registered.info).toHaveBeenCalledWith({ sessionKey: "agent:main:thread" }); + expect(response).toMatchObject({ + ok: true, + payload: { + state: "open", + embedUrl: "https://chat.example/embed/thread", + openUrl: "https://chat.example/thread", + }, + }); + }); + + it("passes the session key to open and returns its result", async () => { + const registered = provider(); + mocks.getProvider.mockReturnValue(registered.value); + + const response = await invoke("session.discussion.open", { + sessionKey: "agent:main:thread", + }); + + expect(registered.open).toHaveBeenCalledWith({ sessionKey: "agent:main:thread" }); + expect(response).toMatchObject({ ok: true, payload: { state: "available" } }); + }); + + it.each(["session.discussion.info", "session.discussion.open"] as const)( + "returns a retryable error when the provider throws from %s", + async (method) => { + const registered = provider(); + const operation = method === "session.discussion.info" ? registered.info : registered.open; + operation.mockRejectedValueOnce(new Error("provider failed")); + mocks.getProvider.mockReturnValue(registered.value); + + expect(await invoke(method, { sessionKey: "agent:main:thread" })).toMatchObject({ + ok: false, + error: { code: "UNAVAILABLE" }, + }); + }, + ); + + it.each(["session.discussion.info", "session.discussion.open"] as const)( + "rejects a malformed provider result from %s", + async (method) => { + const registered = provider(); + const operation = method === "session.discussion.info" ? registered.info : registered.open; + operation.mockResolvedValueOnce({ state: "invalid" } as never); + mocks.getProvider.mockReturnValue(registered.value); + + expect(await invoke(method, { sessionKey: "agent:main:thread" })).toMatchObject({ + ok: false, + error: { code: "UNAVAILABLE" }, + }); + }, + ); + + it.each(["session.discussion.info", "session.discussion.open"] as const)( + "rejects an empty session key for %s", + async (method) => { + expect(await invoke(method, { sessionKey: "" })).toMatchObject({ + ok: false, + error: { code: "INVALID_REQUEST" }, + }); + }, + ); +}); diff --git a/src/gateway/server-methods/session-discussion.ts b/src/gateway/server-methods/session-discussion.ts new file mode 100644 index 00000000000..60fb4c6d998 --- /dev/null +++ b/src/gateway/server-methods/session-discussion.ts @@ -0,0 +1,103 @@ +import { + ErrorCodes, + errorShape, + formatValidationErrors, + validateSessionDiscussionInfoParams, + validateSessionDiscussionInfoResult, + validateSessionDiscussionOpenParams, + validateSessionDiscussionOpenResult, +} from "../../../packages/gateway-protocol/src/index.js"; +import { getSessionDiscussionProvider } from "../../plugins/session-discussion-registry.js"; +import type { GatewayRequestHandlers } from "./types.js"; +import { assertValidParams } from "./validation.js"; + +export const sessionDiscussionHandlers: GatewayRequestHandlers = { + "session.discussion.info": async ({ params, respond }) => { + if ( + !assertValidParams( + params, + validateSessionDiscussionInfoParams, + "session.discussion.info", + respond, + ) + ) { + return; + } + const provider = getSessionDiscussionProvider(); + if (!provider) { + respond(true, { state: "none" }, undefined); + return; + } + try { + const result = await provider.info({ sessionKey: params.sessionKey }); + if (!validateSessionDiscussionInfoResult(result)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + `invalid session.discussion.info result: ${formatValidationErrors(validateSessionDiscussionInfoResult.errors)}`, + ), + ); + return; + } + respond(true, result, undefined); + } catch (error) { + // A throwing provider is a transient failure, not "no discussion": + // returning none here would make the UI cache-hide the feature until + // reconnect. Only an absent provider means none. + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + error instanceof Error ? error.message : "session discussion provider failed", + ), + ); + } + }, + "session.discussion.open": async ({ params, respond }) => { + if ( + !assertValidParams( + params, + validateSessionDiscussionOpenParams, + "session.discussion.open", + respond, + ) + ) { + return; + } + const provider = getSessionDiscussionProvider(); + if (!provider) { + respond(true, { state: "none" }, undefined); + return; + } + try { + const result = await provider.open({ sessionKey: params.sessionKey }); + if (!validateSessionDiscussionOpenResult(result)) { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + `invalid session.discussion.open result: ${formatValidationErrors(validateSessionDiscussionOpenResult.errors)}`, + ), + ); + return; + } + respond(true, result, undefined); + } catch (error) { + // A throwing provider is a transient failure, not "no discussion": + // returning none here would make the UI cache-hide the feature until + // reconnect. Only an absent provider means none. + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + error instanceof Error ? error.message : "session discussion provider failed", + ), + ); + } + }, +}; diff --git a/src/plugin-sdk/session-discussion.ts b/src/plugin-sdk/session-discussion.ts new file mode 100644 index 00000000000..c5315c7be57 --- /dev/null +++ b/src/plugin-sdk/session-discussion.ts @@ -0,0 +1,6 @@ +export type { + SessionDiscussionInfo, + SessionDiscussionProvider, + SessionDiscussionState, +} from "../plugins/session-discussion-registry.js"; +export { registerSessionDiscussionProvider } from "../plugins/session-discussion-registry.js"; diff --git a/src/plugins/session-discussion-registry.test.ts b/src/plugins/session-discussion-registry.test.ts new file mode 100644 index 00000000000..e6d770558ba --- /dev/null +++ b/src/plugins/session-discussion-registry.test.ts @@ -0,0 +1,42 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ warn: vi.fn() })); + +vi.mock("../logging/subsystem.js", () => ({ + createSubsystemLogger: () => ({ warn: mocks.warn }), +})); + +import { + getSessionDiscussionProvider, + registerSessionDiscussionProvider, + type SessionDiscussionProvider, +} from "./session-discussion-registry.js"; + +function provider(id: string): SessionDiscussionProvider { + return { + id, + info: vi.fn().mockResolvedValue({ state: "available" }), + open: vi.fn().mockResolvedValue({ state: "open" }), + }; +} + +describe("session discussion provider registry", () => { + beforeEach(() => { + mocks.warn.mockClear(); + }); + + it("returns the registered provider and warns when replacing it", () => { + const first = provider("first"); + const second = provider("second"); + + registerSessionDiscussionProvider(first); + expect(getSessionDiscussionProvider()).toBe(first); + expect(mocks.warn).not.toHaveBeenCalled(); + + registerSessionDiscussionProvider(second); + expect(getSessionDiscussionProvider()).toBe(second); + expect(mocks.warn).toHaveBeenCalledWith( + "replacing session discussion provider first with second", + ); + }); +}); diff --git a/src/plugins/session-discussion-registry.ts b/src/plugins/session-discussion-registry.ts new file mode 100644 index 00000000000..0a734075111 --- /dev/null +++ b/src/plugins/session-discussion-registry.ts @@ -0,0 +1,41 @@ +import { createSubsystemLogger } from "../logging/subsystem.js"; + +export type SessionDiscussionState = "none" | "available" | "open"; +export type SessionDiscussionInfo = { + state: SessionDiscussionState; + embedUrl?: string; + openUrl?: string; +}; +export type SessionDiscussionProvider = { + id: string; + info(params: { sessionKey: string }): Promise; + open(params: { sessionKey: string }): Promise; +}; + +const log = createSubsystemLogger("plugins/session-discussion"); +const SESSION_DISCUSSION_REGISTRY = Symbol.for("openclaw.sessionDiscussionRegistry"); + +type SessionDiscussionRegistry = { + provider?: SessionDiscussionProvider; +}; + +function getRegistry(): SessionDiscussionRegistry { + // The public SDK entrypoint and lazy gateway chunks may load separately; + // a global symbol keeps them on the same process-wide provider slot. + const globalStore = globalThis as typeof globalThis & { + [SESSION_DISCUSSION_REGISTRY]?: SessionDiscussionRegistry; + }; + return (globalStore[SESSION_DISCUSSION_REGISTRY] ??= {}); +} + +export function registerSessionDiscussionProvider(provider: SessionDiscussionProvider): void { + const registry = getRegistry(); + if (registry.provider) { + log.warn(`replacing session discussion provider ${registry.provider.id} with ${provider.id}`); + } + registry.provider = provider; +} + +export function getSessionDiscussionProvider(): SessionDiscussionProvider | undefined { + return getRegistry().provider; +} diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index fb052d0e795..9636984e4a8 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -4129,6 +4129,18 @@ export const en: TranslationMap = { statusRenamed: "Renamed", statusModified: "Modified", }, + sessionDiscussion: { + title: "Discussion", + show: "Show discussion", + disconnected: "Gateway is disconnected.", + loading: "Loading discussion…", + open: "Open discussion", + opening: "Opening discussion…", + opened: "Session discussion", + openExternal: "Open discussion in a new tab", + frameTitle: "Session discussion", + unavailable: "This discussion cannot be embedded.", + }, workspaceFiles: { label: "Thread workspace", expand: "Expand thread workspace", diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index 5378954c13d..5d336d80e91 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -6,6 +6,8 @@ import type { SessionCatalogHost, SessionCatalogSession, SessionCatalogTranscriptItem, + SessionDiscussionInfo, + SessionDiscussionState, SessionsCatalogContinueResult, SessionsCatalogReadResult, SessionsFilesRevealResult, @@ -53,6 +55,7 @@ import { } from "../../components/command-palette-contract.ts"; import "../../components/modal-dialog.ts"; import { createDockPanelLayout } from "../../components/dock-panel-layout.ts"; +import { icons } from "../../components/icons.ts"; import { isCloudWorkerPlacementState } from "../../components/session-row-badges.ts"; import { t } from "../../i18n/index.ts"; import { @@ -192,6 +195,7 @@ import { import { CHAT_DETAIL_FULL_MESSAGE_MAX_CHARS, type DetailFullMessageResult, + type SidebarContent, type SidebarFullMessageRequest, } from "./components/chat-sidebar.ts"; import { ChatTranscriptController } from "./components/chat-thread.ts"; @@ -406,6 +410,8 @@ class ChatPane extends OpenClawLightDomElement { private swarmBoardSnapshot: BoardSnapshot | null = null; private swarmBoardSnapshotBase: BoardSnapshot | null = null; private swarmBoardSnapshotRequest = 0; + private readonly sessionDiscussionStates = new Map(); + private readonly sessionDiscussionProbes = new Set(); private headerRenameInitialLabel: string | null = null; private headerRenameInitialValue = ""; private headerRenameSessionKey = ""; @@ -2166,6 +2172,13 @@ class ChatPane extends OpenClawLightDomElement { const nextSessionKey = catalogKey ? this.sessionKey : resolveSessionKey(this.sessionKey, this.context.gateway.snapshot.hello); + if (nextSessionKey) { + this.sessionDiscussionStates.delete(nextSessionKey); + // Resolve availability before the action renders: the methods are + // advertised even without a provider, so an unprobed session would + // otherwise show a dead Discussion button on provider-less installs. + void this.probeSessionDiscussion(nextSessionKey); + } if (nextSessionKey && nextSessionKey !== this.state.sessionKey) { this.switchPaneSession(nextSessionKey); } else if (catalogKey && this.catalogRequestedSessionKey !== this.sessionKey) { @@ -2332,6 +2345,7 @@ class ChatPane extends OpenClawLightDomElement { this.taskSuggestions = []; this.taskSuggestionBusyIds.clear(); this.taskSuggestionOperations.clear(); + this.sessionDiscussionStates.clear(); this.resetOlderMessagesViewport(); state.chatLoading = false; } @@ -2339,6 +2353,17 @@ class ChatPane extends OpenClawLightDomElement { state.connected = snapshot.connected; state.connectionEpoch = this.connectionGeneration; state.hello = snapshot.hello; + if (sourceChanged && state.sidebarContent?.kind === "session-discussion") { + // A reconnect may point at a different gateway/provider; an open panel + // would keep rendering the previous provider's URL. Close it — the + // re-probe below restores the action for the new source. + state.handleCloseSidebar(); + } + if (sourceChanged && snapshot.connected && state.sessionKey) { + // Reconnects clear the probed states above; re-probe the active session + // so the Discussion action reappears without a manual session switch. + void this.probeSessionDiscussion(state.sessionKey); + } state.terminalAvailable = this.context.config.current.terminalEnabled && snapshot.connected && @@ -2660,6 +2685,127 @@ class ChatPane extends OpenClawLightDomElement { } } + // Probe once per session activation; transient failures stay uncached so the + // next activation retries instead of permanently hiding the feature. + private async probeSessionDiscussion(sessionKey: string) { + const state = this.state; + if ( + !state?.connected || + !state.client || + this.sessionDiscussionStates.has(sessionKey) || + // One in-flight probe per key: a rapid A→B→A switch must not start a + // second probe whose slower twin could later overwrite the fresh result. + this.sessionDiscussionProbes.has(sessionKey) || + isGatewayMethodAdvertised(this.context.gateway.snapshot, "session.discussion.info") !== true + ) { + return; + } + const generation = this.connectionGeneration; + this.sessionDiscussionProbes.add(sessionKey); + try { + const info = await state.client.request("session.discussion.info", { + sessionKey, + }); + // A reconnect supersedes in-flight probes; a stale result must not + // overwrite the new source's cache (e.g. an old "none" hiding the action). + if (generation !== this.connectionGeneration) { + return; + } + this.sessionDiscussionStates.set(sessionKey, info.state); + this.requestUpdate(); + } catch { + // Leave unprobed: the action stays hidden and a later switch retries. + } finally { + this.sessionDiscussionProbes.delete(sessionKey); + // A reconnect during this probe skipped its own probe (the key was + // still held here); retry now so the new source gets a fresh answer. + if ( + generation !== this.connectionGeneration && + this.state?.sessionKey === sessionKey && + !this.sessionDiscussionStates.has(sessionKey) + ) { + void this.probeSessionDiscussion(sessionKey); + } + } + } + + private renderSessionDiscussionAction() { + const state = this.state; + const sessionKey = state?.sessionKey.trim() ?? ""; + const known = sessionKey ? this.sessionDiscussionStates.get(sessionKey) : undefined; + if ( + !state?.connected || + !state.client || + !sessionKey || + known === undefined || + known === "none" || + isGatewayMethodAdvertised(this.context.gateway.snapshot, "session.discussion.info") !== true + ) { + return nothing; + } + const canOpen = + hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null) && + isGatewayMethodAdvertised(this.context.gateway.snapshot, "session.discussion.open") === true; + const contentGeneration = this.connectionGeneration; + const content: SidebarContent = { + kind: "session-discussion", + sessionKey, + canOpen, + loadInfo: async (key) => { + if (!state.connected || !state.client) { + throw new Error(t("chat.sessionDiscussion.disconnected")); + } + return await state.client.request("session.discussion.info", { + sessionKey: key, + }); + }, + openDiscussion: async (key) => { + if (!state.connected || !state.client) { + throw new Error(t("chat.sessionDiscussion.disconnected")); + } + return await state.client.request("session.discussion.open", { + sessionKey: key, + }); + }, + onStateChange: (key, discussionState) => { + // Panels created under a previous connection may report late; their + // state belongs to the old provider and must not touch the new cache. + if (contentGeneration !== this.connectionGeneration) { + return; + } + this.sessionDiscussionStates.set(key, discussionState); + const current = state.sidebarContent; + if ( + discussionState === "none" && + current?.kind === "session-discussion" && + current.sessionKey === key + ) { + state.handleCloseSidebar(); + return; + } + state.requestUpdate(); + }, + }; + const active = + state.sidebarOpen && + state.sidebarContent?.kind === "session-discussion" && + state.sidebarContent.sessionKey === sessionKey; + const label = t("chat.sessionDiscussion.show"); + return html` + + + + `; + } + private renderPaneHeader( sessionWorkspace: SessionWorkspaceProps, backgroundTasks: BackgroundTasksProps, @@ -2733,6 +2879,7 @@ class ChatPane extends OpenClawLightDomElement { this.state?.connected === true && hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null), terminalAction: renderCatalogTerminalButton(this.state, this.catalogSession), + discussionAction: this.renderSessionDiscussionAction(), diffAction: renderSessionDiffToggle(sessionWorkspace), backgroundTasksAction: renderBackgroundTasksToggle(backgroundTasks), workspaceAction: renderSessionWorkspaceToggle(sessionWorkspace), diff --git a/ui/src/pages/chat/chat-state.ts b/ui/src/pages/chat/chat-state.ts index 04656bbbda3..12aaace3d9a 100644 --- a/ui/src/pages/chat/chat-state.ts +++ b/ui/src/pages/chat/chat-state.ts @@ -507,6 +507,9 @@ export function resetChatStateForRouteSession( saveChatMessagesForSession(state, previousSessionKey); const snapshot = restoreChatMessagesForSession(state, sessionKey); state.sessionKey = sessionKey; + if (state.sidebarContent?.kind === "session-discussion") { + state.sidebarContent = { ...state.sidebarContent, sessionKey }; + } state.selectedChatSessionArchived = state.sessionsResult?.sessions.some( (row) => row.archived === true && areUiSessionKeysEquivalent(row.key, sessionKey), diff --git a/ui/src/pages/chat/components/chat-pane-header.test.ts b/ui/src/pages/chat/components/chat-pane-header.test.ts index e5aa77c4c39..4c1bcf18e56 100644 --- a/ui/src/pages/chat/components/chat-pane-header.test.ts +++ b/ui/src/pages/chat/components/chat-pane-header.test.ts @@ -49,6 +49,7 @@ function mount(patch: Partial = {}) { copiedAction: null, canRename: true, terminalAction: nothing, + discussionAction: nothing, diffAction: nothing, backgroundTasksAction: nothing, workspaceAction: nothing, diff --git a/ui/src/pages/chat/components/chat-pane-header.ts b/ui/src/pages/chat/components/chat-pane-header.ts index ec9abfcac3e..83658700ba8 100644 --- a/ui/src/pages/chat/components/chat-pane-header.ts +++ b/ui/src/pages/chat/components/chat-pane-header.ts @@ -34,6 +34,7 @@ type ChatPaneHeaderProps = { copiedAction: ChatPaneHeaderAction | null; canRename: boolean; terminalAction: TemplateResult | typeof nothing; + discussionAction: TemplateResult | typeof nothing; diffAction: TemplateResult | typeof nothing; backgroundTasksAction: TemplateResult | typeof nothing; workspaceAction: TemplateResult | typeof nothing; @@ -297,7 +298,7 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) { ` : nothing}
- ${props.boardDockAction ?? nothing} ${props.terminalAction} + ${props.boardDockAction ?? nothing} ${props.terminalAction} ${props.discussionAction} ${props.catalog ? nothing : html`${props.diffAction} ${props.backgroundTasksAction} ${props.workspaceAction}`} diff --git a/ui/src/pages/chat/components/chat-sidebar.ts b/ui/src/pages/chat/components/chat-sidebar.ts index f420ccb71ce..7fa65d90bf4 100644 --- a/ui/src/pages/chat/components/chat-sidebar.ts +++ b/ui/src/pages/chat/components/chat-sidebar.ts @@ -20,10 +20,16 @@ import { import { copyToClipboard } from "../../../lib/clipboard.ts"; import { type EditorId, openEditor } from "../../../lib/editor-links.ts"; import { OpenClawLightDomElement } from "../../../lit/openclaw-element.ts"; +import "./session-discussion-panel.ts"; import "./session-diff-panel.ts"; import { renderChatSidebarEditorMenu } from "./chat-sidebar-editor-menu.ts"; import type { FileEditorViewHandle } from "./file-editor-view.ts"; import type { SessionDiffLoader } from "./session-diff-panel.ts"; +import type { + SessionDiscussionInfoLoader, + SessionDiscussionOpener, + SessionDiscussionStateListener, +} from "./session-discussion-panel.ts"; export const CHAT_DETAIL_FULL_MESSAGE_MAX_CHARS = 500_000; @@ -81,6 +87,18 @@ type SessionDiffSidebarContent = { unavailableReason?: DetailUnavailableReason | null; }; +type SessionDiscussionSidebarContent = { + kind: "session-discussion"; + sessionKey: string; + canOpen: boolean; + loadInfo: SessionDiscussionInfoLoader; + openDiscussion: SessionDiscussionOpener; + onStateChange: SessionDiscussionStateListener; + rawText?: string | null; + fullMessageRequest?: SidebarFullMessageRequest; + unavailableReason?: DetailUnavailableReason | null; +}; + type FileSaveOutcome = | { ok: true; hash: string; updatedAtMs?: number } | { ok: false; code: "conflict"; currentHash?: string } @@ -134,6 +152,7 @@ export type SidebarContent = | CanvasSidebarContent | ImageSidebarContent | FileSidebarContent + | SessionDiscussionSidebarContent | SessionDiffSidebarContent; function hasFullMessageRequest(content: SidebarContent): content is SidebarContent & { @@ -513,9 +532,11 @@ function renderMarkdownSidebar(props: MarkdownSidebarProps) { ? content.name.trim() || "File" : content?.kind === "session-diff" ? t("chat.sessionDiff.title") - : content?.kind === "markdown" - ? "Markdown Preview" - : "Tool Details"; + : content?.kind === "session-discussion" + ? t("chat.sessionDiscussion.title") + : content?.kind === "markdown" + ? "Markdown Preview" + : "Tool Details"; return html`