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 df4c9393bc5b..870573faa760 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 @@ -491,6 +491,10 @@ enum class GatewayMethod( SessionMembersList("session.members.list"), SessionMembersAdd("session.members.add"), SessionMembersRemove("session.members.remove"), + SessionSuggestionsAdd("session.suggestions.add"), + SessionSuggestionsList("session.suggestions.list"), + SessionSuggestionsResolve("session.suggestions.resolve"), + SessionTyping("session.typing"), } enum class GatewayEvent( @@ -505,6 +509,8 @@ enum class GatewayEvent( SessionObserver("session.observer"), SessionOperation("session.operation"), SessionSharing("session.sharing"), + SessionSuggestion("session.suggestion"), + SessionTyping("session.typing"), SessionTool("session.tool"), SessionsChanged("sessions.changed"), Presence("presence"), diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 16f0db053c70..06c36d240160 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -116,6 +116,24 @@ public enum SessionSharingAction: String, Codable, Sendable { case memberRemoved = "member-removed" } +public enum SessionSuggestionState: String, Codable, Sendable { + case pending = "pending" + case accepted = "accepted" + case dismissed = "dismissed" +} + +public enum SessionSuggestionAction: String, Codable, Sendable { + case added = "added" + case resolved = "resolved" +} + +public enum SessionSuggestionResolution: String, Codable, Sendable { + case send = "send" + case queue = "queue" + case edit = "edit" + case dismiss = "dismiss" +} + public enum SessionPlacementState: String, Codable, Sendable { case local = "local" case requested = "requested" @@ -5422,6 +5440,252 @@ public struct SessionSharingEvent: Codable, Sendable { } } +public struct SessionSuggestion: Codable, Sendable { + public let id: String + public let sessionkey: String + public let agentid: String + public let author: SessionSharingIdentity + public let text: String + public let createdat: Int + public let state: SessionSuggestionState + + public init( + id: String, + sessionkey: String, + agentid: String, + author: SessionSharingIdentity, + text: String, + createdat: Int, + state: SessionSuggestionState) + { + self.id = id + self.sessionkey = sessionkey + self.agentid = agentid + self.author = author + self.text = text + self.createdat = createdat + self.state = state + } + + private enum CodingKeys: String, CodingKey { + case id + case sessionkey = "sessionKey" + case agentid = "agentId" + case author + case text + case createdat = "createdAt" + case state + } +} + +public struct SessionSuggestionsAddParams: Codable, Sendable { + public let sessionkey: String + public let agentid: String? + public let text: String + + public init( + sessionkey: String, + agentid: String? = nil, + text: String) + { + self.sessionkey = sessionkey + self.agentid = agentid + self.text = text + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case agentid = "agentId" + case text + } +} + +public struct SessionSuggestionsAddResult: Codable, Sendable { + public let suggestion: SessionSuggestion + + public init( + suggestion: SessionSuggestion) + { + self.suggestion = suggestion + } + + private enum CodingKeys: String, CodingKey { + case suggestion + } +} + +public struct SessionSuggestionsListParams: Codable, Sendable { + public let sessionkey: String + public let agentid: String? + + public init( + sessionkey: String, + agentid: String? = nil) + { + self.sessionkey = sessionkey + self.agentid = agentid + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case agentid = "agentId" + } +} + +public struct SessionSuggestionsListResult: Codable, Sendable { + public let suggestions: [SessionSuggestion] + public let role: SessionSharingRole + + public init( + suggestions: [SessionSuggestion], + role: SessionSharingRole) + { + self.suggestions = suggestions + self.role = role + } + + private enum CodingKeys: String, CodingKey { + case suggestions + case role + } +} + +public struct SessionSuggestionsResolveParams: Codable, Sendable { + public let sessionkey: String + public let agentid: String? + public let id: String + public let resolution: SessionSuggestionResolution + + public init( + sessionkey: String, + agentid: String? = nil, + id: String, + resolution: SessionSuggestionResolution) + { + self.sessionkey = sessionkey + self.agentid = agentid + self.id = id + self.resolution = resolution + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case agentid = "agentId" + case id + case resolution + } +} + +public struct SessionSuggestionsResolveResult: Codable, Sendable { + public let suggestion: SessionSuggestion + + public init( + suggestion: SessionSuggestion) + { + self.suggestion = suggestion + } + + private enum CodingKeys: String, CodingKey { + case suggestion + } +} + +public struct SessionSuggestionEvent: Codable, Sendable { + public let action: SessionSuggestionAction + public let suggestion: SessionSuggestion + + public init( + action: SessionSuggestionAction, + suggestion: SessionSuggestion) + { + self.action = action + self.suggestion = suggestion + } + + private enum CodingKeys: String, CodingKey { + case action + case suggestion + } +} + +public struct SessionTypingParams: Codable, Sendable { + public let sessionkey: String + public let agentid: String? + public let sessionid: String + public let typing: Bool + + public init( + sessionkey: String, + agentid: String? = nil, + sessionid: String, + typing: Bool) + { + self.sessionkey = sessionkey + self.agentid = agentid + self.sessionid = sessionid + self.typing = typing + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case agentid = "agentId" + case sessionid = "sessionId" + case typing + } +} + +public struct SessionTypingResult: Codable, Sendable { + public let ok: Bool + public let broadcast: Bool + + public init( + ok: Bool, + broadcast: Bool) + { + self.ok = ok + self.broadcast = broadcast + } + + private enum CodingKeys: String, CodingKey { + case ok + case broadcast + } +} + +public struct SessionTypingEvent: Codable, Sendable { + public let sessionkey: String + public let sessionid: String + public let agentid: String + public let actor: SessionSharingIdentity + public let typing: Bool + public let ts: Int + + public init( + sessionkey: String, + sessionid: String, + agentid: String, + actor: SessionSharingIdentity, + typing: Bool, + ts: Int) + { + self.sessionkey = sessionkey + self.sessionid = sessionid + self.agentid = agentid + self.actor = actor + self.typing = typing + self.ts = ts + } + + private enum CodingKeys: String, CodingKey { + case sessionkey = "sessionKey" + case sessionid = "sessionId" + case agentid = "agentId" + case actor + case typing + case ts + } +} + public struct LocalSessionPlacement: Codable, Sendable { public let state: String public let generation: Int diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts index 77a36bc25de3..ab493be8ed14 100644 --- a/packages/gateway-protocol/src/index.ts +++ b/packages/gateway-protocol/src/index.ts @@ -31,6 +31,7 @@ export { type SessionCreatedActor, type SessionRow, } from "./schema/sessions-row.js"; +export * from "./schema/sessions-suggestions.js"; export * from "./migration-api.js"; export type * from "./public-session-catalog.js"; import { @@ -459,6 +460,10 @@ import { SessionSharingEventSchema, SessionSharingIdentitySchema, SessionSharingRoleSchema, + SessionSuggestionsAddParamsSchema, + SessionSuggestionsListParamsSchema, + SessionSuggestionsResolveParamsSchema, + SessionTypingParamsSchema, SessionVisibilitySchema, SessionVisibilitySetParamsSchema, SessionVisibilitySetResultSchema, @@ -815,6 +820,12 @@ export const validateSessionVisibilitySetParams = lazyCompile(SessionVisibilityS export const validateSessionMembersListParams = lazyCompile(SessionMembersListParamsSchema); export const validateSessionMemberAddParams = lazyCompile(SessionMemberAddParamsSchema); export const validateSessionMemberRemoveParams = lazyCompile(SessionMemberRemoveParamsSchema); +export const validateSessionSuggestionsAddParams = lazyCompile(SessionSuggestionsAddParamsSchema); +export const validateSessionSuggestionsListParams = lazyCompile(SessionSuggestionsListParamsSchema); +export const validateSessionSuggestionsResolveParams = lazyCompile( + SessionSuggestionsResolveParamsSchema, +); +export const validateSessionTypingParams = lazyCompile(SessionTypingParamsSchema); export const validateSessionsCreateParams = lazyCompile(SessionsCreateParamsSchema); export const validateSessionsSendParams = lazyCompile(SessionsSendParamsSchema); export const validateSessionsDispatchParams = lazyCompile(SessionsDispatchParamsSchema); diff --git a/packages/gateway-protocol/src/schema.ts b/packages/gateway-protocol/src/schema.ts index 6c6d83a15d22..2a2664c56b14 100644 --- a/packages/gateway-protocol/src/schema.ts +++ b/packages/gateway-protocol/src/schema.ts @@ -40,6 +40,7 @@ export * from "./schema/session-placement.js"; export * from "./schema/session-discussion.js"; export * from "./schema/sessions.js"; export * from "./schema/sessions-sharing.js"; +export * from "./schema/sessions-suggestions.js"; export * from "./schema/sessions-catalog.js"; export * from "./schema/skill-history.js"; export * from "./schema/snapshot.js"; diff --git a/packages/gateway-protocol/src/schema/protocol-schemas.ts b/packages/gateway-protocol/src/schema/protocol-schemas.ts index 4160c794f3b8..bdcd46a4223d 100644 --- a/packages/gateway-protocol/src/schema/protocol-schemas.ts +++ b/packages/gateway-protocol/src/schema/protocol-schemas.ts @@ -507,6 +507,22 @@ import { SessionVisibilitySetParamsSchema, SessionVisibilitySetResultSchema, } from "./sessions-sharing.js"; +import { + SessionSuggestionEventSchema, + SessionSuggestionActionSchema, + SessionSuggestionResolutionSchema, + SessionSuggestionSchema, + SessionSuggestionStateSchema, + SessionSuggestionsAddParamsSchema, + SessionSuggestionsAddResultSchema, + SessionSuggestionsListParamsSchema, + SessionSuggestionsListResultSchema, + SessionSuggestionsResolveParamsSchema, + SessionSuggestionsResolveResultSchema, + SessionTypingEventSchema, + SessionTypingParamsSchema, + SessionTypingResultSchema, +} from "./sessions-suggestions.js"; import { SessionBranchSchema, SessionsAbortParamsSchema, @@ -856,6 +872,20 @@ export const ProtocolSchemas = { SessionMemberMutationResult: SessionMemberMutationResultSchema, SessionSharingAction: SessionSharingActionSchema, SessionSharingEvent: SessionSharingEventSchema, + SessionSuggestionState: SessionSuggestionStateSchema, + SessionSuggestionAction: SessionSuggestionActionSchema, + SessionSuggestionResolution: SessionSuggestionResolutionSchema, + SessionSuggestion: SessionSuggestionSchema, + SessionSuggestionsAddParams: SessionSuggestionsAddParamsSchema, + SessionSuggestionsAddResult: SessionSuggestionsAddResultSchema, + SessionSuggestionsListParams: SessionSuggestionsListParamsSchema, + SessionSuggestionsListResult: SessionSuggestionsListResultSchema, + SessionSuggestionsResolveParams: SessionSuggestionsResolveParamsSchema, + SessionSuggestionsResolveResult: SessionSuggestionsResolveResultSchema, + SessionSuggestionEvent: SessionSuggestionEventSchema, + SessionTypingParams: SessionTypingParamsSchema, + SessionTypingResult: SessionTypingResultSchema, + SessionTypingEvent: SessionTypingEventSchema, ...SessionPlacementProtocolSchemas, SessionDiscussionState: SessionDiscussionStateSchema, SessionDiscussionInfo: SessionDiscussionInfoSchema, diff --git a/packages/gateway-protocol/src/schema/sessions-suggestions.test.ts b/packages/gateway-protocol/src/schema/sessions-suggestions.test.ts new file mode 100644 index 000000000000..328310072721 --- /dev/null +++ b/packages/gateway-protocol/src/schema/sessions-suggestions.test.ts @@ -0,0 +1,78 @@ +import { Value } from "typebox/value"; +import { describe, expect, it } from "vitest"; +import { + SessionSuggestionEventSchema, + SessionSuggestionsAddParamsSchema, + SessionSuggestionsListResultSchema, + SessionSuggestionsResolveParamsSchema, + SessionTypingEventSchema, + SessionTypingParamsSchema, +} from "./sessions-suggestions.js"; + +const suggestion = { + id: "suggestion-1", + sessionKey: "agent:main:main", + agentId: "main", + author: { type: "human", id: "alice", label: "Alice" }, + text: "Try the smaller refactor", + createdAt: 1, + state: "pending", +}; + +describe("session suggestions protocol", () => { + it("accepts suggestion RPC and event payloads", () => { + expect( + Value.Check(SessionSuggestionsAddParamsSchema, { + sessionKey: "agent:main:main", + text: "Try the smaller refactor", + }), + ).toBe(true); + expect( + Value.Check(SessionSuggestionsResolveParamsSchema, { + sessionKey: "agent:main:main", + id: "suggestion-1", + resolution: "queue", + }), + ).toBe(true); + expect( + Value.Check(SessionSuggestionsListResultSchema, { + suggestions: [suggestion], + role: "owner", + }), + ).toBe(true); + expect(Value.Check(SessionSuggestionEventSchema, { action: "added", suggestion })).toBe(true); + expect( + Value.Check(SessionTypingParamsSchema, { + sessionKey: "agent:main:main", + sessionId: "session-main", + typing: true, + }), + ).toBe(true); + expect( + Value.Check(SessionTypingEventSchema, { + sessionKey: "agent:main:main", + sessionId: "session-main", + agentId: "main", + actor: { type: "human", id: "alice", label: "Alice" }, + typing: true, + ts: 1, + }), + ).toBe(true); + }); + + it("rejects empty suggestions and unknown resolutions", () => { + expect( + Value.Check(SessionSuggestionsAddParamsSchema, { + sessionKey: "agent:main:main", + text: "", + }), + ).toBe(false); + expect( + Value.Check(SessionSuggestionsResolveParamsSchema, { + sessionKey: "agent:main:main", + id: "suggestion-1", + resolution: "accept", + }), + ).toBe(false); + }); +}); diff --git a/packages/gateway-protocol/src/schema/sessions-suggestions.ts b/packages/gateway-protocol/src/schema/sessions-suggestions.ts new file mode 100644 index 000000000000..426052677fa2 --- /dev/null +++ b/packages/gateway-protocol/src/schema/sessions-suggestions.ts @@ -0,0 +1,104 @@ +import type { Static } from "typebox"; +import { Type } from "typebox"; +import { closedObject } from "./closed-object.js"; +import { NonEmptyString } from "./primitives.js"; +import { SessionSharingIdentitySchema, SessionSharingRoleSchema } from "./sessions-sharing.js"; + +const SessionSuggestionTargetParamsSchema = { + sessionKey: NonEmptyString, + agentId: Type.Optional(NonEmptyString), +}; + +export const SessionSuggestionStateSchema = Type.Union([ + Type.Literal("pending"), + Type.Literal("accepted"), + Type.Literal("dismissed"), +]); + +export const SessionSuggestionResolutionSchema = Type.Union([ + Type.Literal("send"), + Type.Literal("queue"), + Type.Literal("edit"), + Type.Literal("dismiss"), +]); + +export const SessionSuggestionActionSchema = Type.Union([ + Type.Literal("added"), + Type.Literal("resolved"), +]); + +export const SessionSuggestionSchema = closedObject({ + id: NonEmptyString, + sessionKey: NonEmptyString, + agentId: NonEmptyString, + author: SessionSharingIdentitySchema, + text: Type.String({ minLength: 1, maxLength: 32_768 }), + createdAt: Type.Integer({ minimum: 0 }), + state: SessionSuggestionStateSchema, +}); + +export const SessionSuggestionsAddParamsSchema = closedObject({ + ...SessionSuggestionTargetParamsSchema, + text: Type.String({ minLength: 1, maxLength: 32_768 }), +}); + +export const SessionSuggestionsListParamsSchema = closedObject(SessionSuggestionTargetParamsSchema); + +export const SessionSuggestionsResolveParamsSchema = closedObject({ + ...SessionSuggestionTargetParamsSchema, + id: NonEmptyString, + resolution: SessionSuggestionResolutionSchema, +}); + +export const SessionSuggestionsAddResultSchema = closedObject({ + suggestion: SessionSuggestionSchema, +}); + +export const SessionSuggestionsListResultSchema = closedObject({ + suggestions: Type.Array(SessionSuggestionSchema), + role: SessionSharingRoleSchema, +}); + +export const SessionSuggestionsResolveResultSchema = closedObject({ + suggestion: SessionSuggestionSchema, +}); + +export const SessionSuggestionEventSchema = closedObject({ + action: SessionSuggestionActionSchema, + suggestion: SessionSuggestionSchema, +}); + +export const SessionTypingParamsSchema = closedObject({ + ...SessionSuggestionTargetParamsSchema, + sessionId: NonEmptyString, + typing: Type.Boolean(), +}); + +export const SessionTypingResultSchema = closedObject({ + ok: Type.Literal(true), + broadcast: Type.Boolean(), +}); + +export const SessionTypingEventSchema = closedObject({ + sessionKey: NonEmptyString, + sessionId: NonEmptyString, + agentId: NonEmptyString, + actor: SessionSharingIdentitySchema, + typing: Type.Boolean(), + ts: Type.Integer({ minimum: 0 }), +}); + +export type SessionSuggestionState = Static; +export type SessionSuggestionResolution = Static; +export type SessionSuggestionAction = Static; +export type SessionSuggestion = Static; +export type SessionSuggestionsAddParams = Static; +export type SessionSuggestionsListParams = Static; +export type SessionSuggestionsResolveParams = Static; +export type SessionSuggestionsAddResult = Static; +export type SessionSuggestionsListResult = Static; +export type SessionSuggestionsResolveResult = Static; +export type SessionSuggestionEvent = Static; +export type SessionTypingParams = Static; +export type SessionTypingResult = Static; +export type SessionTypingEvent = Static; diff --git a/scripts/protocol-event-coverage.allowlist.json b/scripts/protocol-event-coverage.allowlist.json index 19de20b5260f..0a6ac9914feb 100644 --- a/scripts/protocol-event-coverage.allowlist.json +++ b/scripts/protocol-event-coverage.allowlist.json @@ -24,7 +24,9 @@ "terminal.exit": "Embedded terminal is a web/desktop surface; iOS has no terminal client.", "update.available": "Gateway self-update notices do not apply to iOS; app updates ship via the App Store.", "session.approval": "Native approval review uses exec.approval push/nudge delivery; the session-scoped approval stream is a Control UI chat surface.", - "session.sharing": "Session visibility/membership management is a Control UI operator surface; iOS reads visibility/sharingRole from session rows and has no sharing editor." + "session.sharing": "Session visibility/membership management is a Control UI operator surface; iOS reads visibility/sharingRole from session rows and has no sharing editor.", + "session.suggestion": "The suggestion queue is a Control UI collaboration surface; iOS does not render or resolve session suggestions.", + "session.typing": "Collaborative typing state is a Control UI-only ephemeral indicator; iOS does not render it." }, "android": { "session.operation": "Chat UI derives run state from chat/agent events; no session.operation consumer yet.", @@ -51,6 +53,8 @@ "terminal.exit": "Embedded terminal is a web/desktop surface; Android has no terminal client.", "session.approval": "Native approval review uses exec.approval push/nudge delivery; the session-scoped approval stream is a Control UI chat surface.", "session.sharing": "Session visibility/membership management is a Control UI operator surface; Android reads visibility/sharingRole from session rows and has no sharing editor.", + "session.suggestion": "The suggestion queue is a Control UI collaboration surface; Android does not render or resolve session suggestions.", + "session.typing": "Collaborative typing state is a Control UI-only ephemeral indicator; Android does not render it.", "node.invoke.cancel": "Cancel targets streaming agent.cli.claude.run.v1 invokes; app nodes never advertise agent runs, so no cancel can address them.", "node.invoke.input": "Carries terminal keystrokes/resize to a node PTY relay invoke; the relay runs on gateway/CLI node hosts and app nodes never host it, so Android has no consumer." } diff --git a/src/config/sessions.ts b/src/config/sessions.ts index 14d26cf9cb37..320c577a7cbb 100644 --- a/src/config/sessions.ts +++ b/src/config/sessions.ts @@ -26,6 +26,7 @@ export * from "./sessions/session-file.js"; export * from "./sessions/session-file-rotation.js"; export * from "./sessions/session-registry-maintenance.js"; export * from "./sessions/session-sharing-store.js"; +export * from "./sessions/session-suggestion-store.js"; export * from "./sessions/delivery-info.js"; export * from "./sessions/disk-budget.js"; export * from "./sessions/targets.js"; diff --git a/src/config/sessions/session-accessor.sqlite-entry-store.ts b/src/config/sessions/session-accessor.sqlite-entry-store.ts index 51ec39b6d896..6ca8f2442f66 100644 --- a/src/config/sessions/session-accessor.sqlite-entry-store.ts +++ b/src/config/sessions/session-accessor.sqlite-entry-store.ts @@ -12,7 +12,7 @@ import { upsertConversationIdentity, } from "./session-accessor.sqlite-conversation.js"; import { - clearSessionMembersForKey, + clearSessionCollaborationForKey, deleteSessionNodeArtifacts, rehomeLegacySessionNodeArtifacts, } from "./session-accessor.sqlite-node-artifacts.js"; @@ -522,10 +522,10 @@ export function writeSessionEntry( if (previousEntry && previousEntry.sessionId !== normalizedEntry.sessionId) { delete normalizedEntry.visibility; } - // Membership belongs to the exact canonical row being overwritten, which - // can differ from the selected alias during canonicalization. + // Collaboration rows belong to the exact canonical node being overwritten, + // which can differ from the selected alias during canonicalization. if (canonicalPreviousEntry && canonicalPreviousEntry.sessionId !== normalizedEntry.sessionId) { - clearSessionMembersForKey(database, sessionKey); + clearSessionCollaborationForKey(database, sessionKey); } // Registry writes snapshot the current transcript watermark so recovery can // distinguish same-millisecond transcript writes before and after this row. diff --git a/src/config/sessions/session-accessor.sqlite-node-artifacts.ts b/src/config/sessions/session-accessor.sqlite-node-artifacts.ts index 895f7d6f3670..2c3ce8533e02 100644 --- a/src/config/sessions/session-accessor.sqlite-node-artifacts.ts +++ b/src/config/sessions/session-accessor.sqlite-node-artifacts.ts @@ -5,18 +5,24 @@ import { import type { OpenClawAgentDatabase } from "../../state/openclaw-agent-db.js"; import { getSessionKysely } from "./session-accessor.sqlite-scope.js"; -export function clearSessionMembersForKey( +export function clearSessionCollaborationForKey( database: OpenClawAgentDatabase, sessionKey: string, ): void { - if (!readSessionNodeArtifactTables(database).has("session_members")) { - return; - } + const presentTables = readSessionNodeArtifactTables(database); const db = getSessionKysely(database.db); - executeSqliteQuerySync( - database.db, - db.deleteFrom("session_members").where("session_key", "=", sessionKey), - ); + if (presentTables.has("session_members")) { + executeSqliteQuerySync( + database.db, + db.deleteFrom("session_members").where("session_key", "=", sessionKey), + ); + } + if (presentTables.has("session_suggestions")) { + executeSqliteQuerySync( + database.db, + db.deleteFrom("session_suggestions").where("session_key", "=", sessionKey), + ); + } } export function rehomeLegacySessionNodeArtifacts( @@ -154,6 +160,15 @@ export function rehomeLegacySessionNodeArtifacts( ); } } + if (presentTables.has("session_suggestions")) { + executeSqliteQuerySync( + database.db, + db + .updateTable("session_suggestions") + .set({ session_key: canonicalKey }) + .where("session_key", "=", legacyKey), + ); + } } export function deleteSessionNodeArtifacts( @@ -178,7 +193,7 @@ export function deleteSessionNodeArtifacts( db.deleteFrom("heartbeat_outcomes").where("session_key", "=", sessionKey), ); } - clearSessionMembersForKey(database, sessionKey); + clearSessionCollaborationForKey(database, sessionKey); } function readSessionNodeArtifactTables(database: OpenClawAgentDatabase): Set { @@ -195,6 +210,7 @@ function readSessionNodeArtifactTables(database: OpenClawAgentDatabase): Set (row.name ? [row.name] : [])), ); diff --git a/src/config/sessions/session-accessor.sqlite-scope.ts b/src/config/sessions/session-accessor.sqlite-scope.ts index 29d0f772a4bf..fd62452d11fa 100644 --- a/src/config/sessions/session-accessor.sqlite-scope.ts +++ b/src/config/sessions/session-accessor.sqlite-scope.ts @@ -35,6 +35,7 @@ type SessionSqliteDatabase = Pick< | "session_conversations" | "session_members" | "session_nodes" + | "session_suggestions" | "session_windows" | "transcript_rewrite_watermarks" | "trajectory_runtime_events" diff --git a/src/config/sessions/session-suggestion-store.test.ts b/src/config/sessions/session-suggestion-store.test.ts new file mode 100644 index 000000000000..aa28e0e66d5c --- /dev/null +++ b/src/config/sessions/session-suggestion-store.test.ts @@ -0,0 +1,258 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + closeOpenClawAgentDatabasesForTest, + openOpenClawAgentDatabase, +} from "../../state/openclaw-agent-db.js"; +import { withTempDir } from "../../test-helpers/temp-dir.js"; +import { upsertSessionEntry } from "./session-accessor.js"; +import { + addSessionSuggestion, + claimSessionSuggestionDispatch, + finalizeSessionSuggestionClaim, + listSessionSuggestions, + SESSION_SUGGESTION_DISPATCH_CLAIM_TTL_MS, +} from "./session-suggestion-store.js"; + +const MAX_PENDING_SESSION_SUGGESTIONS_PER_AUTHOR = 20; +const MAX_RETAINED_RESOLVED_SESSION_SUGGESTIONS = 200; + +function resolvePendingSuggestion(params: { + scope: { agentId: string; env: NodeJS.ProcessEnv; sessionKey: string }; + id: string; + state: "accepted" | "dismissed"; + expectedSessionId: string; +}) { + const claim = claimSessionSuggestionDispatch(params.scope, { + id: params.id, + resolution: params.state === "accepted" ? "edit" : "dismiss", + expectedSessionId: params.expectedSessionId, + }); + return claim?.kind === "claimed" + ? finalizeSessionSuggestionClaim(params.scope, { + id: params.id, + token: claim.token, + state: params.state, + expectedSessionId: params.expectedSessionId, + }) + : null; +} + +afterEach(() => closeOpenClawAgentDatabasesForTest()); + +describe("session suggestion store", () => { + it("lazily ensures deterministic rows and resolves only pending suggestions", async () => { + await withTempDir({ prefix: "openclaw-session-suggestions-" }, async (dir) => { + const env = { ...process.env, OPENCLAW_STATE_DIR: dir }; + const scope = { agentId: "main", env, sessionKey: "agent:main:main" }; + await upsertSessionEntry(scope, { sessionId: "session-a", updatedAt: 1 }); + const database = openOpenClawAgentDatabase({ agentId: "main", env }); + database.db.exec("DROP TABLE session_suggestions;"); + + expect(listSessionSuggestions(scope)).toEqual([]); + addSessionSuggestion(scope, { + id: "b", + authorId: "bob", + text: "second", + createdAt: 3, + expectedSessionId: "session-a", + }); + addSessionSuggestion(scope, { + id: "a", + authorId: "alice", + authorLabel: "Alice", + text: " first\n", + createdAt: 2, + expectedSessionId: "session-a", + }); + + expect(listSessionSuggestions(scope).map((item) => item.id)).toEqual(["a", "b"]); + expect(listSessionSuggestions(scope, { authorId: "alice" })).toEqual([ + expect.objectContaining({ text: " first\n" }), + ]); + expect( + resolvePendingSuggestion({ + scope, + id: "a", + state: "accepted", + expectedSessionId: "session-a", + })?.state, + ).toBe("accepted"); + expect( + resolvePendingSuggestion({ + scope, + id: "a", + state: "dismissed", + expectedSessionId: "session-a", + }), + ).toBeNull(); + expect(listSessionSuggestions(scope, { pendingOnly: true }).map((item) => item.id)).toEqual([ + "b", + ]); + }); + }); + + it("binds writes to the session instance and clears rows on replacement", async () => { + await withTempDir({ prefix: "openclaw-session-suggestions-reset-" }, async (dir) => { + const env = { ...process.env, OPENCLAW_STATE_DIR: dir }; + const scope = { agentId: "main", env, sessionKey: "agent:main:main" }; + await upsertSessionEntry(scope, { sessionId: "session-a", updatedAt: 1 }); + addSessionSuggestion(scope, { + id: "suggestion", + authorId: "alice", + text: "do this", + expectedSessionId: "session-a", + }); + expect(() => + addSessionSuggestion(scope, { + authorId: "alice", + text: "stale", + expectedSessionId: "session-b", + }), + ).toThrow(/session changed/); + + await upsertSessionEntry(scope, { sessionId: "session-b", updatedAt: 2 }); + expect(listSessionSuggestions(scope)).toEqual([]); + }); + }); + + it("bounds pending suggestions per author", async () => { + await withTempDir({ prefix: "openclaw-session-suggestions-limit-" }, async (dir) => { + const env = { ...process.env, OPENCLAW_STATE_DIR: dir }; + const scope = { agentId: "main", env, sessionKey: "agent:main:main" }; + await upsertSessionEntry(scope, { sessionId: "session-a", updatedAt: 1 }); + for (let index = 0; index < MAX_PENDING_SESSION_SUGGESTIONS_PER_AUTHOR; index += 1) { + addSessionSuggestion(scope, { + id: `suggestion-${index}`, + authorId: "alice", + text: `idea ${index}`, + expectedSessionId: "session-a", + }); + } + expect(() => + addSessionSuggestion(scope, { + authorId: "alice", + text: "one too many", + expectedSessionId: "session-a", + }), + ).toThrow(/author pending suggestion limit/); + + resolvePendingSuggestion({ + scope, + id: "suggestion-0", + state: "dismissed", + expectedSessionId: "session-a", + }); + expect(() => + addSessionSuggestion(scope, { + authorId: "alice", + text: "replacement", + expectedSessionId: "session-a", + }), + ).not.toThrow(); + }); + }); + + it("prunes old resolved suggestions on subsequent writes", async () => { + await withTempDir({ prefix: "openclaw-session-suggestions-retention-" }, async (dir) => { + const env = { ...process.env, OPENCLAW_STATE_DIR: dir }; + const scope = { agentId: "main", env, sessionKey: "agent:main:main" }; + await upsertSessionEntry(scope, { sessionId: "session-a", updatedAt: 1 }); + for (let index = 0; index <= MAX_RETAINED_RESOLVED_SESSION_SUGGESTIONS; index += 1) { + const id = `resolved-${index}`; + addSessionSuggestion(scope, { + id, + authorId: "alice", + text: `resolved ${index}`, + createdAt: index + 1, + expectedSessionId: "session-a", + }); + resolvePendingSuggestion({ + scope, + id, + state: "dismissed", + expectedSessionId: "session-a", + }); + } + const rows = listSessionSuggestions(scope); + expect(rows.filter((row) => row.state !== "pending")).toHaveLength( + MAX_RETAINED_RESOLVED_SESSION_SUGGESTIONS, + ); + expect(rows.some((row) => row.id === "resolved-0")).toBe(false); + }); + }); + + it("durably claims dispatch and permits only same-action stale recovery", async () => { + await withTempDir({ prefix: "openclaw-session-suggestions-claim-" }, async (dir) => { + const env = { ...process.env, OPENCLAW_STATE_DIR: dir }; + const scope = { agentId: "main", env, sessionKey: "agent:main:main" }; + await upsertSessionEntry(scope, { sessionId: "session-a", updatedAt: 1 }); + addSessionSuggestion(scope, { + id: "claimed", + authorId: "alice", + text: "dispatch me", + expectedSessionId: "session-a", + }); + + const first = claimSessionSuggestionDispatch(scope, { + id: "claimed", + resolution: "send", + expectedSessionId: "session-a", + now: 1_000, + }); + expect(first?.kind).toBe("claimed"); + expect( + claimSessionSuggestionDispatch(scope, { + id: "claimed", + resolution: "send", + expectedSessionId: "session-a", + now: 1_001, + }), + ).toEqual({ kind: "busy" }); + expect( + resolvePendingSuggestion({ + scope, + id: "claimed", + state: "dismissed", + expectedSessionId: "session-a", + }), + ).toBeNull(); + + expect( + claimSessionSuggestionDispatch(scope, { + id: "claimed", + resolution: "queue", + expectedSessionId: "session-a", + now: 1_000 + SESSION_SUGGESTION_DISPATCH_CLAIM_TTL_MS, + }), + ).toEqual({ kind: "mismatch", resolution: "send" }); + const recovered = claimSessionSuggestionDispatch(scope, { + id: "claimed", + resolution: "send", + expectedSessionId: "session-a", + now: 1_000 + SESSION_SUGGESTION_DISPATCH_CLAIM_TTL_MS, + }); + expect(recovered?.kind).toBe("claimed"); + if (recovered?.kind !== "claimed") { + throw new Error("expected recovered claim"); + } + expect( + first?.kind === "claimed" + ? finalizeSessionSuggestionClaim(scope, { + id: "claimed", + token: first.token, + state: "accepted", + expectedSessionId: "session-a", + }) + : null, + ).toBeNull(); + expect( + finalizeSessionSuggestionClaim(scope, { + id: "claimed", + token: recovered.token, + state: "accepted", + expectedSessionId: "session-a", + })?.state, + ).toBe("accepted"); + }); + }); +}); diff --git a/src/config/sessions/session-suggestion-store.ts b/src/config/sessions/session-suggestion-store.ts new file mode 100644 index 000000000000..33bb11dc14e1 --- /dev/null +++ b/src/config/sessions/session-suggestion-store.ts @@ -0,0 +1,378 @@ +import { randomUUID } from "node:crypto"; +import type { DatabaseSync } from "node:sqlite"; +import { + executeSqliteQuerySync, + executeSqliteQueryTakeFirstSync, + getNodeSqliteKysely, +} from "../../infra/kysely-sync.js"; +import type { DB as OpenClawAgentKyselyDatabase } from "../../state/openclaw-agent-db.generated.js"; +import { + openOpenClawAgentDatabase, + runOpenClawAgentWriteTransaction, + type OpenClawAgentDatabase, + type OpenClawAgentDatabaseOptions, +} from "../../state/openclaw-agent-db.js"; +import { ensureOpenClawAgentSessionSharingSchemaInTransaction } from "../../state/openclaw-agent-session-sharing-schema.js"; +import { SessionWorkStartInvalidatedError } from "./lifecycle.js"; +import type { SessionAccessScope } from "./session-accessor.sqlite-contract.js"; +import { resolveSqliteScope, toDatabaseOptions } from "./session-accessor.sqlite-scope.js"; + +type SuggestionDatabase = Pick; + +type StoredSessionSuggestionState = "pending" | "accepted" | "dismissed"; +type StoredSessionSuggestionResolution = "send" | "queue" | "edit" | "dismiss"; + +export type StoredSessionSuggestion = { + id: string; + authorId: string; + authorLabel?: string; + text: string; + createdAt: number; + state: StoredSessionSuggestionState; +}; + +const ensuredDatabases = new WeakSet(); +const MAX_PENDING_SESSION_SUGGESTIONS_PER_AUTHOR = 20; +const MAX_PENDING_SESSION_SUGGESTIONS_PER_SESSION = 100; +const MAX_RETAINED_RESOLVED_SESSION_SUGGESTIONS = 200; +export const SESSION_SUGGESTION_DISPATCH_CLAIM_TTL_MS = 30_000; + +function resolveDatabaseOptions(scope: SessionAccessScope): OpenClawAgentDatabaseOptions { + return toDatabaseOptions(resolveSqliteScope(scope)); +} + +function ensureSuggestionSchema(options: OpenClawAgentDatabaseOptions): OpenClawAgentDatabase { + const database = openOpenClawAgentDatabase(options); + if (ensuredDatabases.has(database.db)) { + return database; + } + runOpenClawAgentWriteTransaction((transactionDatabase) => { + ensureOpenClawAgentSessionSharingSchemaInTransaction(transactionDatabase.db); + }, options); + ensuredDatabases.add(database.db); + return database; +} + +function suggestionDb(database: OpenClawAgentDatabase) { + return getNodeSqliteKysely(database.db); +} + +function toSuggestion(row: { + id: string; + author_id: string; + author_label: string | null; + text: string; + created_at: number; + state: string; +}): StoredSessionSuggestion { + return { + id: row.id, + authorId: row.author_id, + ...(row.author_label ? { authorLabel: row.author_label } : {}), + text: row.text, + createdAt: row.created_at, + state: row.state as StoredSessionSuggestionState, + }; +} + +function assertSessionInstance( + database: OpenClawAgentDatabase, + sessionKey: string, + expectedSessionId: string | undefined, +): void { + if (expectedSessionId === undefined) { + return; + } + const row = + database.db /* sqlite-allow-raw: sync session-instance check inside the suggestion write transaction */ + .prepare("SELECT current_session_id, entry_json FROM session_nodes WHERE session_key = ?") + .get(sessionKey) as { current_session_id?: string; entry_json?: string } | undefined; + let entrySessionId: string | undefined; + try { + const entry = row?.entry_json ? (JSON.parse(row.entry_json) as unknown) : undefined; + const candidate = + entry && typeof entry === "object" && !Array.isArray(entry) + ? (entry as { sessionId?: unknown }).sessionId + : undefined; + entrySessionId = typeof candidate === "string" ? candidate : undefined; + } catch { + entrySessionId = undefined; + } + if ( + !row || + entrySessionId === undefined || + row.current_session_id !== entrySessionId || + entrySessionId !== expectedSessionId + ) { + throw new SessionWorkStartInvalidatedError("session changed before suggestion mutation"); + } +} + +function pruneResolvedSessionSuggestions( + database: OpenClawAgentDatabase, + sessionKey: string, +): void { + const db = suggestionDb(database); + const resolvedRows = executeSqliteQuerySync( + database.db, + db + .selectFrom("session_suggestions") + .select("id") + .where("session_key", "=", sessionKey) + .where("state", "!=", "pending") + .orderBy("created_at", "desc") + .orderBy("id", "desc"), + ).rows.slice(MAX_RETAINED_RESOLVED_SESSION_SUGGESTIONS); + if (resolvedRows.length === 0) { + return; + } + executeSqliteQuerySync( + database.db, + db.deleteFrom("session_suggestions").where( + "id", + "in", + resolvedRows.map((row) => row.id), + ), + ); +} + +export function addSessionSuggestion( + scope: SessionAccessScope, + params: { + authorId: string; + authorLabel?: string; + text: string; + createdAt?: number; + id?: string; + expectedSessionId?: string; + }, +): StoredSessionSuggestion { + const authorId = params.authorId.trim(); + const authorLabel = params.authorLabel?.trim() || undefined; + const text = params.text; + if (!authorId || !text.trim()) { + throw new Error("suggestion author and text are required"); + } + const options = resolveDatabaseOptions(scope); + ensureSuggestionSchema(options); + const sessionKey = resolveSqliteScope(scope).sessionKey; + const suggestion: StoredSessionSuggestion = { + id: params.id ?? randomUUID(), + authorId, + ...(authorLabel ? { authorLabel } : {}), + text, + createdAt: params.createdAt ?? Date.now(), + state: "pending", + }; + runOpenClawAgentWriteTransaction((database) => { + assertSessionInstance(database, sessionKey, params.expectedSessionId); + const db = suggestionDb(database); + pruneResolvedSessionSuggestions(database, sessionKey); + const pendingRows = executeSqliteQuerySync( + database.db, + db + .selectFrom("session_suggestions") + .select("author_id") + .where("session_key", "=", sessionKey) + .where("state", "=", "pending"), + ).rows; + if (pendingRows.length >= MAX_PENDING_SESSION_SUGGESTIONS_PER_SESSION) { + throw new Error("session pending suggestion limit reached"); + } + if ( + pendingRows.filter((row) => row.author_id === suggestion.authorId).length >= + MAX_PENDING_SESSION_SUGGESTIONS_PER_AUTHOR + ) { + throw new Error("author pending suggestion limit reached"); + } + executeSqliteQuerySync( + database.db, + db.insertInto("session_suggestions").values({ + id: suggestion.id, + session_key: sessionKey, + author_id: suggestion.authorId, + author_label: suggestion.authorLabel ?? null, + text: suggestion.text, + created_at: suggestion.createdAt, + state: suggestion.state, + dispatch_token: null, + dispatch_started_at: null, + dispatch_resolution: null, + }), + ); + }, options); + return suggestion; +} + +export function listSessionSuggestions( + scope: SessionAccessScope, + params: { authorId?: string; pendingOnly?: boolean } = {}, +): StoredSessionSuggestion[] { + const options = resolveDatabaseOptions(scope); + const database = ensureSuggestionSchema(options); + const sessionKey = resolveSqliteScope(scope).sessionKey; + let query = suggestionDb(database) + .selectFrom("session_suggestions") + .select(["id", "author_id", "author_label", "text", "created_at", "state"]) + .where("session_key", "=", sessionKey); + if (params.authorId?.trim()) { + query = query.where("author_id", "=", params.authorId.trim()); + } + if (params.pendingOnly) { + query = query.where("state", "=", "pending"); + } + return executeSqliteQuerySync( + database.db, + query.orderBy("created_at", "asc").orderBy("id", "asc"), + ).rows.map(toSuggestion); +} + +type SessionSuggestionDispatchClaim = + | { kind: "busy" } + | { kind: "mismatch"; resolution: StoredSessionSuggestionResolution } + | { kind: "claimed"; suggestion: StoredSessionSuggestion; token: string }; + +export function claimSessionSuggestionDispatch( + scope: SessionAccessScope, + params: { + id: string; + expectedSessionId?: string; + resolution: StoredSessionSuggestionResolution; + now?: number; + claimTtlMs?: number; + }, +): SessionSuggestionDispatchClaim | null { + const options = resolveDatabaseOptions(scope); + ensureSuggestionSchema(options); + const sessionKey = resolveSqliteScope(scope).sessionKey; + return runOpenClawAgentWriteTransaction((database) => { + assertSessionInstance(database, sessionKey, params.expectedSessionId); + const db = suggestionDb(database); + const row = executeSqliteQueryTakeFirstSync( + database.db, + db + .selectFrom("session_suggestions") + .select([ + "id", + "author_id", + "author_label", + "text", + "created_at", + "state", + "dispatch_token", + "dispatch_started_at", + "dispatch_resolution", + ]) + .where("session_key", "=", sessionKey) + .where("id", "=", params.id) + .where("state", "=", "pending"), + ); + if (!row) { + return null; + } + const now = params.now ?? Date.now(); + const claimTtlMs = params.claimTtlMs ?? SESSION_SUGGESTION_DISPATCH_CLAIM_TTL_MS; + if ( + row.dispatch_token && + row.dispatch_started_at !== null && + now - row.dispatch_started_at < claimTtlMs + ) { + return { kind: "busy" }; + } + if (row.dispatch_resolution && row.dispatch_resolution !== params.resolution) { + return { + kind: "mismatch", + resolution: row.dispatch_resolution as StoredSessionSuggestionResolution, + }; + } + const token = randomUUID(); + executeSqliteQuerySync( + database.db, + db + .updateTable("session_suggestions") + .set({ + dispatch_token: token, + dispatch_started_at: now, + dispatch_resolution: params.resolution, + }) + .where("session_key", "=", sessionKey) + .where("id", "=", params.id) + .where("state", "=", "pending"), + ); + return { kind: "claimed", suggestion: toSuggestion(row), token }; + }, options); +} + +export function releaseSessionSuggestionDispatch( + scope: SessionAccessScope, + params: { id: string; token: string; expectedSessionId?: string }, +): boolean { + const options = resolveDatabaseOptions(scope); + ensureSuggestionSchema(options); + const sessionKey = resolveSqliteScope(scope).sessionKey; + return runOpenClawAgentWriteTransaction((database) => { + assertSessionInstance(database, sessionKey, params.expectedSessionId); + const result = executeSqliteQuerySync( + database.db, + suggestionDb(database) + .updateTable("session_suggestions") + .set({ dispatch_token: null, dispatch_started_at: null, dispatch_resolution: null }) + .where("session_key", "=", sessionKey) + .where("id", "=", params.id) + .where("state", "=", "pending") + .where("dispatch_token", "=", params.token), + ); + return (result.numAffectedRows ?? 0n) > 0n; + }, options); +} + +export function finalizeSessionSuggestionClaim( + scope: SessionAccessScope, + params: { + id: string; + token: string; + state: Exclude; + expectedSessionId?: string; + }, +): StoredSessionSuggestion | null { + const options = resolveDatabaseOptions(scope); + ensureSuggestionSchema(options); + const sessionKey = resolveSqliteScope(scope).sessionKey; + return runOpenClawAgentWriteTransaction((database) => { + assertSessionInstance(database, sessionKey, params.expectedSessionId); + const db = suggestionDb(database); + const row = executeSqliteQueryTakeFirstSync( + database.db, + db + .selectFrom("session_suggestions") + .select(["id", "author_id", "author_label", "text", "created_at", "state"]) + .where("session_key", "=", sessionKey) + .where("id", "=", params.id) + .where("state", "=", "pending") + .where("dispatch_token", "=", params.token), + ); + if (!row) { + return null; + } + const updated = executeSqliteQuerySync( + database.db, + db + .updateTable("session_suggestions") + .set({ + state: params.state, + dispatch_token: null, + dispatch_started_at: null, + dispatch_resolution: null, + }) + .where("session_key", "=", sessionKey) + .where("id", "=", params.id) + .where("state", "=", "pending") + .where("dispatch_token", "=", params.token), + ); + if ((updated.numAffectedRows ?? 0n) === 0n) { + return null; + } + pruneResolvedSessionSuggestions(database, sessionKey); + return { ...toSuggestion(row), state: params.state }; + }, options); +} diff --git a/src/gateway/methods/core-descriptors.since.test.ts b/src/gateway/methods/core-descriptors.since.test.ts index 37905135f51f..7d7e59820d05 100644 --- a/src/gateway/methods/core-descriptors.since.test.ts +++ b/src/gateway/methods/core-descriptors.since.test.ts @@ -12,6 +12,10 @@ const CURRENT_TRAIN_METHODS = [ "session.members.add", "session.members.list", "session.members.remove", + "session.suggestions.add", + "session.suggestions.list", + "session.suggestions.resolve", + "session.typing", "session.visibility.set", "board.prompt.authorize", "board.data.read", diff --git a/src/gateway/methods/core-descriptors.ts b/src/gateway/methods/core-descriptors.ts index 59ca664d5ea5..1aea81e069d2 100644 --- a/src/gateway/methods/core-descriptors.ts +++ b/src/gateway/methods/core-descriptors.ts @@ -462,6 +462,10 @@ const CORE_GATEWAY_METHOD_SPECS: readonly CoreGatewayMethodSpec[] = [ { name: "session.members.list", scope: "operator.read", since: "2026.7" }, { name: "session.members.add", scope: "operator.write", since: "2026.7" }, { name: "session.members.remove", scope: "operator.write", since: "2026.7" }, + { name: "session.suggestions.add", scope: "operator.write", since: "2026.7" }, + { name: "session.suggestions.list", scope: "operator.read", since: "2026.7" }, + { name: "session.suggestions.resolve", scope: "operator.write", since: "2026.7" }, + { name: "session.typing", scope: "operator.write", since: "2026.7" }, ] as const; const CORE_GATEWAY_METHOD_SPEC_BY_NAME: ReadonlyMap = new Map( diff --git a/src/gateway/server-broadcast.board.test.ts b/src/gateway/server-broadcast.board.test.ts index 0dc50fbcbe02..76810af08166 100644 --- a/src/gateway/server-broadcast.board.test.ts +++ b/src/gateway/server-broadcast.board.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { createGatewayBroadcaster } from "./server-broadcast.js"; +import { createSessionMessageSubscriberRegistry } from "./server-chat-state.js"; import type { GatewayWsClient } from "./server/ws-types.js"; type RecordingSocket = { @@ -82,3 +83,50 @@ describe("board event scope guards", () => { expect(canReceiveSessionEvent).toHaveBeenCalledTimes(2); }); }); + +describe("collaboration event scope guards", () => { + it("guards suggestion and typing events and forwards payloads to visibility filtering", () => { + const pairing = makeClient("pairing", "operator", ["operator.pairing"]); + const reader = makeClient("reader", "operator", ["operator.read"]); + const unrelated = makeClient("unrelated", "operator", ["operator.read"]); + const sessionMessageSubscribers = createSessionMessageSubscriberRegistry(); + sessionMessageSubscribers.subscribe("reader", "agent:main:main"); + const canReceiveSessionEvent = vi.fn( + ( + _client: GatewayWsClient, + sessionKeys: readonly string[], + agentId: string | undefined, + event: string | undefined, + payload: unknown, + ) => { + expect(sessionKeys).toEqual(["agent:main:main"]); + expect(agentId).toBe("main"); + expect(payload).toBeDefined(); + return event === "session.typing"; + }, + ); + const { broadcast } = createGatewayBroadcaster({ + clients: new Set([pairing.client, reader.client, unrelated.client]), + canReceiveSessionEvent, + sessionMessageSubscribers, + }); + + broadcast("session.suggestion", { + suggestion: { sessionKey: "agent:main:main", agentId: "main" }, + }); + broadcast( + "session.typing", + { + sessionKey: "agent:main:main", + agentId: "main", + typing: true, + }, + { sessionKeys: ["agent:main:main"], agentId: "main" }, + ); + + expect(pairing.socket.events).toEqual([]); + expect(reader.socket.events).toEqual(["session.typing"]); + expect(unrelated.socket.events).toEqual([]); + expect(canReceiveSessionEvent).toHaveBeenCalledTimes(4); + }); +}); diff --git a/src/gateway/server-broadcast.ts b/src/gateway/server-broadcast.ts index e6b7a128bf31..9fca7c166b4c 100644 --- a/src/gateway/server-broadcast.ts +++ b/src/gateway/server-broadcast.ts @@ -74,6 +74,8 @@ const EVENT_SCOPE_GUARDS: Record = { "session.observer": [READ_SCOPE], "session.operation": [READ_SCOPE], "session.sharing": [READ_SCOPE], + "session.suggestion": [READ_SCOPE], + "session.typing": [READ_SCOPE], "session.tool": [READ_SCOPE], // Operator terminal byte/exit streams. Admin-gated to match the terminal.* // methods; also targeted to the owning connection at broadcast time. @@ -185,6 +187,8 @@ export function createGatewayBroadcaster(params: { client: GatewayWsClient, sessionKeys: readonly string[], agentId?: string, + event?: string, + payload?: unknown, ) => boolean; }) { const clientSeq = new WeakMap(); @@ -254,14 +258,17 @@ export function createGatewayBroadcaster(params: { if ( sessionKeys.length > 0 && params.canReceiveSessionEvent && - !params.canReceiveSessionEvent(c, sessionKeys, agentId) + !params.canReceiveSessionEvent(c, sessionKeys, agentId, event, payload) ) { continue; } - if ( - (isBrowserCopilotClient(c.connect.client) || + const requiresSessionSubscription = + event === "session.typing" || + ((isBrowserCopilotClient(c.connect.client) || hasGatewayClientCap(c.connect.caps, GATEWAY_CLIENT_CAPS.SESSION_SCOPED_EVENTS)) && - SESSION_SUBSCRIPTION_EVENTS.has(event) && + SESSION_SUBSCRIPTION_EVENTS.has(event)); + if ( + requiresSessionSubscription && (!opts?.sessionKeys?.length || !opts.sessionKeys.some((sessionKey) => params.sessionMessageSubscribers?.get(sessionKey).has(c.connId), diff --git a/src/gateway/server-methods-list.test.ts b/src/gateway/server-methods-list.test.ts index 05d08ed3816a..92299d0f06cd 100644 --- a/src/gateway/server-methods-list.test.ts +++ b/src/gateway/server-methods-list.test.ts @@ -62,7 +62,7 @@ describe("listGatewayMethods", () => { }); it("appends new methods after model probing without shifting older method indices", () => { - expect(listGatewayMethods().slice(-18)).toEqual([ + expect(listGatewayMethods().slice(-22)).toEqual([ "models.probe", "migrations.memory.plan", "migrations.memory.apply", @@ -81,6 +81,10 @@ describe("listGatewayMethods", () => { "session.members.list", "session.members.add", "session.members.remove", + "session.suggestions.add", + "session.suggestions.list", + "session.suggestions.resolve", + "session.typing", ]); const methods = listGatewayMethods(); expect(methods.indexOf("node.pluginSurface.refresh")).toBe( @@ -141,7 +145,7 @@ describe("listGatewayMethods", () => { "exec.approval.get", ]); expect(methods).toContain("tts.speak"); - expect(coreMethods.slice(-25)).toEqual([ + expect(coreMethods.slice(-29)).toEqual([ "sessions.catalog.continue", "sessions.catalog.archive", "approval.get", @@ -167,6 +171,10 @@ describe("listGatewayMethods", () => { "session.members.list", "session.members.add", "session.members.remove", + "session.suggestions.add", + "session.suggestions.list", + "session.suggestions.resolve", + "session.typing", ]); 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-list.ts b/src/gateway/server-methods-list.ts index 441c6b780452..86deb702f857 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -46,6 +46,8 @@ export const GATEWAY_EVENTS = [ "session.observer", "session.operation", "session.sharing", + "session.suggestion", + "session.typing", "session.tool", "sessions.changed", "presence", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index b0c30bc1b803..871fe2d8d9de 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -756,6 +756,10 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { "session.members.list", "session.members.add", "session.members.remove", + "session.suggestions.add", + "session.suggestions.list", + "session.suggestions.resolve", + "session.typing", ], loadHandlers: loadSessionsHandlers, }), diff --git a/src/gateway/server-methods/gateway-client-identity.test.ts b/src/gateway/server-methods/gateway-client-identity.test.ts new file mode 100644 index 000000000000..cd4bb958f9a3 --- /dev/null +++ b/src/gateway/server-methods/gateway-client-identity.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { + gatewayClientSenderFields, + gatewayClientSessionCreator, +} from "./gateway-client-identity.js"; +import type { GatewayClient } from "./types.js"; + +describe("gateway client identity", () => { + it("overrides sender attribution without replacing the authorizing identity", () => { + const client = { + authenticatedUserProfile: { + profileId: "owner", + displayName: "Owner", + hasAvatar: false, + updatedAt: 1, + }, + internal: { + syntheticClient: true, + senderAttribution: { id: "alice", name: "Suggested by Alice" }, + }, + } as GatewayClient; + + expect(gatewayClientSessionCreator(client)).toEqual({ + type: "human", + id: "owner", + label: "Owner", + }); + expect(gatewayClientSenderFields(client)).toEqual({ + sender: { id: "alice", name: "Suggested by Alice" }, + }); + }); +}); diff --git a/src/gateway/server-methods/gateway-client-identity.ts b/src/gateway/server-methods/gateway-client-identity.ts index bc33a4a7189e..aaf9a4749e01 100644 --- a/src/gateway/server-methods/gateway-client-identity.ts +++ b/src/gateway/server-methods/gateway-client-identity.ts @@ -6,6 +6,9 @@ type GatewayClientSender = { id: string; name?: string }; export function gatewayClientSenderFields(client: GatewayClient | null): { sender?: GatewayClientSender; } { + if (client?.internal?.senderAttribution) { + return { sender: client.internal.senderAttribution }; + } const profile = client?.authenticatedUserProfile; if (profile) { return { diff --git a/src/gateway/server-methods/session-typing-state.ts b/src/gateway/server-methods/session-typing-state.ts new file mode 100644 index 000000000000..bf900c9a7a18 --- /dev/null +++ b/src/gateway/server-methods/session-typing-state.ts @@ -0,0 +1,136 @@ +import { listSystemPresence } from "../../infra/system-presence.js"; + +const TYPING_THROTTLE_MS = 1_000; +const TYPING_ACTIVE_TTL_MS = 2_500; +const MAX_TYPING_THROTTLE_KEYS = 2_048; +type PendingTypingBroadcast = { typing: boolean; emit: () => boolean }; +type TypingBroadcastState = { + at: number; + typing: boolean; + pending?: PendingTypingBroadcast; + timer?: ReturnType; +}; + +const typingBroadcastState = new Map(); +const typingConnections = new Map>(); + +export function liveViewerIdentities(sessionKeys: ReadonlySet): Set { + return new Set( + listSystemPresence() + .filter( + (entry) => + entry.user?.id && + entry.watchedSessions?.some((sessionKey) => sessionKeys.has(sessionKey)), + ) + .map((entry) => entry.user?.id) + .filter((id): id is string => Boolean(id)), + ); +} + +function rememberTypingBroadcast(key: string, state: TypingBroadcastState): void { + typingBroadcastState.delete(key); + typingBroadcastState.set(key, state); + if (typingBroadcastState.size <= MAX_TYPING_THROTTLE_KEYS) { + return; + } + const oldestKey = typingBroadcastState.keys().next().value; + if (!oldestKey) { + return; + } + const oldest = typingBroadcastState.get(oldestKey); + if (oldest?.timer) { + clearTimeout(oldest.timer); + } + typingBroadcastState.delete(oldestKey); +} + +export function broadcastTypingThrottled(params: { + key: string; + typing: boolean; + now: number; + emit: () => boolean; +}): boolean { + const previous = typingBroadcastState.get(params.key); + if (!previous || params.now - previous.at >= TYPING_THROTTLE_MS) { + if (previous?.timer) { + clearTimeout(previous.timer); + } + const emitted = params.emit(); + if (emitted) { + rememberTypingBroadcast(params.key, { at: params.now, typing: params.typing }); + } else { + typingBroadcastState.delete(params.key); + } + return emitted; + } + + if (params.typing === previous.typing && previous.pending?.typing !== params.typing) { + if (previous.timer) { + clearTimeout(previous.timer); + } + delete previous.pending; + delete previous.timer; + if (!params.typing) { + rememberTypingBroadcast(params.key, previous); + return false; + } + } + + previous.pending = { typing: params.typing, emit: params.emit }; + if (!previous.timer) { + const timer = setTimeout( + () => { + const current = typingBroadcastState.get(params.key); + if (!current || current.timer !== timer || !current.pending) { + return; + } + const pending = current.pending; + const next = { at: Date.now(), typing: pending.typing } satisfies TypingBroadcastState; + if (pending.emit()) { + rememberTypingBroadcast(params.key, next); + } else { + typingBroadcastState.delete(params.key); + } + }, + TYPING_THROTTLE_MS - (params.now - previous.at), + ); + timer.unref?.(); + previous.timer = timer; + } + rememberTypingBroadcast(params.key, previous); + return false; +} + +export function updateTypingConnections(params: { + key: string; + connectionId: string; + typing: boolean; + now: number; +}): boolean { + for (const [typingKey, activeConnections] of typingConnections) { + for (const [connectionId, updatedAt] of activeConnections) { + if (params.now - updatedAt >= TYPING_ACTIVE_TTL_MS) { + activeConnections.delete(connectionId); + } + } + if (activeConnections.size === 0) { + typingConnections.delete(typingKey); + } + } + const connections = typingConnections.get(params.key) ?? new Map(); + if (params.typing) { + connections.set(params.connectionId, params.now); + } else { + connections.delete(params.connectionId); + } + if (connections.size === 0) { + typingConnections.delete(params.key); + return false; + } + typingConnections.delete(params.key); + typingConnections.set(params.key, connections); + if (typingConnections.size > MAX_TYPING_THROTTLE_KEYS) { + typingConnections.delete(typingConnections.keys().next().value ?? ""); + } + return true; +} diff --git a/src/gateway/server-methods/sessions-suggestions.test.ts b/src/gateway/server-methods/sessions-suggestions.test.ts new file mode 100644 index 000000000000..5c7576021eb7 --- /dev/null +++ b/src/gateway/server-methods/sessions-suggestions.test.ts @@ -0,0 +1,1010 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { upsertSessionEntry } from "../../config/sessions/session-accessor.js"; +import { addSessionMember } from "../../config/sessions/session-sharing-store.js"; +import { + addSessionSuggestion, + listSessionSuggestions, + SESSION_SUGGESTION_DISPATCH_CLAIM_TTL_MS, +} from "../../config/sessions/session-suggestion-store.js"; +import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; +import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; +import { sessionSuggestionHandlers } from "./sessions-suggestions.js"; +import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js"; + +const mocks = vi.hoisted(() => ({ + appendSessionAudit: vi.fn(async () => undefined), + handleChatSend: vi.fn(), + suggestionMutationFailure: undefined as + | "claim" + | "release" + | "release-unexpected" + | "finalize" + | undefined, + presence: [] as Array<{ + user?: { id: string; name?: string }; + watchedSessions?: string[]; + }>, +})); + +vi.mock("./chat-send-handler.js", () => ({ handleChatSend: mocks.handleChatSend })); +vi.mock("./session-audit.js", () => ({ appendSessionAudit: mocks.appendSessionAudit })); +vi.mock("../../infra/system-presence.js", () => ({ + listSystemPresence: () => mocks.presence, +})); +vi.mock("../../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + const failIfRequested = (phase: "claim" | "release" | "finalize") => { + if (mocks.suggestionMutationFailure === phase) { + throw new actual.SessionWorkStartInvalidatedError("session changed in test"); + } + }; + return { + ...actual, + claimSessionSuggestionDispatch: ( + ...args: Parameters + ) => { + failIfRequested("claim"); + return actual.claimSessionSuggestionDispatch(...args); + }, + finalizeSessionSuggestionClaim: ( + ...args: Parameters + ) => { + failIfRequested("finalize"); + return actual.finalizeSessionSuggestionClaim(...args); + }, + releaseSessionSuggestionDispatch: ( + ...args: Parameters + ) => { + failIfRequested("release"); + if (mocks.suggestionMutationFailure === "release-unexpected") { + throw new Error("release storage failed"); + } + return actual.releaseSessionSuggestionDispatch(...args); + }, + }; +}); + +const sessionKey = "agent:main:main"; + +function createDeferred() { + let resolve!: (value: T) => void; + const promise = new Promise((nextResolve) => { + resolve = nextResolve; + }); + return { promise, resolve }; +} + +function client(profileId: string, displayName: string, admin = false): GatewayClient { + return { + connId: `conn-${profileId}`, + connect: { + minProtocol: 1, + maxProtocol: 1, + client: { + id: "openclaw-control-ui", + version: "test", + platform: "test", + mode: "webchat", + instanceId: `instance-${profileId}`, + }, + role: "operator", + scopes: admin ? ["operator.admin"] : ["operator.read", "operator.write"], + }, + authenticatedUserId: `${profileId}@example.com`, + authenticatedUserProfile: { + profileId, + displayName, + hasAvatar: false, + updatedAt: 1, + }, + }; +} + +function context(broadcast = vi.fn()): GatewayRequestContext { + return { + getRuntimeConfig: () => ({}), + broadcast, + broadcastToConnIds: vi.fn(), + chatAbortControllers: new Map(), + logGateway: { warn: vi.fn() }, + } as unknown as GatewayRequestContext; +} + +async function call( + method: + | "session.suggestions.add" + | "session.suggestions.list" + | "session.suggestions.resolve" + | "session.typing", + params: Record, + requestClient: GatewayClient | null, + requestContext = context(), +) { + const responses: Parameters[] = []; + await sessionSuggestionHandlers[method]?.({ + req: { type: "req", id: "request-1", method, params }, + params, + client: requestClient, + context: requestContext, + isWebchatConnect: () => true, + respond: (...response: Parameters) => responses.push(response), + }); + return { responses, context: requestContext }; +} + +function responseSuggestionId(result: Awaited>): string { + const payload = result.responses[0]?.[1] as { suggestion?: { id?: string } } | undefined; + if (!payload?.suggestion?.id) { + throw new Error("suggestion response id missing"); + } + return payload.suggestion.id; +} + +beforeEach(() => { + mocks.appendSessionAudit.mockClear(); + mocks.handleChatSend.mockReset(); + mocks.handleChatSend.mockImplementation(async ({ respond }: { respond: RespondFn }) => { + respond(true, { runId: "suggestion-run", status: "started" }); + }); + mocks.suggestionMutationFailure = undefined; + mocks.presence = []; +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + closeOpenClawAgentDatabasesForTest(); +}); + +describe("session suggestion handlers", () => { + it("lets a suggest viewer add and list only their own suggestion", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + await upsertSessionEntry( + { agentId: "main", sessionKey }, + { + sessionId: "session-main", + updatedAt: 1, + createdActor: { type: "human", id: "owner" }, + visibility: "suggest", + }, + ); + const alice = client("alice", "Alice"); + const add = await call( + "session.suggestions.add", + { sessionKey: "main", text: " Try the focused fix\n" }, + alice, + ); + expect(add.responses[0]?.[0]).toBe(true); + expect(add.responses[0]?.[1]).toMatchObject({ + suggestion: { + author: { id: "alice", label: "Alice" }, + text: " Try the focused fix\n", + state: "pending", + }, + }); + expect(add.context.broadcast).toHaveBeenCalledWith( + "session.suggestion", + expect.objectContaining({ action: "added" }), + expect.objectContaining({ sessionKeys: [sessionKey, "main"] }), + ); + expect(mocks.appendSessionAudit).not.toHaveBeenCalled(); + + await call( + "session.suggestions.add", + { sessionKey, text: "Bob's idea" }, + client("bob", "Bob"), + ); + const listed = await call("session.suggestions.list", { sessionKey }, alice); + expect(listed.responses[0]?.[1]).toMatchObject({ + role: "viewer", + suggestions: [{ author: { id: "alice" }, text: " Try the focused fix\n" }], + }); + }); + }); + + it("hides draft suggestions from members while owner and admin can list", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + const draftKey = "agent:main:draft-suggestions"; + await upsertSessionEntry( + { agentId: "main", sessionKey: draftKey }, + { + sessionId: "session-draft", + updatedAt: 1, + createdActor: { type: "human", id: "owner" }, + visibility: "draft", + }, + ); + addSessionMember( + { agentId: "main", sessionKey: draftKey }, + { identityId: "member", addedBy: "owner", expectedSessionId: "session-draft" }, + ); + addSessionSuggestion( + { agentId: "main", sessionKey: draftKey }, + { + id: "draft-suggestion", + authorId: "member", + text: "private draft suggestion", + expectedSessionId: "session-draft", + }, + ); + + const member = client("member", "Member"); + const expectHiddenDraft = (result: Awaited>) => { + expect(result.responses[0]?.[0]).toBe(false); + expect(result.responses[0]?.[1]).toBeUndefined(); + expect(result.responses[0]?.[2]).toMatchObject({ + message: "session is draft for this connection", + details: { + code: "SESSION_PARTICIPATION_REQUIRED", + sessionKey: draftKey, + visibility: "draft", + }, + }); + }; + + expectHiddenDraft(await call("session.suggestions.list", { sessionKey: draftKey }, member)); + expectHiddenDraft( + await call("session.suggestions.add", { sessionKey: draftKey, text: "leak draft" }, member), + ); + expectHiddenDraft( + await call( + "session.suggestions.resolve", + { sessionKey: draftKey, id: "draft-suggestion", resolution: "dismiss" }, + member, + ), + ); + expect( + ( + await call( + "session.typing", + { sessionKey: draftKey, sessionId: "session-draft", typing: true }, + member, + ) + ).responses[0]?.[1], + ).toEqual({ ok: true, broadcast: false }); + + const ownerList = await call( + "session.suggestions.list", + { sessionKey: draftKey }, + client("owner", "Owner"), + ); + expect(ownerList.responses[0]?.[1]).toMatchObject({ + role: "owner", + suggestions: [{ id: "draft-suggestion", text: "private draft suggestion" }], + }); + const adminList = await call( + "session.suggestions.list", + { sessionKey: draftKey }, + client("admin", "Admin", true), + ); + expect(adminList.responses[0]?.[1]).toMatchObject({ + role: "admin", + suggestions: [{ id: "draft-suggestion", text: "private draft suggestion" }], + }); + }); + }); + + it("keeps incognito suggestion and typing surfaces admin-only", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + const incognitoKey = "agent:main:dashboard:incognito-suggestions"; + await upsertSessionEntry( + { agentId: "main", sessionKey: incognitoKey }, + { + sessionId: "session-incognito", + updatedAt: 1, + incognito: true, + createdActor: { type: "human", id: "owner" }, + visibility: "suggest", + }, + ); + addSessionSuggestion( + { agentId: "main", sessionKey: incognitoKey }, + { + id: "incognito-suggestion", + authorId: "owner", + text: "private suggestion", + expectedSessionId: "session-incognito", + }, + ); + const owner = client("owner", "Owner"); + const expectHidden = (result: Awaited>) => { + expect(result.responses[0]?.[0]).toBe(false); + expect(result.responses[0]?.[1]).toBeUndefined(); + expect(result.responses[0]?.[2]?.message).toBe( + `Incognito session "${incognitoKey}" was not found.`, + ); + }; + + expectHidden(await call("session.suggestions.list", { sessionKey: incognitoKey }, owner)); + expectHidden( + await call("session.suggestions.add", { sessionKey: incognitoKey, text: "probe" }, owner), + ); + expectHidden( + await call( + "session.suggestions.resolve", + { sessionKey: incognitoKey, id: "incognito-suggestion", resolution: "dismiss" }, + owner, + ), + ); + expectHidden( + await call( + "session.typing", + { sessionKey: incognitoKey, sessionId: "wrong-session", typing: true }, + owner, + ), + ); + expectHidden( + await call( + "session.typing", + { sessionKey: incognitoKey, sessionId: "session-incognito", typing: true }, + owner, + ), + ); + + const adminList = await call( + "session.suggestions.list", + { sessionKey: incognitoKey }, + client("admin", "Admin", true), + ); + expect(adminList.responses[0]?.[1]).toMatchObject({ + role: "admin", + suggestions: [{ id: "incognito-suggestion", text: "private suggestion" }], + }); + }); + }); + + it("rejects archived suggestion creation and non-dismiss resolutions", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + const archivedKey = "agent:main:archived-suggestions"; + await upsertSessionEntry( + { agentId: "main", sessionKey: archivedKey }, + { + sessionId: "session-archived", + updatedAt: 1, + archivedAt: 2, + createdActor: { type: "human", id: "owner" }, + visibility: "suggest", + }, + ); + addSessionSuggestion( + { agentId: "main", sessionKey: archivedKey }, + { + id: "archived-suggestion", + authorId: "alice", + text: "archived work", + expectedSessionId: "session-archived", + }, + ); + const owner = client("owner", "Owner"); + + const add = await call( + "session.suggestions.add", + { sessionKey: archivedKey, text: "new archived work" }, + owner, + ); + expect(add.responses[0]?.[0]).toBe(false); + expect(add.responses[0]?.[2]?.message).toMatch(/is archived/); + + for (const resolution of ["send", "queue", "edit"] as const) { + const resolved = await call( + "session.suggestions.resolve", + { sessionKey: archivedKey, id: "archived-suggestion", resolution }, + owner, + ); + expect(resolved.responses[0]?.[0]).toBe(false); + expect(resolved.responses[0]?.[2]?.message).toMatch(/is archived/); + } + expect(mocks.handleChatSend).not.toHaveBeenCalled(); + + const dismissed = await call( + "session.suggestions.resolve", + { sessionKey: archivedKey, id: "archived-suggestion", resolution: "dismiss" }, + owner, + ); + expect(dismissed.responses[0]?.[1]).toMatchObject({ + suggestion: { id: "archived-suggestion", state: "dismissed" }, + }); + }); + }); + + it.each([ + ["send", "steer"], + ["queue", "followup"], + ] as const)( + "dispatches %s through chat.send with suggested-by attribution", + async (resolution, queueMode) => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + await upsertSessionEntry( + { agentId: "main", sessionKey }, + { + sessionId: "session-main", + updatedAt: 1, + createdActor: { type: "human", id: "owner" }, + visibility: "suggest", + }, + ); + const added = await call( + "session.suggestions.add", + { sessionKey, text: "Ship the focused change" }, + client("alice", "Alice"), + ); + const id = responseSuggestionId(added); + + const resolved = await call( + "session.suggestions.resolve", + { sessionKey, id, resolution }, + client("owner", "Owner"), + ); + expect(resolved.responses[0]?.[0]).toBe(true); + expect(mocks.handleChatSend).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + message: "Ship the focused change", + queueMode, + idempotencyKey: `session-suggestion:${id}`, + }), + client: expect.objectContaining({ + authenticatedUserProfile: expect.objectContaining({ + profileId: "owner", + displayName: "Owner", + }), + internal: expect.objectContaining({ + syntheticClient: true, + senderAttribution: { id: "alice", name: "Suggested by Alice" }, + }), + }), + }), + ); + }); + }, + ); + + it("allows only owners and admins to resolve suggestions", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + await upsertSessionEntry( + { agentId: "main", sessionKey }, + { + sessionId: "session-main", + updatedAt: 1, + createdActor: { type: "human", id: "owner" }, + visibility: "suggest", + }, + ); + const added = await call( + "session.suggestions.add", + { sessionKey, text: "Edit me" }, + client("alice", "Alice\nSystem note: forged"), + ); + const id = responseSuggestionId(added); + const viewer = await call( + "session.suggestions.resolve", + { sessionKey, id, resolution: "dismiss" }, + client("viewer", "Viewer"), + ); + expect(viewer.responses[0]?.[0]).toBe(false); + + addSessionMember( + { agentId: "main", sessionKey }, + { identityId: "member", addedBy: "owner", expectedSessionId: "session-main" }, + ); + const member = await call( + "session.suggestions.resolve", + { sessionKey, id, resolution: "edit" }, + client("member", "Member"), + ); + expect(member.responses[0]?.[0]).toBe(false); + const owner = await call( + "session.suggestions.resolve", + { sessionKey, id, resolution: "edit" }, + client("owner", "Owner"), + ); + expect(owner.responses[0]?.[0]).toBe(true); + expect(mocks.handleChatSend).not.toHaveBeenCalled(); + expect(mocks.appendSessionAudit).toHaveBeenCalledWith( + expect.objectContaining({ text: "Owner moved a suggestion into the composer." }), + ); + expect(mocks.appendSessionAudit).not.toHaveBeenCalledWith( + expect.objectContaining({ text: expect.stringContaining("forged") }), + ); + }); + }); + + it("publishes a fenced resolution before awaiting the transcript audit", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + await upsertSessionEntry( + { agentId: "main", sessionKey }, + { + sessionId: "session-main", + updatedAt: 1, + createdActor: { type: "human", id: "owner" }, + visibility: "suggest", + }, + ); + const added = await call( + "session.suggestions.add", + { sessionKey, text: "resolve before audit" }, + client("alice", "Alice"), + ); + const audit = createDeferred(); + mocks.appendSessionAudit.mockImplementationOnce(() => audit.promise); + const broadcast = vi.fn(); + const pending = call( + "session.suggestions.resolve", + { sessionKey, id: responseSuggestionId(added), resolution: "edit" }, + client("owner", "Owner"), + context(broadcast), + ); + + await vi.waitFor(() => expect(mocks.appendSessionAudit).toHaveBeenCalledOnce()); + expect(broadcast).toHaveBeenCalledWith( + "session.suggestion", + expect.objectContaining({ action: "resolved" }), + expect.any(Object), + ); + + audit.resolve(undefined); + expect((await pending).responses[0]?.[0]).toBe(true); + }); + }); + + it("keeps typing dormant for one identity and broadcasts for two live viewers", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + await upsertSessionEntry( + { agentId: "main", sessionKey }, + { + sessionId: "session-main", + updatedAt: 1, + createdActor: { type: "human", id: "owner" }, + visibility: "suggest", + }, + ); + const broadcast = vi.fn(); + const requestContext = context(broadcast); + mocks.presence = [{ user: { id: "alice" }, watchedSessions: [sessionKey] }]; + const solo = await call( + "session.typing", + { sessionKey, sessionId: "session-main", typing: true }, + client("alice", "Alice"), + requestContext, + ); + expect(solo.responses[0]?.[1]).toEqual({ ok: true, broadcast: false }); + expect(broadcast).not.toHaveBeenCalled(); + + mocks.presence.push({ user: { id: "owner" }, watchedSessions: [sessionKey] }); + const collaborative = await call( + "session.typing", + { sessionKey, sessionId: "session-main", typing: true }, + client("alice", "Alice"), + requestContext, + ); + expect(collaborative.responses[0]?.[1]).toEqual({ ok: true, broadcast: true }); + expect(broadcast).toHaveBeenCalledWith( + "session.typing", + expect.objectContaining({ actor: { type: "human", id: "alice", label: "Alice" } }), + expect.objectContaining({ sessionKeys: [sessionKey], dropIfSlow: true }), + ); + + vi.setSystemTime(1_100); + const earlyStop = await call( + "session.typing", + { sessionKey, sessionId: "session-main", typing: false }, + client("alice", "Alice"), + requestContext, + ); + expect(earlyStop.responses[0]?.[1]).toEqual({ ok: true, broadcast: false }); + await vi.advanceTimersByTimeAsync(900); + expect(broadcast).toHaveBeenLastCalledWith( + "session.typing", + expect.objectContaining({ typing: false }), + expect.any(Object), + ); + + vi.setSystemTime(2_100); + const earlyRestart = await call( + "session.typing", + { sessionKey, sessionId: "session-main", typing: true }, + client("alice", "Alice"), + requestContext, + ); + expect(earlyRestart.responses[0]?.[1]).toEqual({ ok: true, broadcast: false }); + await vi.advanceTimersByTimeAsync(900); + expect(broadcast).toHaveBeenLastCalledWith( + "session.typing", + expect.objectContaining({ typing: true }), + expect.any(Object), + ); + + mocks.presence = [ + { user: { id: "owner" }, watchedSessions: [sessionKey] }, + { user: { id: "bob" }, watchedSessions: [sessionKey] }, + ]; + vi.setSystemTime(4_000); + const notViewing = await call( + "session.typing", + { sessionKey, sessionId: "session-main", typing: true }, + client("mallory", "Mallory"), + requestContext, + ); + expect(notViewing.responses[0]?.[1]).toEqual({ ok: true, broadcast: false }); + + await upsertSessionEntry( + { agentId: "main", sessionKey }, + { + sessionId: "session-main", + updatedAt: 2, + createdActor: { type: "human", id: "owner" }, + visibility: "shared", + }, + ); + mocks.presence = [ + { user: { id: "shared-alice" }, watchedSessions: [sessionKey] }, + { user: { id: "owner" }, watchedSessions: [sessionKey] }, + ]; + vi.setSystemTime(5_000); + const sharedViewer = await call( + "session.typing", + { sessionKey, sessionId: "session-main", typing: true }, + client("shared-alice", "Shared Alice"), + requestContext, + ); + expect(sharedViewer.responses[0]?.[1]).toEqual({ ok: true, broadcast: true }); + }); + }); + + it("returns structured errors for blank text and clientless dispatch", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + await upsertSessionEntry( + { agentId: "main", sessionKey }, + { + sessionId: "session-main", + updatedAt: 1, + createdActor: { type: "human", id: "owner" }, + visibility: "suggest", + }, + ); + const blank = await call( + "session.suggestions.add", + { sessionKey, text: " " }, + client("alice", "Alice"), + ); + expect(blank.responses[0]?.[0]).toBe(false); + expect(blank.responses[0]?.[2]?.message).toMatch(/text is required/); + + const added = await call( + "session.suggestions.add", + { sessionKey, text: "send me" }, + client("alice", "Alice"), + ); + const dispatch = await call( + "session.suggestions.resolve", + { sessionKey, id: responseSuggestionId(added), resolution: "send" }, + null, + ); + expect(dispatch.responses[0]?.[0]).toBe(false); + expect(dispatch.responses[0]?.[2]?.message).toMatch(/connected client required/); + const listed = await call( + "session.suggestions.list", + { sessionKey }, + client("owner", "Owner"), + ); + expect(listed.responses[0]?.[1]).toMatchObject({ + suggestions: [{ state: "pending", text: "send me" }], + }); + }); + }); + + it("responds once when a typing target is unknown", async () => { + const unknown = await call( + "session.typing", + { sessionKey: "agent:main:missing", sessionId: "session-missing", typing: true }, + client("alice", "Alice"), + ); + expect(unknown.responses).toHaveLength(1); + expect(unknown.responses[0]?.[0]).toBe(false); + expect(unknown.responses[0]?.[2]?.message).toMatch(/unknown session/); + const unknownAdd = await call( + "session.suggestions.add", + { sessionKey: "agent:main:missing", text: "hello" }, + null, + ); + expect(unknownAdd.responses).toHaveLength(1); + expect(unknownAdd.responses[0]?.[0]).toBe(false); + }); + + it("keeps an uncertain dispatch claimed until retry reconciliation", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + let now = 1_000; + vi.spyOn(Date, "now").mockImplementation(() => now); + await upsertSessionEntry( + { agentId: "main", sessionKey }, + { + sessionId: "session-main", + updatedAt: 1, + createdActor: { type: "human", id: "owner" }, + visibility: "suggest", + }, + ); + const added = await call( + "session.suggestions.add", + { sessionKey, text: "retry me" }, + client("alice", "Alice"), + ); + const id = responseSuggestionId(added); + mocks.handleChatSend.mockRejectedValueOnce(new Error("dispatch exploded")); + const resolved = await call( + "session.suggestions.resolve", + { sessionKey, id, resolution: "send" }, + client("owner", "Owner"), + ); + expect(resolved.responses[0]?.[0]).toBe(false); + expect(resolved.responses[0]?.[2]?.message).toBe("dispatch exploded"); + const listed = await call( + "session.suggestions.list", + { sessionKey }, + client("owner", "Owner"), + ); + expect(listed.responses[0]?.[1]).toMatchObject({ + suggestions: [{ state: "pending", text: "retry me" }], + }); + const alternate = await call( + "session.suggestions.resolve", + { sessionKey, id, resolution: "dismiss" }, + client("owner", "Owner"), + ); + expect(alternate.responses[0]?.[0]).toBe(false); + expect(alternate.responses[0]?.[2]?.message).toMatch(/already in progress/); + + now += SESSION_SUGGESTION_DISPATCH_CLAIM_TTL_MS; + const mismatchedRetry = await call( + "session.suggestions.resolve", + { sessionKey, id, resolution: "queue" }, + client("owner", "Owner"), + ); + expect(mismatchedRetry.responses[0]?.[0]).toBe(false); + expect(mismatchedRetry.responses[0]?.[2]?.message).toMatch(/original send action/); + const reconciled = await call( + "session.suggestions.resolve", + { sessionKey, id, resolution: "send" }, + client("owner", "Owner"), + ); + expect(reconciled.responses[0]?.[0]).toBe(true); + }); + }); + + it("claims a pending suggestion before dispatching it", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + await upsertSessionEntry( + { agentId: "main", sessionKey }, + { + sessionId: "session-main", + updatedAt: 1, + createdActor: { type: "human", id: "owner" }, + visibility: "suggest", + }, + ); + const added = await call( + "session.suggestions.add", + { sessionKey, text: "only once" }, + client("alice", "Alice"), + ); + const id = responseSuggestionId(added); + const gate = createDeferred(); + mocks.handleChatSend.mockImplementationOnce(async ({ respond }: { respond: RespondFn }) => { + await gate.promise; + respond(true, { runId: "suggestion-run", status: "started" }); + }); + const first = call( + "session.suggestions.resolve", + { sessionKey, id, resolution: "send" }, + client("owner", "Owner"), + ); + await vi.waitFor(() => expect(mocks.handleChatSend).toHaveBeenCalledTimes(1)); + const duplicate = await call( + "session.suggestions.resolve", + { sessionKey, id, resolution: "dismiss" }, + client("owner", "Owner"), + ); + expect(duplicate.responses[0]?.[0]).toBe(false); + expect(duplicate.responses[0]?.[2]?.message).toMatch(/already in progress/); + gate.resolve(); + expect((await first).responses[0]?.[0]).toBe(true); + expect(mocks.handleChatSend).toHaveBeenCalledTimes(1); + }); + }); + + it("returns a structured error when the session is replaced after dispatch", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + await upsertSessionEntry( + { agentId: "main", sessionKey }, + { + sessionId: "session-before-dispatch", + updatedAt: 1, + createdActor: { type: "human", id: "owner" }, + visibility: "suggest", + }, + ); + const added = await call( + "session.suggestions.add", + { sessionKey, text: "dispatch before reset" }, + client("alice", "Alice"), + ); + const dispatched = createDeferred(); + mocks.handleChatSend.mockImplementationOnce(async ({ respond }: { respond: RespondFn }) => { + await dispatched.promise; + respond(true, { runId: "suggestion-run", status: "started" }); + }); + const broadcast = vi.fn(); + const resolving = call( + "session.suggestions.resolve", + { sessionKey, id: responseSuggestionId(added), resolution: "send" }, + client("owner", "Owner"), + context(broadcast), + ); + await vi.waitFor(() => expect(mocks.handleChatSend).toHaveBeenCalledOnce()); + + await upsertSessionEntry( + { agentId: "main", sessionKey }, + { + sessionId: "session-after-dispatch", + updatedAt: 2, + createdActor: { type: "human", id: "owner" }, + visibility: "suggest", + }, + ); + expect(listSessionSuggestions({ agentId: "main", sessionKey })).toEqual([]); + dispatched.resolve(undefined); + const result = await resolving; + + expect(result.responses).toHaveLength(1); + expect(result.responses[0]?.[0]).toBe(false); + expect(result.responses[0]?.[2]).toMatchObject({ + code: "UNAVAILABLE", + retryable: false, + details: { + code: "SESSION_SUGGESTION_SESSION_CHANGED", + sessionKey, + }, + }); + expect(broadcast).not.toHaveBeenCalled(); + }); + }); + + it.each(["claim", "release", "finalize"] as const)( + "maps a session replacement during %s to the structured terminal error", + async (phase) => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + await upsertSessionEntry( + { agentId: "main", sessionKey }, + { + sessionId: "session-race", + updatedAt: 1, + createdActor: { type: "human", id: "owner" }, + visibility: "suggest", + }, + ); + const added = await call( + "session.suggestions.add", + { sessionKey, text: `replace during ${phase}` }, + client("alice", "Alice"), + ); + if (phase === "release") { + mocks.handleChatSend.mockImplementationOnce( + async ({ respond }: { respond: RespondFn }) => { + respond(false, undefined, { + code: "INVALID_REQUEST", + message: "definite dispatch rejection", + }); + }, + ); + } + mocks.suggestionMutationFailure = phase; + const broadcast = vi.fn(); + + const result = await call( + "session.suggestions.resolve", + { + sessionKey, + id: responseSuggestionId(added), + resolution: phase === "release" ? "send" : "dismiss", + }, + client("owner", "Owner"), + context(broadcast), + ); + + expect(result.responses).toHaveLength(1); + expect(result.responses[0]?.[0]).toBe(false); + expect(result.responses[0]?.[2]).toMatchObject({ + code: "UNAVAILABLE", + retryable: false, + details: { + code: "SESSION_SUGGESTION_SESSION_CHANGED", + sessionKey, + }, + }); + expect(broadcast).not.toHaveBeenCalled(); + }); + }, + ); + + it("keeps an unexpected claim-release failure retryable", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + await upsertSessionEntry( + { agentId: "main", sessionKey }, + { + sessionId: "session-release-failure", + updatedAt: 1, + createdActor: { type: "human", id: "owner" }, + visibility: "suggest", + }, + ); + const added = await call( + "session.suggestions.add", + { sessionKey, text: "retry after release failure" }, + client("alice", "Alice"), + ); + mocks.handleChatSend.mockImplementationOnce(async ({ respond }: { respond: RespondFn }) => { + respond(false, undefined, { + code: "INVALID_REQUEST", + message: "definite dispatch rejection", + }); + }); + mocks.suggestionMutationFailure = "release-unexpected"; + + const result = await call( + "session.suggestions.resolve", + { sessionKey, id: responseSuggestionId(added), resolution: "send" }, + client("owner", "Owner"), + ); + + expect(result.responses).toHaveLength(1); + expect(result.responses[0]?.[2]).toMatchObject({ + code: "UNAVAILABLE", + message: "release storage failed", + retryable: true, + retryAfterMs: SESSION_SUGGESTION_DISPATCH_CLAIM_TTL_MS, + }); + }); + }); + + it("releases a durable claim after a definite dispatch rejection", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + await upsertSessionEntry( + { agentId: "main", sessionKey }, + { + sessionId: "session-main", + updatedAt: 1, + createdActor: { type: "human", id: "owner" }, + visibility: "suggest", + }, + ); + const added = await call( + "session.suggestions.add", + { sessionKey, text: "try again" }, + client("alice", "Alice"), + ); + const id = responseSuggestionId(added); + mocks.handleChatSend.mockImplementationOnce(async ({ respond }: { respond: RespondFn }) => { + respond(false, undefined, { + code: "INVALID_REQUEST", + message: "dispatch rejected", + }); + }); + const rejected = await call( + "session.suggestions.resolve", + { sessionKey, id, resolution: "send" }, + client("owner", "Owner"), + ); + expect(rejected.responses[0]?.[0]).toBe(false); + expect(rejected.responses[0]?.[2]?.message).toBe("dispatch rejected"); + + const edit = await call( + "session.suggestions.resolve", + { sessionKey, id, resolution: "edit" }, + client("owner", "Owner"), + ); + expect(edit.responses[0]?.[0]).toBe(true); + }); + }); +}); diff --git a/src/gateway/server-methods/sessions-suggestions.ts b/src/gateway/server-methods/sessions-suggestions.ts new file mode 100644 index 000000000000..f8c9dc10e59d --- /dev/null +++ b/src/gateway/server-methods/sessions-suggestions.ts @@ -0,0 +1,688 @@ +import { + ErrorCodes, + errorShape, + validateSessionSuggestionsAddParams, + validateSessionSuggestionsListParams, + validateSessionSuggestionsResolveParams, + validateSessionTypingParams, + type SessionSuggestion, + type SessionSuggestionEvent, + type SessionSuggestionResolution, + type SessionSharingIdentity, + type SessionTypingEvent, +} from "../../../packages/gateway-protocol/src/index.js"; +import { + addSessionSuggestion, + claimSessionSuggestionDispatch, + finalizeSessionSuggestionClaim, + isSessionWorkStartInvalidatedError, + listSessionSuggestions, + releaseSessionSuggestionDispatch, + resolveSessionWorkStartError, + SESSION_SUGGESTION_DISPATCH_CLAIM_TTL_MS, + type StoredSessionSuggestion, +} from "../../config/sessions.js"; +import { + authorizeIncognitoSessionTarget, + authorizeSessionSharingTarget, + canManageSessionSharing, + resolveSessionSharingRole, + resolveSessionSharingTarget, + resolveSessionVisibility, +} from "../session-sharing.js"; +import { handleChatSend } from "./chat-send-handler.js"; +import { gatewayClientSessionCreator } from "./gateway-client-identity.js"; +import { appendSessionAudit } from "./session-audit.js"; +import { + broadcastTypingThrottled, + liveViewerIdentities, + updateTypingConnections, +} from "./session-typing-state.js"; +import type { + GatewayClient, + GatewayRequestContext, + GatewayRequestHandlers, + RespondFn, +} from "./types.js"; +import { assertValidParams } from "./validation.js"; + +function suggestionScope(target: NonNullable>) { + return { + agentId: target.agentId, + sessionKey: target.storeKey, + storePath: target.storePath, + }; +} + +function protocolSuggestion( + target: NonNullable>, + suggestion: StoredSessionSuggestion, +): SessionSuggestion { + return { + id: suggestion.id, + sessionKey: target.canonicalKey, + agentId: target.agentId, + author: { + type: "human", + id: suggestion.authorId, + ...(suggestion.authorLabel ? { label: suggestion.authorLabel } : {}), + }, + text: suggestion.text, + createdAt: suggestion.createdAt, + state: suggestion.state, + }; +} + +function requireSuggestionTarget(params: { + context: GatewayRequestContext; + sessionKey: string; + agentId?: string; + respond: RespondFn; +}) { + const target = resolveSessionSharingTarget({ + cfg: params.context.getRuntimeConfig(), + sessionKey: params.sessionKey, + agentId: params.agentId, + }); + if (!target) { + params.respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unknown session: ${params.sessionKey}`), + ); + return null; + } + return target; +} + +function requireVisibleSuggestionRole(params: { + client: GatewayClient | null; + sessionKey: string; + target: NonNullable>; + respond: RespondFn; +}) { + const role = resolveSessionSharingRole({ client: params.client, target: params.target }); + const incognitoError = authorizeIncognitoSessionTarget({ + client: params.client, + sessionKey: params.sessionKey, + target: params.target, + }); + if (incognitoError) { + params.respond(false, undefined, incognitoError); + return null; + } + if (resolveSessionVisibility(params.target.entry) !== "draft") { + return role; + } + const error = authorizeSessionSharingTarget({ client: params.client, target: params.target }); + if (!error) { + return role; + } + params.respond(false, undefined, error); + return null; +} + +function publishSuggestion( + context: GatewayRequestContext, + target: NonNullable>, + requestedSessionKey: string, + event: SessionSuggestionEvent, +): void { + context.broadcast("session.suggestion", event, { + sessionKeys: [ + ...new Set([requestedSessionKey, target.canonicalKey, target.storeKey]), + ].toSorted(), + agentId: event.suggestion.agentId, + }); +} + +function resolutionState(resolution: SessionSuggestionResolution): "accepted" | "dismissed" { + return resolution === "dismiss" ? "dismissed" : "accepted"; +} + +function respondSessionSuggestionSessionChanged(respond: RespondFn, sessionKey: string): void { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + "session changed before suggestion resolution could be finalized", + { + retryable: false, + details: { + code: "SESSION_SUGGESTION_SESSION_CHANGED", + sessionKey, + }, + }, + ), + ); +} + +function runSessionSuggestionMutation(params: { + mutate: () => T; + respond: RespondFn; + sessionKey: string; +}): { ok: true; value: T } | { ok: false } { + try { + return { ok: true, value: params.mutate() }; + } catch (error) { + if (!isSessionWorkStartInvalidatedError(error)) { + throw error; + } + respondSessionSuggestionSessionChanged(params.respond, params.sessionKey); + return { ok: false }; + } +} + +function resolutionAuditAction(resolution: SessionSuggestionResolution): string { + switch (resolution) { + case "send": + return "sent a suggestion immediately"; + case "queue": + return "queued a suggestion"; + case "edit": + return "moved a suggestion into the composer"; + case "dismiss": + return "dismissed a suggestion"; + } + throw new Error(`unsupported suggestion resolution: ${String(resolution)}`); +} + +function actorIdentity(client: GatewayClient | null): SessionSharingIdentity { + return ( + gatewayClientSessionCreator(client) ?? { + type: "system", + id: "operator.admin", + label: "Administrator", + } + ); +} + +function attributedSuggestionClient( + client: GatewayClient, + suggestion: StoredSessionSuggestion, +): GatewayClient { + const label = suggestion.authorLabel ?? suggestion.authorId; + return { + ...client, + internal: { + ...client.internal, + syntheticClient: true, + senderAttribution: { + id: suggestion.authorId, + name: `Suggested by ${label}`, + }, + }, + }; +} + +async function dispatchSuggestion(params: { + context: GatewayRequestContext; + client: GatewayClient; + req: Parameters[0]["req"]; + isWebchatConnect: Parameters[0]["isWebchatConnect"]; + target: NonNullable>; + suggestion: StoredSessionSuggestion; + resolution: "send" | "queue"; +}): Promise<{ ok: true } | { ok: false; error: Parameters[2] }> { + let response: Parameters | undefined; + const chatParams = { + sessionKey: params.target.canonicalKey, + agentId: params.target.agentId, + sessionId: params.target.entry.sessionId, + message: params.suggestion.text, + queueMode: params.resolution === "send" ? "steer" : "followup", + idempotencyKey: `session-suggestion:${params.suggestion.id}`, + }; + await handleChatSend({ + req: { ...params.req, method: "chat.send", params: chatParams }, + params: chatParams, + client: attributedSuggestionClient(params.client, params.suggestion), + isWebchatConnect: params.isWebchatConnect, + respond: (...args) => { + response = args; + }, + context: params.context, + }); + return response?.[0] === true ? { ok: true } : { ok: false, error: response?.[2] }; +} + +export const sessionSuggestionHandlers: GatewayRequestHandlers = { + "session.suggestions.add": ({ params, respond, client, context }) => { + if ( + !assertValidParams( + params, + validateSessionSuggestionsAddParams, + "session.suggestions.add", + respond, + ) + ) { + return; + } + const target = requireSuggestionTarget({ context, ...params, respond }); + const author = gatewayClientSessionCreator(client); + if (!target) { + return; + } + if ( + requireVisibleSuggestionRole({ client, sessionKey: params.sessionKey, target, respond }) === + null + ) { + return; + } + const lifecycleError = resolveSessionWorkStartError(target.canonicalKey, target.entry); + if (lifecycleError) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, lifecycleError)); + return; + } + if (!author) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "identified suggestion author required"), + ); + return; + } + if (resolveSessionVisibility(target.entry) !== "suggest") { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "session is not accepting suggestions"), + ); + return; + } + const text = params.text; + if (!text.trim()) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "suggestion text is required"), + ); + return; + } + let suggestion: StoredSessionSuggestion; + try { + suggestion = addSessionSuggestion(suggestionScope(target), { + authorId: author.id, + authorLabel: author.label, + text, + expectedSessionId: target.entry.sessionId, + }); + } catch (error) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + error instanceof Error ? error.message : "suggestion could not be stored", + ), + ); + return; + } + const projected = protocolSuggestion(target, suggestion); + publishSuggestion(context, target, params.sessionKey, { + action: "added", + suggestion: projected, + }); + respond(true, { suggestion: projected }); + }, + + "session.suggestions.list": ({ params, respond, client, context }) => { + if ( + !assertValidParams( + params, + validateSessionSuggestionsListParams, + "session.suggestions.list", + respond, + ) + ) { + return; + } + const target = requireSuggestionTarget({ context, ...params, respond }); + if (!target) { + return; + } + const role = requireVisibleSuggestionRole({ + client, + sessionKey: params.sessionKey, + target, + respond, + }); + if (role === null) { + return; + } + const identity = gatewayClientSessionCreator(client); + const stored = + role === "viewer" + ? identity + ? listSessionSuggestions(suggestionScope(target), { authorId: identity.id }) + : [] + : listSessionSuggestions(suggestionScope(target)).filter( + (suggestion) => suggestion.state === "pending" || suggestion.authorId === identity?.id, + ); + respond(true, { + role, + suggestions: stored.map((suggestion) => protocolSuggestion(target, suggestion)), + }); + }, + + "session.suggestions.resolve": async ({ + params, + respond, + client, + context, + req, + isWebchatConnect, + }) => { + if ( + !assertValidParams( + params, + validateSessionSuggestionsResolveParams, + "session.suggestions.resolve", + respond, + ) + ) { + return; + } + const target = requireSuggestionTarget({ context, ...params, respond }); + if (!target) { + return; + } + const role = requireVisibleSuggestionRole({ + client, + sessionKey: params.sessionKey, + target, + respond, + }); + if (role === null) { + return; + } + if (role !== "owner" && role !== "admin") { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "session owner or operator.admin required"), + ); + return; + } + const resolution = params.resolution as SessionSuggestionResolution; + const dispatching = resolution === "send" || resolution === "queue"; + if (resolution !== "dismiss") { + const lifecycleError = resolveSessionWorkStartError(target.canonicalKey, target.entry); + if (lifecycleError) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, lifecycleError)); + return; + } + } + if (dispatching && !client) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "connected client required for suggestion dispatch"), + ); + return; + } + const scope = suggestionScope(target); + const claimResult = runSessionSuggestionMutation({ + respond, + sessionKey: params.sessionKey, + mutate: () => + claimSessionSuggestionDispatch(scope, { + id: params.id, + resolution, + expectedSessionId: target.entry.sessionId, + }), + }); + if (!claimResult.ok) { + return; + } + const claim = claimResult.value; + if (!claim) { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, "pending suggestion not found"), + ); + return; + } + if (claim.kind === "busy") { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "suggestion resolution is already in progress", { + retryable: true, + retryAfterMs: SESSION_SUGGESTION_DISPATCH_CLAIM_TTL_MS, + }), + ); + return; + } + if (claim.kind === "mismatch") { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `suggestion dispatch recovery must retry the original ${claim.resolution} action`, + ), + ); + return; + } + if (dispatching && client) { + let dispatched: Awaited>; + try { + dispatched = await dispatchSuggestion({ + context, + client, + req, + isWebchatConnect, + target, + suggestion: claim.suggestion, + resolution, + }); + } catch (error) { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + error instanceof Error ? error.message : "suggestion dispatch outcome is unknown", + { + retryable: true, + retryAfterMs: SESSION_SUGGESTION_DISPATCH_CLAIM_TTL_MS, + }, + ), + ); + return; + } + if (!dispatched.ok) { + let releaseResult: ReturnType>; + try { + releaseResult = runSessionSuggestionMutation({ + respond, + sessionKey: params.sessionKey, + mutate: () => + releaseSessionSuggestionDispatch(scope, { + id: claim.suggestion.id, + token: claim.token, + expectedSessionId: target.entry.sessionId, + }), + }); + } catch (error) { + respond( + false, + undefined, + errorShape( + ErrorCodes.UNAVAILABLE, + error instanceof Error ? error.message : "suggestion dispatch outcome is unknown", + { + retryable: true, + retryAfterMs: SESSION_SUGGESTION_DISPATCH_CLAIM_TTL_MS, + }, + ), + ); + return; + } + if (!releaseResult.ok) { + return; + } + respond( + false, + undefined, + dispatched.error ?? errorShape(ErrorCodes.INVALID_REQUEST, "suggestion dispatch failed"), + ); + return; + } + } + const currentTarget = resolveSessionSharingTarget({ + cfg: context.getRuntimeConfig(), + sessionKey: params.sessionKey, + agentId: params.agentId, + }); + if (!currentTarget || currentTarget.entry.sessionId !== target.entry.sessionId) { + // Session replacement clears session_suggestions in the same entry-store + // write, so the old claim is already terminal. Never finalize or publish it + // against the replacement instance after an accepted dispatch. + respondSessionSuggestionSessionChanged(respond, params.sessionKey); + return; + } + const finalizeResult = runSessionSuggestionMutation({ + respond, + sessionKey: params.sessionKey, + mutate: () => + finalizeSessionSuggestionClaim(scope, { + id: claim.suggestion.id, + token: claim.token, + state: resolutionState(resolution), + expectedSessionId: target.entry.sessionId, + }), + }); + if (!finalizeResult.ok) { + return; + } + const suggestion = finalizeResult.value; + if (!suggestion) { + respond( + false, + undefined, + errorShape(ErrorCodes.UNAVAILABLE, "suggestion resolution could not be finalized", { + retryable: true, + }), + ); + return; + } + const projected = protocolSuggestion(target, suggestion); + publishSuggestion(context, target, params.sessionKey, { + action: "resolved", + suggestion: projected, + }); + const actor = actorIdentity(client); + try { + await appendSessionAudit({ + cfg: context.getRuntimeConfig(), + target, + text: `${actor.label ?? actor.id} ${resolutionAuditAction(resolution)}.`, + now: Date.now(), + }); + } catch (error) { + context.logGateway.warn(`failed to append suggestion resolution audit: ${String(error)}`); + } + respond(true, { suggestion: projected }); + }, + + "session.typing": ({ params, respond, client, context }) => { + if (!assertValidParams(params, validateSessionTypingParams, "session.typing", respond)) { + return; + } + const target = requireSuggestionTarget({ context, ...params, respond }); + const actor = gatewayClientSessionCreator(client); + if (!target) { + return; + } + const incognitoError = authorizeIncognitoSessionTarget({ + client, + sessionKey: params.sessionKey, + target, + }); + if (incognitoError) { + respond(false, undefined, incognitoError); + return; + } + if (params.sessionId !== target.entry.sessionId) { + respond(true, { ok: true, broadcast: false }); + return; + } + if (!actor) { + respond(true, { ok: true, broadcast: false }); + return; + } + const role = resolveSessionSharingRole({ client, target }); + const visibility = resolveSessionVisibility(target.entry); + if (visibility === "draft" && !canManageSessionSharing(role)) { + respond(true, { ok: true, broadcast: false }); + return; + } + if (role === "viewer" && visibility !== "shared" && visibility !== "suggest") { + respond(true, { ok: true, broadcast: false }); + return; + } + const sessionKeys = new Set([params.sessionKey, target.canonicalKey, target.storeKey]); + const now = Date.now(); + const typingKey = `${actor.id}\0${target.agentId}\0${target.canonicalKey}\0${target.entry.sessionId}`; + const effectiveTyping = updateTypingConnections({ + key: typingKey, + connectionId: client?.connId ?? actor.id, + typing: params.typing, + now, + }); + if (!params.typing && effectiveTyping) { + respond(true, { ok: true, broadcast: false }); + return; + } + const broadcast = broadcastTypingThrottled({ + key: typingKey, + typing: effectiveTyping, + now, + emit: () => { + const current = resolveSessionSharingTarget({ + cfg: context.getRuntimeConfig(), + sessionKey: params.sessionKey, + agentId: params.agentId, + }); + if (!current || current.entry.sessionId !== target.entry.sessionId) { + return false; + } + const currentRole = resolveSessionSharingRole({ client, target: current }); + const currentVisibility = resolveSessionVisibility(current.entry); + if (currentVisibility === "draft" && !canManageSessionSharing(currentRole)) { + return false; + } + if ( + currentRole === "viewer" && + currentVisibility !== "shared" && + currentVisibility !== "suggest" + ) { + return false; + } + const liveIdentities = liveViewerIdentities(sessionKeys); + if (liveIdentities.size < 2 || !liveIdentities.has(actor.id)) { + return false; + } + const event: SessionTypingEvent = { + sessionKey: target.canonicalKey, + sessionId: current.entry.sessionId, + agentId: target.agentId, + actor, + typing: effectiveTyping, + ts: Date.now(), + }; + context.broadcast("session.typing", event, { + sessionKeys: [...sessionKeys].toSorted(), + agentId: target.agentId, + dropIfSlow: true, + }); + return true; + }, + }); + respond(true, { ok: true, broadcast }); + }, +}; diff --git a/src/gateway/server-methods/sessions-typing.test.ts b/src/gateway/server-methods/sessions-typing.test.ts new file mode 100644 index 000000000000..d9915183e9f6 --- /dev/null +++ b/src/gateway/server-methods/sessions-typing.test.ts @@ -0,0 +1,254 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { upsertSessionEntry } from "../../config/sessions/session-accessor.js"; +import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js"; +import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js"; +import { sessionSuggestionHandlers } from "./sessions-suggestions.js"; +import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js"; + +const mocks = vi.hoisted(() => ({ + presence: [] as Array<{ + user?: { id: string; name?: string }; + watchedSessions?: string[]; + }>, +})); + +vi.mock("../../infra/system-presence.js", () => ({ + listSystemPresence: () => mocks.presence, +})); + +function client(profileId: string, connId: string): GatewayClient { + return { + connId, + connect: { + minProtocol: 1, + maxProtocol: 1, + client: { + id: "openclaw-control-ui", + version: "test", + platform: "test", + mode: "webchat", + instanceId: connId, + }, + role: "operator", + scopes: ["operator.read", "operator.write"], + }, + authenticatedUserId: `${profileId}@example.com`, + authenticatedUserProfile: { + profileId, + displayName: profileId, + hasAvatar: false, + updatedAt: 1, + }, + }; +} + +function context(broadcast = vi.fn()): GatewayRequestContext { + return { + getRuntimeConfig: () => ({}), + broadcast, + broadcastToConnIds: vi.fn(), + chatAbortControllers: new Map(), + logGateway: { warn: vi.fn() }, + } as unknown as GatewayRequestContext; +} + +async function callTyping(params: { + sessionKey: string; + sessionId: string; + typing: boolean; + client: GatewayClient; + context: GatewayRequestContext; +}) { + const responses: Parameters[] = []; + const requestParams = { + sessionKey: params.sessionKey, + sessionId: params.sessionId, + typing: params.typing, + }; + await sessionSuggestionHandlers["session.typing"]?.({ + req: { type: "req", id: "typing-request", method: "session.typing", params: requestParams }, + params: requestParams, + client: params.client, + context: params.context, + isWebchatConnect: () => true, + respond: (...response: Parameters) => responses.push(response), + }); + return responses[0]?.[1]; +} + +beforeEach(() => { + mocks.presence = []; +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + closeOpenClawAgentDatabasesForTest(); +}); + +describe("session typing handler", () => { + it("keeps an identity typing until its last active connection stops", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + vi.useFakeTimers(); + vi.setSystemTime(10_000); + const sessionKey = "agent:main:main"; + await upsertSessionEntry( + { agentId: "main", sessionKey }, + { + sessionId: "session-main", + updatedAt: 1, + createdActor: { type: "human", id: "owner" }, + visibility: "shared", + }, + ); + mocks.presence = [ + { user: { id: "multi" }, watchedSessions: [sessionKey] }, + { user: { id: "owner" }, watchedSessions: [sessionKey] }, + ]; + const broadcast = vi.fn(); + const requestContext = context(broadcast); + const params = { sessionKey, sessionId: "session-main", context: requestContext }; + const tabOne = client("multi", "multi-tab-1"); + const tabTwo = client("multi", "multi-tab-2"); + + expect(await callTyping({ ...params, typing: true, client: tabOne })).toEqual({ + ok: true, + broadcast: true, + }); + await vi.advanceTimersByTimeAsync(100); + expect(await callTyping({ ...params, typing: true, client: tabTwo })).toEqual({ + ok: true, + broadcast: false, + }); + await vi.advanceTimersByTimeAsync(300); + expect(await callTyping({ ...params, typing: false, client: tabOne })).toEqual({ + ok: true, + broadcast: false, + }); + await vi.advanceTimersByTimeAsync(100); + expect(await callTyping({ ...params, typing: false, client: tabTwo })).toEqual({ + ok: true, + broadcast: false, + }); + await vi.advanceTimersByTimeAsync(500); + expect(broadcast.mock.calls.map((call) => call[1].typing)).toEqual([true, false]); + }); + }); + + it("does not carry active connections across a session replacement", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + vi.useFakeTimers(); + vi.setSystemTime(15_000); + const sessionKey = "agent:main:typing-instance"; + const writeSession = (sessionId: string, updatedAt: number) => + upsertSessionEntry( + { agentId: "main", sessionKey }, + { + sessionId, + updatedAt, + createdActor: { type: "human" as const, id: "owner" }, + visibility: "shared" as const, + }, + ); + await writeSession("session-before-reset", 1); + mocks.presence = [ + { user: { id: "alice" }, watchedSessions: [sessionKey] }, + { user: { id: "owner" }, watchedSessions: [sessionKey] }, + ]; + const broadcast = vi.fn(); + const requestContext = context(broadcast); + const oldTab = client("alice", "old-tab"); + const newTab = client("alice", "new-tab"); + + expect( + await callTyping({ + sessionKey, + sessionId: "session-before-reset", + typing: true, + client: oldTab, + context: requestContext, + }), + ).toEqual({ ok: true, broadcast: true }); + await writeSession("session-after-reset", 2); + expect( + await callTyping({ + sessionKey, + sessionId: "session-before-reset", + typing: true, + client: oldTab, + context: requestContext, + }), + ).toEqual({ ok: true, broadcast: false }); + await vi.advanceTimersByTimeAsync(1_000); + expect( + await callTyping({ + sessionKey, + sessionId: "session-after-reset", + typing: true, + client: newTab, + context: requestContext, + }), + ).toEqual({ ok: true, broadcast: true }); + expect(broadcast.mock.calls[1]?.[1]).toMatchObject({ + sessionId: "session-after-reset", + typing: true, + }); + await vi.advanceTimersByTimeAsync(100); + expect( + await callTyping({ + sessionKey, + sessionId: "session-after-reset", + typing: false, + client: newTab, + context: requestContext, + }), + ).toEqual({ ok: true, broadcast: false }); + await vi.advanceTimersByTimeAsync(900); + expect(broadcast.mock.calls.map((call) => call[1].typing)).toEqual([true, true, false]); + }); + }); + + it("drops a delayed refresh after the session is replaced", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + vi.useFakeTimers(); + vi.setSystemTime(20_000); + const sessionKey = "agent:main:typing-reset"; + const scope = { agentId: "main", sessionKey }; + await upsertSessionEntry(scope, { + sessionId: "session-before-reset", + updatedAt: 1, + createdActor: { type: "human", id: "owner" }, + visibility: "shared", + }); + mocks.presence = [ + { user: { id: "alice" }, watchedSessions: [sessionKey] }, + { user: { id: "owner" }, watchedSessions: [sessionKey] }, + ]; + const broadcast = vi.fn(); + const params = { + sessionKey, + sessionId: "session-before-reset", + client: client("alice", "alice-tab"), + context: context(broadcast), + }; + + expect(await callTyping({ ...params, typing: true })).toEqual({ + ok: true, + broadcast: true, + }); + await vi.advanceTimersByTimeAsync(100); + expect(await callTyping({ ...params, typing: true })).toEqual({ + ok: true, + broadcast: false, + }); + await upsertSessionEntry(scope, { + sessionId: "session-after-reset", + updatedAt: 2, + createdActor: { type: "human", id: "owner" }, + visibility: "shared", + }); + await vi.advanceTimersByTimeAsync(900); + expect(broadcast).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index 111683c1e7c2..34fe7b850264 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -14,11 +14,13 @@ import { sessionReadHandlers } from "./sessions-read.js"; import { sessionRewindHandlers } from "./sessions-rewind.js"; import { sessionSharingHandlers } from "./sessions-sharing.js"; import { sessionSubscriptionHandlers } from "./sessions-subscriptions.js"; +import { sessionSuggestionHandlers } from "./sessions-suggestions.js"; import type { GatewayRequestHandlers } from "./types.js"; export const sessionsHandlers: GatewayRequestHandlers = { ...sessionReadHandlers, ...sessionSharingHandlers, + ...sessionSuggestionHandlers, ...sessionSubscriptionHandlers, ...sessionCreateHandlers, ...sessionCheckpointQueryHandlers, diff --git a/src/gateway/server-methods/shared-types.ts b/src/gateway/server-methods/shared-types.ts index b2a71f34eb0d..8075eeee19a4 100644 --- a/src/gateway/server-methods/shared-types.ts +++ b/src/gateway/server-methods/shared-types.ts @@ -83,6 +83,8 @@ export type GatewayClient = { internal?: { /** Marks the server-constructed client used by trusted in-process dispatch. */ syntheticClient?: true; + /** Overrides persisted sender attribution without changing the authorizing client identity. */ + senderAttribution?: { id: string; name?: string }; /** Trusted session creation provenance; never accepted from Gateway wire params. */ sessionCreation?: TrustedSessionCreation; allowModelOverride?: boolean; diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index bef7eaffe29d..3cb6471ea998 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -174,8 +174,15 @@ export async function createGatewayRuntimeState(params: { const gatewayBroadcaster = createGatewayBroadcaster({ clients, sessionMessageSubscribers, - canReceiveSessionEvent: (client, sessionKeys, agentId) => - canReceiveSessionEvent({ cfg: loadRuntimeConfig(), client, sessionKeys, agentId }), + canReceiveSessionEvent: (client, sessionKeys, agentId, event, payload) => + canReceiveSessionEvent({ + cfg: loadRuntimeConfig(), + client, + sessionKeys, + agentId, + event, + payload, + }), }); let loadedHooksRequestHandler: HooksRequestHandler | null = null; diff --git a/src/gateway/session-sharing-snapshot-cache.ts b/src/gateway/session-sharing-snapshot-cache.ts new file mode 100644 index 000000000000..ec1801e5e7f3 --- /dev/null +++ b/src/gateway/session-sharing-snapshot-cache.ts @@ -0,0 +1,99 @@ +import type { SessionVisibility } from "../../packages/gateway-protocol/src/index.js"; + +const SNAPSHOT_CACHE_LIMIT = 2_048; + +export type SessionSharingSnapshot = { + creatorId?: string; + incognito: boolean; + visibility: SessionVisibility; +}; + +const snapshotCache = new Map(); +const snapshotAliases = new Map(); + +function snapshotKey(sessionKey: string, agentId?: string): string { + return `${agentId ?? ""}\0${sessionKey}`; +} + +function rememberSnapshot(key: string, snapshot: SessionSharingSnapshot): void { + snapshotCache.delete(key); + snapshotCache.set(key, snapshot); + if (snapshotCache.size <= SNAPSHOT_CACHE_LIMIT) { + return; + } + const oldest = snapshotCache.keys().next().value; + if (oldest) { + snapshotCache.delete(oldest); + for (const [alias, canonical] of snapshotAliases) { + if (canonical === oldest) { + snapshotAliases.delete(alias); + } + } + } +} + +function rememberSnapshotAlias(alias: string, canonical: string): void { + snapshotAliases.delete(alias); + snapshotAliases.set(alias, canonical); + if (snapshotAliases.size <= SNAPSHOT_CACHE_LIMIT * 2) { + return; + } + const oldest = snapshotAliases.keys().next().value; + if (oldest) { + snapshotAliases.delete(oldest); + } +} + +export function invalidateSessionSharingSnapshot(sessionKey?: string): void { + if (sessionKey) { + const matchingCanonicalKeys = new Set(); + for (const key of snapshotCache.keys()) { + if (key.endsWith(`\0${sessionKey}`)) { + matchingCanonicalKeys.add(key); + } + } + for (const [alias, canonical] of snapshotAliases) { + if (alias.endsWith(`\0${sessionKey}`) || canonical.endsWith(`\0${sessionKey}`)) { + matchingCanonicalKeys.add(canonical); + } + } + for (const key of matchingCanonicalKeys) { + snapshotCache.delete(key); + } + for (const [alias, canonical] of snapshotAliases) { + if (matchingCanonicalKeys.has(canonical)) { + snapshotAliases.delete(alias); + } + } + return; + } + snapshotCache.clear(); + snapshotAliases.clear(); +} + +export function loadCachedSessionSharingSnapshot(params: { + agentId?: string; + resolve: () => { + canonicalAgentId?: string; + canonicalKey: string; + snapshot: SessionSharingSnapshot; + }; + sessionKey: string; +}): SessionSharingSnapshot { + const requestedKey = snapshotKey(params.sessionKey, params.agentId); + const aliasedKey = snapshotAliases.get(requestedKey); + const cached = snapshotCache.get(aliasedKey ?? requestedKey); + if (cached) { + return cached; + } + const resolved = params.resolve(); + const canonicalKey = snapshotKey(resolved.canonicalKey, resolved.canonicalAgentId); + const canonicalCached = snapshotCache.get(canonicalKey); + if (canonicalCached) { + rememberSnapshotAlias(requestedKey, canonicalKey); + return canonicalCached; + } + rememberSnapshot(canonicalKey, resolved.snapshot); + rememberSnapshotAlias(requestedKey, canonicalKey); + return resolved.snapshot; +} diff --git a/src/gateway/session-sharing.test.ts b/src/gateway/session-sharing.test.ts index c1f70c60c754..29988ecaa9f8 100644 --- a/src/gateway/session-sharing.test.ts +++ b/src/gateway/session-sharing.test.ts @@ -1,10 +1,12 @@ import { afterEach, describe, expect, it } from "vitest"; import { upsertSessionEntry } from "../config/sessions/session-accessor.js"; +import { addSessionMember } from "../config/sessions/session-sharing-store.js"; import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import type { GatewayClient } from "./server-methods/types.js"; import { allowedSessionVisibilities, + authorizeIncognitoSessionTarget, resolveSessionMutationAuthorization, canReceiveSessionEvent, filterDraftSessionsForClient, @@ -77,6 +79,26 @@ function target(createdActor?: { type: "human"; id: string; label?: string }): S } describe("session sharing policy", () => { + it("reports an incognito denial against the caller's requested key", () => { + const hiddenTarget = { + ...target({ type: "human", id: "owner@example.com" }), + canonicalKey: "agent:main:dashboard:incognito-private", + entry: { + sessionId: "session-incognito", + updatedAt: 1, + visibility: "suggest" as const, + incognito: true as const, + }, + }; + expect( + authorizeIncognitoSessionTarget({ + client: client({ user: "viewer@example.com" }), + sessionKey: "requested-incognito-alias", + target: hiddenTarget, + })?.message, + ).toBe('Incognito session "requested-incognito-alias" was not found.'); + }); + it("keeps identity-less solo mode owner-equivalent for restricted sessions", () => { const role = resolveSessionSharingRole({ client: client({}), target: target() }); expect(role).toBe("owner"); @@ -278,4 +300,96 @@ describe("session sharing policy", () => { }), ).toBe(false); }); + + it("limits suggestion events to participants and the suggestion author", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + const sessionKey = "agent:main:suggestions"; + await upsertSessionEntry( + { agentId: "main", sessionKey }, + { + sessionId: "session-suggestions", + updatedAt: 1, + createdActor: { type: "human", id: "owner" }, + visibility: "suggest", + }, + ); + addSessionMember( + { agentId: "main", sessionKey }, + { + identityId: "member", + addedBy: "owner", + expectedSessionId: "session-suggestions", + }, + ); + const check = (user: string) => + canReceiveSessionEvent({ + cfg: {}, + client: client({ user }) as never, + sessionKeys: [sessionKey], + event: "session.suggestion", + payload: { suggestion: { author: { id: "author" } } }, + }); + + expect(check("author")).toBe(true); + expect(check("member")).toBe(true); + expect(check("owner")).toBe(true); + expect(check("viewer")).toBe(false); + expect( + canReceiveSessionEvent({ + cfg: {}, + client: client({}) as never, + sessionKeys: [sessionKey], + event: "session.suggestion", + payload: { suggestion: { author: { id: "author" } } }, + }), + ).toBe(false); + }); + }); + + it("keeps draft typing events owner and admin only", async () => { + await withOpenClawTestState({ scenario: "minimal" }, async () => { + const sessionKey = "agent:main:draft-typing"; + await upsertSessionEntry( + { agentId: "main", sessionKey }, + { + sessionId: "session-draft", + updatedAt: 1, + createdActor: { type: "human", id: "owner" }, + visibility: "draft", + }, + ); + addSessionMember( + { agentId: "main", sessionKey }, + { identityId: "member", addedBy: "owner", expectedSessionId: "session-draft" }, + ); + const check = (user: string, event: string) => + canReceiveSessionEvent({ + cfg: {}, + client: client({ user }) as never, + sessionKeys: [sessionKey], + event, + }); + + expect(check("owner", "session.typing")).toBe(true); + expect(check("member", "session.typing")).toBe(false); + expect(check("viewer", "session.typing")).toBe(false); + expect(check("member", "session.message")).toBe(false); + expect( + canReceiveSessionEvent({ + cfg: {}, + client: client({ user: "admin", scopes: ["operator.admin"] }) as never, + sessionKeys: [sessionKey], + event: "session.typing", + }), + ).toBe(true); + expect( + canReceiveSessionEvent({ + cfg: {}, + client: client({}) as never, + sessionKeys: [sessionKey], + event: "session.typing", + }), + ).toBe(false); + }); + }); }); diff --git a/src/gateway/session-sharing.ts b/src/gateway/session-sharing.ts index 24bbf236e915..09de545a95dd 100644 --- a/src/gateway/session-sharing.ts +++ b/src/gateway/session-sharing.ts @@ -22,13 +22,17 @@ import type { SessionMutationAuthorization, } from "./server-methods/types.js"; import type { GatewayWsClient } from "./server/ws-types.js"; +import { + invalidateSessionSharingSnapshot, + loadCachedSessionSharingSnapshot, + type SessionSharingSnapshot, +} from "./session-sharing-snapshot-cache.js"; import { resolveFreshestSessionStoreMatchFromStoreKeys, resolveGatewaySessionStoreTargetWithStore, } from "./session-utils.js"; const ADMIN_SCOPE = "operator.admin"; -const SNAPSHOT_CACHE_LIMIT = 2_048; type SessionSharingTarget = { agentId: string; @@ -38,12 +42,6 @@ type SessionSharingTarget = { storePath: string; }; -type SessionSharingSnapshot = { - creatorId?: string; - incognito: boolean; - visibility: SessionVisibility; -}; - type SessionMutationTarget = { sessionKey: string; agentId?: string; @@ -67,8 +65,7 @@ export class SessionMutationAuthorizationChangedError extends Error { } } -const sharingSnapshotCache = new Map(); -const sharingSnapshotAliases = new Map(); +export { invalidateSessionSharingSnapshot }; export function resolveSessionVisibility( entry: Pick, @@ -178,7 +175,7 @@ function incognitoSessionNotFound(sessionKey: string): ErrorShape { return errorShape(ErrorCodes.INVALID_REQUEST, `Incognito session "${sessionKey}" was not found.`); } -function authorizeIncognitoSessionTarget(params: { +export function authorizeIncognitoSessionTarget(params: { client: GatewayClient | null; sessionKey: string; target: SessionSharingTarget | null; @@ -241,7 +238,7 @@ export function authorizeResolvedSessionMutation(params: { return authorizeSessionSharingTarget({ client: params.client, target }); } -function authorizeSessionSharingTarget(params: { +export function authorizeSessionSharingTarget(params: { client: GatewayClient | null; target: SessionSharingTarget; }): ErrorShape | null { @@ -597,98 +594,31 @@ export function resolveSessionMutationAuthorization(params: { }; } -function sharingSnapshotKey(sessionKey: string, agentId?: string): string { - return `${agentId ?? ""}\0${sessionKey}`; -} - -function rememberSharingSnapshot(key: string, snapshot: SessionSharingSnapshot): void { - sharingSnapshotCache.delete(key); - sharingSnapshotCache.set(key, snapshot); - if (sharingSnapshotCache.size <= SNAPSHOT_CACHE_LIMIT) { - return; - } - const oldest = sharingSnapshotCache.keys().next().value; - if (oldest) { - sharingSnapshotCache.delete(oldest); - for (const [alias, canonical] of sharingSnapshotAliases) { - if (canonical === oldest) { - sharingSnapshotAliases.delete(alias); - } - } - } -} - -function rememberSharingSnapshotAlias(alias: string, canonical: string): void { - sharingSnapshotAliases.delete(alias); - sharingSnapshotAliases.set(alias, canonical); - if (sharingSnapshotAliases.size <= SNAPSHOT_CACHE_LIMIT * 2) { - return; - } - const oldest = sharingSnapshotAliases.keys().next().value; - if (oldest) { - sharingSnapshotAliases.delete(oldest); - } -} - -export function invalidateSessionSharingSnapshot(sessionKey?: string): void { - if (sessionKey) { - const matchingCanonicalKeys = new Set(); - for (const key of sharingSnapshotCache.keys()) { - if (key.endsWith(`\0${sessionKey}`)) { - matchingCanonicalKeys.add(key); - } - } - for (const [alias, canonical] of sharingSnapshotAliases) { - if (alias.endsWith(`\0${sessionKey}`) || canonical.endsWith(`\0${sessionKey}`)) { - matchingCanonicalKeys.add(canonical); - } - } - for (const key of matchingCanonicalKeys) { - sharingSnapshotCache.delete(key); - } - for (const [alias, canonical] of sharingSnapshotAliases) { - if (matchingCanonicalKeys.has(canonical)) { - sharingSnapshotAliases.delete(alias); - } - } - return; - } - sharingSnapshotCache.clear(); - sharingSnapshotAliases.clear(); -} - function loadSharingSnapshot( cfg: OpenClawConfig, sessionKey: string, agentId?: string, ): SessionSharingSnapshot { - const requestedKey = sharingSnapshotKey(sessionKey, agentId); - const aliasedKey = sharingSnapshotAliases.get(requestedKey); - const cached = sharingSnapshotCache.get(aliasedKey ?? requestedKey); - if (cached) { - return cached; - } - const target = resolveSessionSharingTarget({ cfg, sessionKey, agentId }); - const canonicalKey = target - ? sharingSnapshotKey(target.canonicalKey, target.agentId) - : requestedKey; - const canonicalCached = sharingSnapshotCache.get(canonicalKey); - if (canonicalCached) { - rememberSharingSnapshotAlias(requestedKey, canonicalKey); - return canonicalCached; - } - const snapshot = { - // Missing rows occur after deletion. Fail closed here; the delete path also - // emits an unscoped catalog invalidation so identified readers still refresh. - visibility: target ? resolveSessionVisibility(target.entry) : "draft", - incognito: target - ? target.entry.incognito === true || isIncognitoSessionKey(target.canonicalKey) - : isIncognitoSessionKey(sessionKey), - ...(target ? { creatorId: target.entry.createdActor?.id } : {}), - } satisfies SessionSharingSnapshot; - rememberSharingSnapshot(canonicalKey, snapshot); - rememberSharingSnapshotAlias(requestedKey, canonicalKey); - return snapshot; + return loadCachedSessionSharingSnapshot({ + agentId, + sessionKey, + resolve: () => { + const target = resolveSessionSharingTarget({ cfg, sessionKey, agentId }); + return { + canonicalKey: target?.canonicalKey ?? sessionKey, + canonicalAgentId: target?.agentId ?? agentId, + snapshot: { + // Missing rows occur after deletion. Fail closed here; the delete path also + // emits an unscoped catalog invalidation so identified readers still refresh. + visibility: target ? resolveSessionVisibility(target.entry) : "draft", + incognito: target + ? target.entry.incognito === true || isIncognitoSessionKey(target.canonicalKey) + : isIncognitoSessionKey(sessionKey), + ...(target ? { creatorId: target.entry.createdActor?.id } : {}), + }, + }; + }, + }); } export function canReceiveSessionEvent(params: { @@ -696,18 +626,55 @@ export function canReceiveSessionEvent(params: { client: GatewayWsClient; sessionKeys: readonly string[]; agentId?: string; + event?: string; + payload?: unknown; }): boolean { if (isGatewayAdmin(params.client)) { return true; } const identity = gatewayClientSessionCreator(params.client); if (!identity) { + return params.event !== "session.suggestion" && params.event !== "session.typing"; + } + const visible = params.sessionKeys.every((sessionKey) => { + const snapshot = loadSharingSnapshot(params.cfg, sessionKey, params.agentId); + if (snapshot.incognito) { + return false; + } + if (snapshot.visibility !== "draft" || snapshot.creatorId === identity.id) { + return true; + } + if (params.event !== "session.typing") { + return false; + } + const target = resolveSessionSharingTarget({ + cfg: params.cfg, + sessionKey, + agentId: params.agentId, + }); + return ( + target !== null && + canManageSessionSharing(resolveSessionSharingRole({ client: params.client, target })) + ); + }); + if (!visible || params.event !== "session.suggestion") { + return visible; + } + const authorId = + params.payload && typeof params.payload === "object" + ? (params.payload as { suggestion?: { author?: { id?: unknown } } }).suggestion?.author?.id + : undefined; + if (authorId === identity.id) { return true; } return params.sessionKeys.every((sessionKey) => { - const snapshot = loadSharingSnapshot(params.cfg, sessionKey, params.agentId); + const target = resolveSessionSharingTarget({ + cfg: params.cfg, + sessionKey, + agentId: params.agentId, + }); return ( - !snapshot.incognito && (snapshot.visibility !== "draft" || snapshot.creatorId === identity.id) + target !== null && resolveSessionSharingRole({ client: params.client, target }) !== "viewer" ); }); } diff --git a/src/state/openclaw-agent-db.generated.d.ts b/src/state/openclaw-agent-db.generated.d.ts index 2e35d6f3765a..d38e107612c9 100644 --- a/src/state/openclaw-agent-db.generated.d.ts +++ b/src/state/openclaw-agent-db.generated.d.ts @@ -218,6 +218,19 @@ export interface SessionNodes { updated_at: number; } +export interface SessionSuggestions { + author_id: string; + author_label: string | null; + created_at: number; + dispatch_resolution: string | null; + dispatch_started_at: number | null; + dispatch_token: string | null; + id: string; + session_key: string; + state: string; + text: string; +} + export interface SessionTranscriptActiveEvents { active_position: number; event_seq: number; @@ -363,6 +376,7 @@ export interface DB { session_conversations: SessionConversations; session_members: SessionMembers; session_nodes: SessionNodes; + session_suggestions: SessionSuggestions; session_transcript_active_events: SessionTranscriptActiveEvents; session_transcript_fts: SessionTranscriptFts; session_transcript_fts_config: SessionTranscriptFtsConfig; diff --git a/src/state/openclaw-agent-schema.generated.ts b/src/state/openclaw-agent-schema.generated.ts index f5ce2dc4e551..0539152f38b8 100644 --- a/src/state/openclaw-agent-schema.generated.ts +++ b/src/state/openclaw-agent-schema.generated.ts @@ -220,6 +220,31 @@ CREATE TABLE IF NOT EXISTS session_members ( CREATE INDEX IF NOT EXISTS idx_agent_session_members_identity ON session_members(identity_id, session_key); + +CREATE TABLE IF NOT EXISTS session_suggestions ( + id TEXT PRIMARY KEY, + session_key TEXT NOT NULL, + author_id TEXT NOT NULL, + author_label TEXT, + text TEXT NOT NULL, + created_at INTEGER NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'accepted', 'dismissed')), + dispatch_token TEXT, + dispatch_started_at INTEGER, + dispatch_resolution TEXT CHECK (dispatch_resolution IN ('send', 'queue', 'edit', 'dismiss')), + CHECK ( + (dispatch_token IS NULL AND dispatch_started_at IS NULL AND dispatch_resolution IS NULL) + OR (dispatch_token IS NOT NULL AND dispatch_started_at IS NOT NULL AND dispatch_resolution IS NOT NULL) + ), + FOREIGN KEY (session_key) REFERENCES session_nodes(session_key) ON DELETE CASCADE +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_agent_session_suggestions_session_state_created + ON session_suggestions(session_key, state, created_at, id); + +CREATE INDEX IF NOT EXISTS idx_agent_session_suggestions_author_created + ON session_suggestions(author_id, created_at, id); + CREATE TABLE IF NOT EXISTS board_tabs ( session_key TEXT NOT NULL, tab_id TEXT NOT NULL, diff --git a/src/state/openclaw-agent-schema.sql b/src/state/openclaw-agent-schema.sql index 6e0a6fcb05a8..a71c1173cd90 100644 --- a/src/state/openclaw-agent-schema.sql +++ b/src/state/openclaw-agent-schema.sql @@ -215,6 +215,31 @@ CREATE TABLE IF NOT EXISTS session_members ( CREATE INDEX IF NOT EXISTS idx_agent_session_members_identity ON session_members(identity_id, session_key); + +CREATE TABLE IF NOT EXISTS session_suggestions ( + id TEXT PRIMARY KEY, + session_key TEXT NOT NULL, + author_id TEXT NOT NULL, + author_label TEXT, + text TEXT NOT NULL, + created_at INTEGER NOT NULL, + state TEXT NOT NULL CHECK (state IN ('pending', 'accepted', 'dismissed')), + dispatch_token TEXT, + dispatch_started_at INTEGER, + dispatch_resolution TEXT CHECK (dispatch_resolution IN ('send', 'queue', 'edit', 'dismiss')), + CHECK ( + (dispatch_token IS NULL AND dispatch_started_at IS NULL AND dispatch_resolution IS NULL) + OR (dispatch_token IS NOT NULL AND dispatch_started_at IS NOT NULL AND dispatch_resolution IS NOT NULL) + ), + FOREIGN KEY (session_key) REFERENCES session_nodes(session_key) ON DELETE CASCADE +) STRICT; + +CREATE INDEX IF NOT EXISTS idx_agent_session_suggestions_session_state_created + ON session_suggestions(session_key, state, created_at, id); + +CREATE INDEX IF NOT EXISTS idx_agent_session_suggestions_author_created + ON session_suggestions(author_id, created_at, id); + CREATE TABLE IF NOT EXISTS board_tabs ( session_key TEXT NOT NULL, tab_id TEXT NOT NULL, diff --git a/ui/src/components/viewer-facepile.test.ts b/ui/src/components/viewer-facepile.test.ts index c763e801adfb..30cea3499945 100644 --- a/ui/src/components/viewer-facepile.test.ts +++ b/ui/src/components/viewer-facepile.test.ts @@ -3,7 +3,11 @@ import { afterEach, expect, it, vi } from "vitest"; import type { ControlUiBuildInfo } from "../build-info.ts"; import { setAvatarGatewayOrigin } from "../lib/identity-avatar.ts"; -import { hasSessionPresenceViewers, type PresenceViewer } from "./viewer-facepile.ts"; +import { + hasMultiplePresenceIdentities, + hasSessionPresenceViewers, + type PresenceViewer, +} from "./viewer-facepile.ts"; type ViewerAvatarElement = HTMLElement & { user: PresenceViewer | null; @@ -184,3 +188,26 @@ it("detects only other viewers watching the requested session", () => { expect(hasSessionPresenceViewers(payload, "self-instance", "agent:main:active")).toBe(false); expect(hasSessionPresenceViewers(payload, "self-instance", "agent:main:other")).toBe(true); }); + +it("keeps collaboration UI dormant for a solo identity", () => { + const solo = { + presence: [ + { + instanceId: "self-instance", + user: { id: "self", name: "Self" }, + watchedSessions: ["agent:main:active"], + }, + { + instanceId: "second-tab", + user: { id: "self", name: "Self" }, + watchedSessions: ["agent:main:active"], + }, + ], + }; + expect(hasMultiplePresenceIdentities(solo)).toBe(false); + expect( + hasMultiplePresenceIdentities({ + presence: [...solo.presence, { user: { id: "alice" }, watchedSessions: [] }], + }), + ).toBe(true); +}); diff --git a/ui/src/components/viewer-facepile.ts b/ui/src/components/viewer-facepile.ts index 90568a9fe8f3..77974c3ecd38 100644 --- a/ui/src/components/viewer-facepile.ts +++ b/ui/src/components/viewer-facepile.ts @@ -102,6 +102,10 @@ export function hasSessionPresenceViewers( ); } +export function hasMultiplePresenceIdentities(value: unknown): boolean { + return projectPresencePayload(value).users.length >= 2; +} + export function presenceViewerLabel(user: PresenceViewer): string { return user.name ?? user.email ?? user.id; } diff --git a/ui/src/e2e/session-suggestions.e2e.test.ts b/ui/src/e2e/session-suggestions.e2e.test.ts new file mode 100644 index 000000000000..470c6e174717 --- /dev/null +++ b/ui/src/e2e/session-suggestions.e2e.test.ts @@ -0,0 +1,232 @@ +// Control UI E2E tests cover suggestion queue and solo-dormancy behavior. +import fs from "node:fs/promises"; +import path from "node:path"; +import { chromium, expect, type Browser, type Page } from "playwright/test"; +import { afterAll, beforeAll, describe, it } from "vitest"; +import { + canRunPlaywrightChromium, + installMockGateway, + resolvePlaywrightChromiumExecutablePath, + startControlUiE2eServer, + type ControlUiE2eServer, +} from "../test-helpers/control-ui-e2e.ts"; + +const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); +const chromiumAvailable = canRunPlaywrightChromium(chromiumExecutablePath); +const allowMissingChromium = process.env.OPENCLAW_UI_E2E_ALLOW_MISSING_CHROMIUM === "1"; +const describeControlUiE2e = chromiumAvailable || !allowMissingChromium ? describe : describe.skip; +const sessionKey = "agent:main:main"; + +let browser: Browser; +let server: ControlUiE2eServer; + +function artifactDir(): string | undefined { + return process.env.OPENCLAW_CONTROL_UI_E2E_ARTIFACT_DIR?.trim() || undefined; +} + +async function contextAndPage() { + const output = artifactDir(); + if (output) { + await fs.mkdir(output, { recursive: true }); + } + const context = await browser.newContext({ + viewport: { height: 760, width: 1180 }, + ...(output ? { recordVideo: { dir: output, size: { height: 760, width: 1180 } } } : {}), + }); + return { context, page: await context.newPage() }; +} + +async function screenshot(page: Page, name: string) { + const output = artifactDir(); + if (output) { + await page.screenshot({ animations: "disabled", path: path.join(output, name) }); + } +} + +function sessionRow(sharingRole: "owner" | "viewer") { + return { + count: 1, + defaults: { contextTokens: null, model: "gpt-5.5", modelProvider: "openai" }, + path: "", + sessions: [ + { + key: sessionKey, + kind: "direct", + label: "Main", + sessionId: "session-main", + status: "done", + updatedAt: 1, + visibility: "suggest", + sharingRole, + }, + ], + ts: 1, + }; +} + +const featureMethods = [ + "chat.metadata", + "chat.startup", + "session.suggestions.add", + "session.suggestions.list", + "session.suggestions.resolve", + "session.typing", +]; + +describeControlUiE2e("Control UI session suggestions", () => { + beforeAll(async () => { + server = await startControlUiE2eServer(); + browser = await chromium.launch({ executablePath: chromiumExecutablePath }); + }); + + afterAll(async () => { + await browser?.close(); + await server?.close(); + }); + + it("submits a viewer draft as a suggestion and shows its pending state", async () => { + const { context, page } = await contextAndPage(); + const suggestion = { + id: "suggestion-1", + sessionKey, + agentId: "main", + author: { type: "human", id: "alice", label: "Alice" }, + text: "Try the focused change", + createdAt: 1, + state: "pending", + }; + const gateway = await installMockGateway(page, { + featureMethods, + presenceUsers: [ + { + self: true, + id: "alice", + name: "Alice", + watchedSessions: ["main", sessionKey], + }, + { id: "owner", name: "Owner", watchedSessions: ["main", sessionKey] }, + ], + methodResponses: { + "sessions.list": sessionRow("viewer"), + "session.suggestions.list": { suggestions: [], role: "viewer" }, + "session.suggestions.add": { suggestion }, + "session.typing": { ok: true, broadcast: true }, + }, + }); + + await page.goto(`${server.baseUrl}chat?session=${encodeURIComponent(sessionKey)}`); + const composer = page.locator(".agent-chat__composer-combobox textarea"); + await gateway.waitForRequest("session.suggestions.list"); + await expect(composer).toBeEnabled(); + await gateway.emitGatewayEvent("session.typing", { + sessionKey: "main", + sessionId: "session-main", + agentId: "main", + actor: { type: "human", id: "owner", label: "Owner" }, + typing: true, + ts: Date.now(), + }); + await expect(page.locator(".agent-chat__typing-indicator")).toHaveText("Owner is typing…"); + await composer.fill("Try the focused change"); + const typing = await gateway.waitForRequest("session.typing"); + expect(typing.params).toMatchObject({ sessionId: "session-main" }); + await page.getByRole("button", { name: "Suggest message" }).click(); + const add = await gateway.waitForRequest("session.suggestions.add"); + expect(add.params).toMatchObject({ sessionKey: "main", text: "Try the focused change" }); + await expect(page.locator(".session-suggestion__state")).toHaveText("Pending"); + await expect(page.locator(".session-suggestion__text")).toHaveText("Try the focused change"); + await screenshot(page, "viewer-pending.png"); + await context.close(); + }); + + it("shows four owner actions and loads edit into the composer", async () => { + const { context, page } = await contextAndPage(); + const suggestion = { + id: "suggestion-2", + sessionKey, + agentId: "main", + author: { type: "human", id: "alice", label: "Alice" }, + text: "Please edit this first", + createdAt: 2, + state: "pending", + }; + const gateway = await installMockGateway(page, { + deferredMethods: ["session.suggestions.resolve"], + featureMethods, + presenceUsers: [ + { self: true, id: "owner", name: "Owner", watchedSessions: ["main", sessionKey] }, + { id: "alice", name: "Alice", watchedSessions: ["main", sessionKey] }, + ], + methodResponses: { + "sessions.list": sessionRow("owner"), + "session.suggestions.list": { suggestions: [suggestion], role: "owner" }, + }, + }); + + await page.goto(`${server.baseUrl}chat?session=${encodeURIComponent(sessionKey)}`); + const row = page.locator(".session-suggestion"); + await expect(row).toBeVisible(); + await expect(row.locator("button")).toHaveCount(4); + expect( + await row + .locator("button") + .evaluateAll((buttons) => buttons.map((button) => button.getAttribute("aria-label"))), + ).toEqual([ + "Send Alice's suggestion now", + "Queue Alice's suggestion", + "Edit Alice's suggestion", + "Dismiss Alice's suggestion", + ]); + await page.getByRole("button", { name: "Edit Alice's suggestion" }).click(); + await gateway.waitForRequest("session.suggestions.resolve"); + const composer = page.locator(".agent-chat__composer-combobox textarea"); + await expect(composer).toHaveValue("Please edit this first"); + await composer.fill("A newer owner draft"); + await gateway.resolveDeferred("session.suggestions.resolve", { + suggestion: { ...suggestion, state: "accepted" }, + }); + await expect(composer).toHaveValue("A newer owner draft"); + await screenshot(page, "owner-edit.png"); + await context.close(); + }); + + it("keeps suggestion and typing UI dormant with one identity", async () => { + const { context, page } = await contextAndPage(); + const gateway = await installMockGateway(page, { + featureMethods, + presenceUsers: [ + { + self: true, + id: "alice", + name: "Alice", + watchedSessions: ["main", sessionKey], + }, + ], + methodResponses: { "sessions.list": sessionRow("viewer") }, + }); + + await page.goto(`${server.baseUrl}chat?session=${encodeURIComponent(sessionKey)}`); + await expect(page.locator(".agent-chat__composer-combobox textarea")).toBeDisabled(); + await expect(page.getByRole("button", { name: "Suggest message" })).toHaveCount(0); + await expect(page.locator(".agent-chat__typing-indicator")).toHaveCount(0); + expect(await gateway.getRequests("session.suggestions.list")).toEqual([]); + await screenshot(page, "solo-dormant.png"); + await context.close(); + }); + + it("keeps older gateways read-only when suggestion RPCs are not advertised", async () => { + const { context, page } = await contextAndPage(); + await installMockGateway(page, { + presenceUsers: [ + { self: true, id: "alice", name: "Alice", watchedSessions: ["main"] }, + { id: "owner", name: "Owner", watchedSessions: ["main"] }, + ], + methodResponses: { "sessions.list": sessionRow("viewer") }, + }); + + await page.goto(`${server.baseUrl}chat?session=${encodeURIComponent(sessionKey)}`); + await expect(page.locator(".agent-chat__composer-combobox textarea")).toBeDisabled(); + await expect(page.getByRole("button", { name: "Suggest message" })).toHaveCount(0); + await context.close(); + }); +}); diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index afbe0d799d3e..9f48b1a0ddbe 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -3787,6 +3787,22 @@ export const en: TranslationMap = { noPeople: "No paired people found.", readOnlyNotice: "Only the thread owner and members can act in this thread.", }, + sessionSuggestions: { + suggest: "Suggest", + suggestMessage: "Suggest message", + attachmentsUnsupported: "Remove attachments before submitting a text suggestion.", + sendNow: "Send {author}'s suggestion now", + queue: "Queue {author}'s suggestion", + edit: "Edit {author}'s suggestion", + dismiss: "Dismiss {author}'s suggestion", + typing: "{name} is typing…", + typingMany: "{names} are typing…", + state: { + pending: "Pending", + accepted: "Accepted", + dismissed: "Dismissed", + }, + }, loadOlder: "Load older", sessionHeader: { renameTooltip: "Rename thread", diff --git a/ui/src/pages/chat/chat-composer.test.ts b/ui/src/pages/chat/chat-composer.test.ts index 347ffb6e8008..f6a16bd6580d 100644 --- a/ui/src/pages/chat/chat-composer.test.ts +++ b/ui/src/pages/chat/chat-composer.test.ts @@ -51,6 +51,36 @@ function renderComposer(overrides: Partial = {}) { return { container, props: composerProps }; } +describe("suggestion composer", () => { + it("labels the send action as Suggest and emits ephemeral typing state", () => { + const onTypingChange = vi.fn(); + const view = renderComposer({ + suggestionComposer: true, + draft: "", + onTypingChange, + }); + expect(view.container.querySelector(".agent-chat__control-label")?.textContent).toContain( + "Suggest", + ); + expect( + view.container.querySelector('button[aria-label="Add attachment"]') + ?.disabled, + ).toBe(true); + + const textarea = view.container.querySelector("textarea"); + expect(textarea).not.toBeNull(); + if (!textarea) { + return; + } + textarea.value = "hello"; + textarea.dispatchEvent(new InputEvent("beforeinput", { bubbles: true })); + textarea.dispatchEvent(new InputEvent("input", { bubbles: true })); + textarea.dispatchEvent(new FocusEvent("blur", { bubbles: true })); + expect(onTypingChange).toHaveBeenNthCalledWith(1, true); + expect(onTypingChange).toHaveBeenLastCalledWith(false); + }); +}); + function questionPrompt(id: string, question: string): QuestionPrompt { return { id, diff --git a/ui/src/pages/chat/chat-pane-lifecycle.test.ts b/ui/src/pages/chat/chat-pane-lifecycle.test.ts index 6f8f9cc2588e..4b08c9db2c8c 100644 --- a/ui/src/pages/chat/chat-pane-lifecycle.test.ts +++ b/ui/src/pages/chat/chat-pane-lifecycle.test.ts @@ -4,7 +4,12 @@ // The non-isolated runner resets modules between files but preserves customElements. // A dedicated jsdom context keeps the registered pane class on this file's module graph. import { afterEach, describe, expect, it, vi } from "vitest"; -import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import type { + SessionSuggestion, + SessionSuggestionsListResult, +} from "../../../../packages/gateway-protocol/src/index.js"; +import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gateway.ts"; +import type { GatewaySessionRow } from "../../api/types.ts"; import type { SessionCapability } from "../../lib/sessions/index.ts"; import { createTestChatPane } from "./chat-pane.test-support.ts"; import { @@ -16,6 +21,555 @@ import * as chatThread from "./components/chat-thread.ts"; const SKIP_REWIND_CONFIRM_PREFERENCE = "openclaw:skip-rewind-confirm"; const confirmationOwners = new Set(); +function createDeferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { promise, reject, resolve }; +} + +describe("chat pane session suggestion lifecycle", () => { + it("does not let a stale add completion clear a newer session operation", async () => { + const first = createDeferred<{ suggestion: SessionSuggestion }>(); + const second = createDeferred<{ suggestion: SessionSuggestion }>(); + const client = { + request: vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise), + } as unknown as GatewayBrowserClient; + const sessions = {} as SessionCapability; + const { pane, state } = createTestChatPane({ client, sessions }); + state.chatAttachments = []; + pane.presencePayload = { + presence: [{ user: { id: "owner" } }, { user: { id: "alice" } }], + }; + const row = (id: string, text: string): SessionSuggestion => ({ + id, + sessionKey: state.sessionKey, + agentId: "main", + author: { type: "human", id: "alice", label: "Alice" }, + text, + createdAt: 1, + state: "pending", + }); + + state.chatMessage = "first"; + const firstPending = pane.addCurrentSessionSuggestion(); + pane.resetSessionSuggestions(); + state.chatMessage = "second"; + const secondPending = pane.addCurrentSessionSuggestion(); + + first.resolve({ suggestion: row("first", "first") }); + await firstPending; + expect(pane.sessionSuggestionAddOperation).toBeDefined(); + expect(pane.sessionSuggestions.some((suggestion) => suggestion.id === "first")).toBe(false); + second.resolve({ suggestion: row("second", "second") }); + await secondPending; + expect(pane.sessionSuggestionAddOperation).toBeUndefined(); + }); + + it("rejects suggestion submission while attachments remain", async () => { + const request = vi.fn(); + const client = { request } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ + client, + sessions: {} as SessionCapability, + }); + pane.presencePayload = { + presence: [{ user: { id: "owner" } }, { user: { id: "alice" } }], + }; + state.chatMessage = "text only"; + state.chatAttachments = [{ id: "attachment" } as never]; + + await pane.addCurrentSessionSuggestion(); + expect(request).not.toHaveBeenCalled(); + expect(state.chatError).toContain("Remove attachments"); + }); + + it("coalesces overlapping refreshes and applies the event-invalidated follow-up", async () => { + const firstList = createDeferred(); + const secondList = createDeferred(); + const request = vi + .fn() + .mockReturnValueOnce(firstList.promise) + .mockReturnValueOnce(secondList.promise); + const client = { + request, + } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ + client, + sessions: {} as SessionCapability, + }); + pane.presencePayload = { + presence: [{ user: { id: "owner" } }, { user: { id: "alice" } }], + }; + state.sessionsResult = { + count: 1, + path: "", + sessions: [ + { + key: state.sessionKey, + kind: "direct", + updatedAt: 1, + visibility: "suggest", + sharingRole: "viewer", + }, + ], + } as never; + const eventSuggestion: SessionSuggestion = { + id: "event", + sessionKey: state.sessionKey, + agentId: "main", + author: { type: "human", id: "alice", label: "Alice" }, + text: "new event", + createdAt: 1, + state: "pending", + }; + const existingSuggestion: SessionSuggestion = { + ...eventSuggestion, + id: "existing", + text: "already queued", + createdAt: 0, + }; + + const pending = pane.refreshSessionSuggestions(); + const overlapping = pane.refreshSessionSuggestions(); + expect(request).toHaveBeenCalledTimes(1); + pane.handleSessionSuggestionEvent({ action: "added", suggestion: eventSuggestion }); + firstList.resolve({ suggestions: [existingSuggestion], role: "viewer" }); + await Promise.all([pending, overlapping]); + await vi.waitFor(() => expect(request).toHaveBeenCalledTimes(2)); + secondList.resolve({ suggestions: [existingSuggestion, eventSuggestion], role: "viewer" }); + await vi.waitFor(() => + expect(pane.sessionSuggestions).toEqual([existingSuggestion, eventSuggestion]), + ); + expect(pane.sessionSuggestionRole).toBe("viewer"); + await Promise.resolve(); + expect(request).toHaveBeenCalledTimes(2); + }); + + it("clears cached suggestions until a rotated session instance list resolves", async () => { + const listed = createDeferred(); + const request = vi.fn(() => listed.promise); + const client = { request } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ + client, + sessions: {} as SessionCapability, + }); + pane.presencePayload = { + presence: [{ user: { id: "owner" } }, { user: { id: "alice" } }], + }; + const row = (sessionId: string): GatewaySessionRow => + ({ + key: state.sessionKey, + kind: "direct", + sessionId, + updatedAt: 1, + visibility: "suggest", + sharingRole: "owner", + }) as GatewaySessionRow; + const stale: SessionSuggestion = { + id: "stale-instance", + sessionKey: state.sessionKey, + agentId: "main", + author: { type: "human", id: "alice", label: "Alice" }, + text: "old instance", + createdAt: 1, + state: "pending", + }; + const fresh: SessionSuggestion = { + ...stale, + id: "fresh-instance", + text: "new instance", + }; + + pane.syncSessionSuggestionTarget("main", row("session-a")); + await pane.refreshSessionSuggestions(); + pane.sessionSuggestions = [stale]; + state.sessionsResultAgentId = "main"; + state.sessionsResult = { + count: 1, + path: "", + sessions: [row("session-b")], + } as never; + + pane.syncSessionSuggestionTarget("main", row("session-b")); + + expect(pane.sessionSuggestions).toEqual([]); + expect(request).toHaveBeenCalledTimes(1); + listed.resolve({ suggestions: [fresh], role: "owner" }); + await vi.waitFor(() => expect(pane.sessionSuggestions).toEqual([fresh])); + }); + + it("clears displayed typing actors when the session instance rotates", () => { + const client = { request: vi.fn() } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ + client, + sessions: {} as SessionCapability, + }); + pane.presencePayload = { + presence: [{ user: { id: "owner" } }, { user: { id: "alice" } }], + }; + const row = (sessionId: string): GatewaySessionRow => + ({ + key: state.sessionKey, + kind: "direct", + sessionId, + updatedAt: 1, + visibility: "suggest", + sharingRole: "owner", + }) as GatewaySessionRow; + const sessionA = row("session-a"); + state.sessionsResult = { + count: 1, + path: "", + sessions: [sessionA], + } as never; + pane.syncSessionSuggestionTarget("main", sessionA); + pane.handleSessionTypingEvent({ + sessionKey: state.sessionKey, + sessionId: "session-a", + agentId: "main", + actor: { type: "human", id: "alice", label: "Alice" }, + typing: true, + ts: 1, + }); + expect(pane.typingActors.size).toBe(1); + + const sessionB = row("session-b"); + state.sessionsResult = { + count: 1, + path: "", + sessions: [sessionB], + } as never; + pane.syncSessionSuggestionTarget("main", sessionB); + + expect(pane.typingActors.size).toBe(0); + }); + + it("preserves an author's resolved event while its role is still loading", () => { + const client = { request: vi.fn() } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ + client, + sessions: {} as SessionCapability, + }); + pane.presencePayload = { + presence: [{ user: { id: "owner" } }, { user: { id: "alice" } }], + }; + pane.context.gateway.snapshot.selfUser = { id: "alice" } as never; + const pending: SessionSuggestion = { + id: "mine", + sessionKey: state.sessionKey, + agentId: "main", + author: { type: "human", id: "alice", label: "Alice" }, + text: "my suggestion", + createdAt: 1, + state: "pending", + }; + pane.sessionSuggestions = [pending]; + + pane.handleSessionSuggestionEvent({ + action: "resolved", + suggestion: { ...pending, state: "accepted" }, + }); + expect(pane.sessionSuggestions).toEqual([{ ...pending, state: "accepted" }]); + }); + + it("keeps an owner's self-authored resolved suggestion through the following list", async () => { + const listed = createDeferred(); + const resolvedResponse = createDeferred<{ suggestion: SessionSuggestion }>(); + const request = vi.fn((method: string) => { + if (method === "session.suggestions.resolve") { + return resolvedResponse.promise; + } + if (method === "session.suggestions.list") { + return listed.promise; + } + throw new Error(`unexpected method: ${method}`); + }); + const client = { request } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ + client, + sessions: {} as SessionCapability, + }); + pane.presencePayload = { + presence: [{ user: { id: "owner" } }, { user: { id: "alice" } }], + }; + pane.context.gateway.snapshot.selfUser = { id: "owner" } as never; + state.sessionsResult = { + count: 1, + path: "", + sessions: [ + { + key: state.sessionKey, + kind: "direct", + updatedAt: 1, + visibility: "suggest", + sharingRole: "owner", + }, + ], + } as never; + const pending: SessionSuggestion = { + id: "owner-suggestion", + sessionKey: state.sessionKey, + agentId: "main", + author: { type: "human", id: "owner", label: "Owner" }, + text: "my resolved suggestion", + createdAt: 1, + state: "pending", + }; + const resolved = { ...pending, state: "accepted" as const }; + pane.sessionSuggestionRole = "owner"; + pane.sessionSuggestions = [pending]; + + const resolving = pane.resolveCurrentSessionSuggestion(pending, "queue"); + expect(request).toHaveBeenCalledTimes(1); + pane.handleSessionSuggestionEvent({ action: "resolved", suggestion: resolved }); + expect(pane.sessionSuggestions).toEqual([resolved]); + expect(request).toHaveBeenCalledTimes(2); + + listed.resolve({ suggestions: [resolved], role: "owner" }); + await vi.waitFor(() => expect(pane.sessionSuggestions).toEqual([resolved])); + resolvedResponse.resolve({ suggestion: resolved }); + await resolving; + + expect(pane.sessionSuggestions).toEqual([resolved]); + expect(pane.sessionSuggestionRole).toBe("owner"); + }); + + it("drops a resolve completion after the same session key rotates instances", async () => { + const response = createDeferred<{ suggestion: SessionSuggestion }>(); + const client = { + request: vi.fn(() => response.promise), + } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ + client, + sessions: {} as SessionCapability, + }); + const session = (sessionId: string): GatewaySessionRow => + ({ + key: state.sessionKey, + kind: "direct", + sessionId, + updatedAt: 1, + visibility: "suggest", + sharingRole: "owner", + }) as GatewaySessionRow; + const suggestion: SessionSuggestion = { + id: "old-instance-resolution", + sessionKey: state.sessionKey, + agentId: "main", + author: { type: "human", id: "owner", label: "Owner" }, + text: "old instance suggestion", + createdAt: 1, + state: "pending", + }; + pane.context.gateway.snapshot.selfUser = { id: "owner" } as never; + pane.syncSessionSuggestionTarget("main", session("session-a")); + pane.sessionSuggestions = [suggestion]; + + const resolving = pane.resolveCurrentSessionSuggestion(suggestion, "queue"); + pane.syncSessionSuggestionTarget("main", session("session-b")); + response.resolve({ suggestion: { ...suggestion, state: "accepted" } }); + await resolving; + + expect(pane.sessionSuggestions).toEqual([]); + expect(state.chatError).toBeNull(); + }); + + it.each(["draft", "shared"] as const)( + "loads an owner's pending suggestions after visibility changes to %s", + async (visibility) => { + const pending: SessionSuggestion = { + id: `pending-${visibility}`, + sessionKey: "agent:main:current", + agentId: "main", + author: { type: "human", id: "alice", label: "Alice" }, + text: "still needs review", + createdAt: 1, + state: "pending", + }; + const request = vi.fn(async () => ({ suggestions: [pending], role: "owner" as const })); + const client = { request } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ + client, + sessions: {} as SessionCapability, + }); + pane.presencePayload = { + presence: [{ user: { id: "owner" } }, { user: { id: "alice" } }], + }; + state.sessionsResult = { + count: 1, + path: "", + sessions: [ + { + key: state.sessionKey, + kind: "direct", + updatedAt: 1, + visibility, + sharingRole: "owner", + }, + ], + } as never; + + await pane.refreshSessionSuggestions(); + + expect(request).toHaveBeenCalledWith( + "session.suggestions.list", + expect.objectContaining({ sessionKey: state.sessionKey }), + ); + expect(pane.sessionSuggestions).toEqual([pending]); + expect(pane.sessionSuggestionRole).toBe("owner"); + }, + ); + + it("does not apply an edit failure after the same session key rotates instances", async () => { + const deferred = createDeferred(); + const client = { + request: vi.fn(() => deferred.promise), + } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ + client, + sessions: {} as SessionCapability, + }); + const suggestion: SessionSuggestion = { + id: "edit", + sessionKey: state.sessionKey, + agentId: "main", + author: { type: "human", id: "alice", label: "Alice" }, + text: "suggested text", + createdAt: 1, + state: "pending", + }; + state.handleChatDraftChange = (next) => { + state.chatMessage = next; + }; + pane.sessionSuggestionTargetSignature = "main\0agent:main:current\0session-a"; + state.chatMessage = "original"; + const pending = pane.resolveCurrentSessionSuggestion(suggestion, "edit"); + pane.sessionSuggestionTargetSignature = "main\0agent:main:current\0session-b"; + pane.resetSessionSuggestions(); + state.chatMessage = "new session draft"; + deferred.reject(new Error("old request failed")); + + await pending; + expect(state.chatMessage).toBe("new session draft"); + expect(state.chatError).not.toBe("old request failed"); + }); + + it("keeps suggested text after an ambiguous edit failure", async () => { + const client = { + request: vi.fn(async () => { + throw new Error("response lost"); + }), + } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ + client, + sessions: {} as SessionCapability, + }); + const suggestion: SessionSuggestion = { + id: "edit-ambiguous", + sessionKey: state.sessionKey, + agentId: "main", + author: { type: "human", id: "alice", label: "Alice" }, + text: "preserve this suggestion", + createdAt: 1, + state: "pending", + }; + state.handleChatDraftChange = (next) => { + state.chatMessage = next; + }; + state.chatMessage = "owner draft"; + + await pane.resolveCurrentSessionSuggestion(suggestion, "edit"); + + expect(state.chatMessage).toBe("preserve this suggestion"); + expect(state.chatError).toBe("response lost"); + }); + + it("restores an untouched owner draft after a definite edit rejection", async () => { + const client = { + request: vi.fn(async () => { + throw new GatewayRequestError({ + code: "INVALID_REQUEST", + message: "suggestion already resolved", + }); + }), + } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ + client, + sessions: {} as SessionCapability, + }); + const suggestion: SessionSuggestion = { + id: "edit-rejected", + sessionKey: state.sessionKey, + agentId: "main", + author: { type: "human", id: "alice", label: "Alice" }, + text: "rejected suggestion", + createdAt: 1, + state: "pending", + }; + state.handleChatDraftChange = (next) => { + state.chatMessage = next; + }; + state.chatMessage = "owner draft"; + + await pane.resolveCurrentSessionSuggestion(suggestion, "edit"); + + expect(state.chatMessage).toBe("owner draft"); + expect(state.chatError).toBe("suggestion already resolved"); + }); + + it("serializes edit resolutions so rejected suggestions cannot snapshot each other", async () => { + const first = createDeferred(); + const second = createDeferred(); + const request = vi.fn().mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise); + const client = { + request, + } as unknown as GatewayBrowserClient; + const { pane, state } = createTestChatPane({ + client, + sessions: {} as SessionCapability, + }); + const suggestion = (id: string, text: string): SessionSuggestion => ({ + id, + sessionKey: state.sessionKey, + agentId: "main", + author: { type: "human", id: "alice", label: "Alice" }, + text, + createdAt: 1, + state: "pending", + }); + state.handleChatDraftChange = (next) => { + state.chatMessage = next; + }; + state.chatMessage = "owner draft"; + + const firstPending = pane.resolveCurrentSessionSuggestion(suggestion("first", "first"), "edit"); + await pane.resolveCurrentSessionSuggestion(suggestion("second", "second"), "edit"); + expect(request).toHaveBeenCalledTimes(1); + expect(state.chatMessage).toBe("first"); + + first.reject( + new GatewayRequestError({ code: "INVALID_REQUEST", message: "first was rejected" }), + ); + await firstPending; + expect(state.chatMessage).toBe("owner draft"); + + const secondPending = pane.resolveCurrentSessionSuggestion( + suggestion("second", "second"), + "edit", + ); + second.reject( + new GatewayRequestError({ code: "INVALID_REQUEST", message: "second was rejected" }), + ); + await secondPending; + expect(request).toHaveBeenCalledTimes(2); + expect(state.chatMessage).toBe("owner draft"); + }); +}); + function createConfirmationOwner() { const owner = document.createElement("span"); owner.className = "chat-delete-wrap"; diff --git a/ui/src/pages/chat/chat-pane.test-support.ts b/ui/src/pages/chat/chat-pane.test-support.ts index 606ab3ebded9..be11a0fa72b6 100644 --- a/ui/src/pages/chat/chat-pane.test-support.ts +++ b/ui/src/pages/chat/chat-pane.test-support.ts @@ -1,6 +1,9 @@ import type { TemplateResult } from "lit"; import { vi } from "vitest"; import type { + SessionSuggestion, + SessionSuggestionEvent, + SessionTypingEvent, SessionCatalogSession, SessionCatalogTranscriptItem, TaskSuggestion, @@ -39,6 +42,24 @@ export type TestChatPane = HTMLElement & { refreshSessionPullRequests: (options?: { refresh?: boolean }) => Promise; sessionPullRequests: ControlUiSessionPullRequest[]; taskSuggestions: TaskSuggestion[]; + presencePayload?: { presence: unknown[] }; + sessionSuggestionAddOperation: symbol | undefined; + sessionSuggestionRole: "admin" | "owner" | "member" | "viewer" | undefined; + addCurrentSessionSuggestion: () => Promise; + resetSessionSuggestions: () => void; + sessionSuggestions: SessionSuggestion[]; + sessionSuggestionsRequestVersion: number; + sessionSuggestionsRefreshPromise: Promise | undefined; + sessionSuggestionTargetSignature: string; + syncSessionSuggestionTarget: (agentId: string, session: GatewaySessionRow | undefined) => void; + handleSessionSuggestionEvent: (event: SessionSuggestionEvent) => void; + handleSessionTypingEvent: (event: SessionTypingEvent) => void; + typingActors: Map; + refreshSessionSuggestions: () => Promise; + resolveCurrentSessionSuggestion: ( + suggestion: SessionSuggestion, + resolution: "send" | "queue" | "edit" | "dismiss", + ) => Promise; onPaneSessionChange?: (paneId: string, sessionKey: string) => void; sessionKey: string; switchPaneSession: (nextSessionKey: string) => void; @@ -97,7 +118,11 @@ export function createSessionContext( snapshot: { client, phase: "connected" as const, - hello: { features: { methods: ["taskSuggestions.list"] } }, + hello: { + features: { + methods: ["taskSuggestions.list", "session.suggestions.list"], + }, + }, }, }, agents: { state: { agentsList: null } }, diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index e881598e750f..9a84aa73b58b 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -11,6 +11,12 @@ import { type SessionDiscussionInfo, type SessionDiscussionState, type SessionObserverDigest, + type SessionSharingRole, + type SessionSuggestion, + type SessionSuggestionEvent, + type SessionSuggestionResolution, + type SessionSuggestionsListResult, + type SessionTypingEvent, type SessionsCatalogContinueResult, type SessionsCatalogReadResult, type SessionsFilesRevealResult, @@ -27,7 +33,7 @@ import type { ControlUiSessionPullRequest, ControlUiSessionPullRequests, } from "../../../../src/gateway/control-ui-contract.js"; -import type { GatewayBrowserClient } from "../../api/gateway.ts"; +import { GatewayRequestError, type GatewayBrowserClient } from "../../api/gateway.ts"; import type { GatewaySessionRow, SessionMembersListResult, @@ -74,7 +80,10 @@ import { createDockPanelLayout } from "../../components/dock-panel-layout.ts"; import { icons } from "../../components/icons.ts"; import { listSessionCreators } from "../../components/session-owner-chip.ts"; import { isCloudWorkerPlacementState } from "../../components/session-row-badges.ts"; -import { hasSessionPresenceViewers } from "../../components/viewer-facepile.ts"; +import { + hasMultiplePresenceIdentities, + hasSessionPresenceViewers, +} from "../../components/viewer-facepile.ts"; import { t } from "../../i18n/index.ts"; import { resolveBoardChatLayoutWidth } from "../../lib/board/chat-layout.ts"; import { @@ -659,6 +668,18 @@ class ChatPane extends OpenClawLightDomElement { private readonly taskSuggestionBusyIds = new Set(); private readonly taskSuggestionOperations = new Map(); private taskSuggestionsRequestVersion = 0; + private sessionSuggestions: SessionSuggestion[] = []; + private sessionSuggestionRole: SessionSharingRole | undefined; + private readonly sessionSuggestionBusyIds = new Set(); + private sessionSuggestionsRequestVersion = 0; + private sessionSuggestionsRefreshPromise: Promise | undefined; + private sessionSuggestionsRefreshVersion: number | undefined; + private sessionSuggestionsRefreshQueued = false; + private sessionSuggestionTargetSignature = ""; + private sessionSuggestionAddOperation: symbol | undefined; + private sessionSuggestionEditOperation: symbol | undefined; + private readonly typingActors = new Map(); + private readonly typingTimers = new Map(); private sessionPullRequests: ControlUiSessionPullRequest[] = []; private sessionPullRequestsBranch: ControlUiSessionBranch | undefined; private sessionPullRequestsRateLimited = false; @@ -747,6 +768,413 @@ class ChatPane extends OpenClawLightDomElement { ); } + private hasMultipleIdentities(): boolean { + return hasMultiplePresenceIdentities(this.presencePayload); + } + + private sessionSuggestionMatchesCurrentSession(suggestion: SessionSuggestion): boolean { + const state = this.state; + return Boolean( + state?.connected && + uiSessionEventMatches( + { + agentsList: this.context.agents.state.agentsList, + hello: this.context.gateway.snapshot.hello, + sessionKey: state.sessionKey, + }, + suggestion.sessionKey, + suggestion.agentId, + ), + ); + } + + private isCurrentSessionArchived(state: ChatPageHost): boolean { + return ( + state.selectedChatSessionArchived || + state.sessionsResult?.sessions.some( + (row) => row.archived === true && areUiSessionKeysEquivalent(row.key, state.sessionKey), + ) === true + ); + } + + private resetSessionSuggestions(): void { + this.sessionSuggestionsRequestVersion += 1; + this.sessionSuggestionsRefreshQueued = false; + this.sessionSuggestions = []; + this.sessionSuggestionRole = undefined; + this.sessionSuggestionBusyIds.clear(); + this.sessionSuggestionAddOperation = undefined; + this.sessionSuggestionEditOperation = undefined; + } + + private syncSessionSuggestionTarget( + agentId: string, + session: GatewaySessionRow | undefined, + ): void { + const signature = session + ? `${agentId}\0${session.key}\0${session.sessionId ?? ""}\0${session.visibility ?? "shared"}\0${session.sharingRole ?? "owner"}` + : ""; + if (signature === this.sessionSuggestionTargetSignature) { + return; + } + this.sessionSuggestionTargetSignature = signature; + this.resetSessionSuggestions(); + this.clearTypingActors(); + void this.refreshSessionSuggestions(); + } + + private refreshSessionSuggestions(): Promise { + if (this.sessionSuggestionsRefreshPromise) { + if (this.sessionSuggestionsRefreshVersion !== this.sessionSuggestionsRequestVersion) { + this.sessionSuggestionsRefreshQueued = true; + } + return this.sessionSuggestionsRefreshPromise; + } + const requestVersion = ++this.sessionSuggestionsRequestVersion; + this.sessionSuggestionsRefreshVersion = requestVersion; + const refresh = this.loadSessionSuggestions(requestVersion); + const tracked = refresh.finally(() => { + if (this.sessionSuggestionsRefreshPromise !== tracked) { + return; + } + this.sessionSuggestionsRefreshPromise = undefined; + this.sessionSuggestionsRefreshVersion = undefined; + if (this.sessionSuggestionsRefreshQueued) { + this.sessionSuggestionsRefreshQueued = false; + void this.refreshSessionSuggestions(); + } + }); + this.sessionSuggestionsRefreshPromise = tracked; + return tracked; + } + + private async loadSessionSuggestions(requestVersion: number): Promise { + const targetSignature = this.sessionSuggestionTargetSignature; + const scope = this.captureConnectionScope(); + const row = scope?.state.sessionsResult?.sessions.find((candidate) => + areUiSessionKeysEquivalent(candidate.key, scope.state.sessionKey), + ); + // Solo dormancy intentionally hides persisted rows too; when a second identity + // returns, the presence transition below triggers a fresh authoritative list. + if ( + !scope || + !row || + !this.hasMultipleIdentities() || + !isGatewayMethodAdvertised(scope.context.gateway.snapshot, "session.suggestions.list") + ) { + this.sessionSuggestions = []; + this.sessionSuggestionRole = undefined; + this.requestUpdate(); + return; + } + const sessionKey = scope.state.sessionKey; + try { + const result = await scope.client.request( + "session.suggestions.list", + { + sessionKey, + ...scopedAgentParamsForSession(scope.state, sessionKey), + }, + ); + if (!this.isConnectionScopeCurrent(scope) || scope.state.sessionKey !== sessionKey) { + return; + } + if ( + requestVersion !== this.sessionSuggestionsRequestVersion || + targetSignature !== this.sessionSuggestionTargetSignature + ) { + return; + } + this.sessionSuggestions = result.suggestions; + this.sessionSuggestionRole = result.role; + this.requestUpdate(); + } catch { + if ( + requestVersion === this.sessionSuggestionsRequestVersion && + targetSignature === this.sessionSuggestionTargetSignature + ) { + this.sessionSuggestions = []; + this.sessionSuggestionRole = undefined; + this.requestUpdate(); + } + } + } + + private handleSessionSuggestionEvent(event: SessionSuggestionEvent): void { + if ( + !this.hasMultipleIdentities() || + !this.sessionSuggestionMatchesCurrentSession(event.suggestion) + ) { + return; + } + const shouldRefresh = + this.sessionSuggestionsRefreshPromise !== undefined || + this.sessionSuggestionRole !== undefined; + this.sessionSuggestionsRequestVersion += 1; + const selfId = this.context.gateway.snapshot.selfUser?.id; + if (this.sessionSuggestionRole === "viewer" && event.suggestion.author.id !== selfId) { + return; + } + if (event.action === "added") { + this.sessionSuggestions = [ + ...this.sessionSuggestions.filter((item) => item.id !== event.suggestion.id), + event.suggestion, + ].toSorted( + (left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id), + ); + } else if (event.suggestion.author.id === selfId) { + this.sessionSuggestions = this.sessionSuggestions.map((item) => + item.id === event.suggestion.id ? event.suggestion : item, + ); + } else { + this.sessionSuggestions = this.sessionSuggestions.filter( + (item) => item.id !== event.suggestion.id, + ); + } + this.sessionSuggestionBusyIds.delete(event.suggestion.id); + this.requestUpdate(); + if (shouldRefresh) { + void this.refreshSessionSuggestions(); + } + } + + private async addCurrentSessionSuggestion(): Promise { + const scope = this.captureConnectionScope(); + const text = scope?.state.chatMessage ?? ""; + if ( + !scope || + !text.trim() || + this.sessionSuggestionAddOperation || + !this.hasMultipleIdentities() + ) { + return; + } + if (scope.state.chatAttachments.length > 0) { + scope.state.chatError = t("chat.sessionSuggestions.attachmentsUnsupported"); + scope.state.lastError = scope.state.chatError; + scope.state.requestUpdate?.(); + return; + } + const sessionKey = scope.state.sessionKey; + const operation = Symbol(); + this.sessionSuggestionAddOperation = operation; + this.requestUpdate(); + try { + const result = await scope.client.request<{ suggestion: SessionSuggestion }>( + "session.suggestions.add", + { + sessionKey, + text, + ...scopedAgentParamsForSession(scope.state, sessionKey), + }, + ); + if ( + this.sessionSuggestionAddOperation !== operation || + !this.isConnectionScopeCurrent(scope) || + scope.state.sessionKey !== sessionKey + ) { + return; + } + if (scope.state.chatMessage === text) { + scope.state.handleChatDraftChange(""); + } + this.sessionSuggestions = [ + ...this.sessionSuggestions.filter((item) => item.id !== result.suggestion.id), + result.suggestion, + ]; + } catch (error) { + if ( + this.sessionSuggestionAddOperation === operation && + this.isConnectionScopeCurrent(scope) + ) { + scope.state.chatError = error instanceof Error ? error.message : String(error); + scope.state.lastError = scope.state.chatError; + } + } finally { + if (this.sessionSuggestionAddOperation === operation) { + this.sessionSuggestionAddOperation = undefined; + this.requestUpdate(); + } + } + } + + private async resolveCurrentSessionSuggestion( + suggestion: SessionSuggestion, + resolution: SessionSuggestionResolution, + ): Promise { + const scope = this.captureConnectionScope(); + if ( + !scope || + this.sessionSuggestionBusyIds.has(suggestion.id) || + (resolution === "edit" && this.sessionSuggestionEditOperation !== undefined) || + !this.sessionSuggestionMatchesCurrentSession(suggestion) + ) { + return; + } + if (this.isCurrentSessionArchived(scope.state) && resolution !== "dismiss") { + return; + } + const sessionKey = scope.state.sessionKey; + const targetSignature = this.sessionSuggestionTargetSignature; + const isCurrentTarget = () => + this.isConnectionScopeCurrent(scope) && + scope.state.sessionKey === sessionKey && + this.sessionSuggestionTargetSignature === targetSignature; + const previousEditDraft = resolution === "edit" ? scope.state.chatMessage : undefined; + const editOperation = resolution === "edit" ? Symbol() : undefined; + if (editOperation) { + this.sessionSuggestionEditOperation = editOperation; + } + this.sessionSuggestionBusyIds.add(suggestion.id); + if (resolution === "edit") { + scope.state.handleChatDraftChange(suggestion.text); + queueMicrotask(() => + this.querySelector(CHAT_COMPOSER_TEXTAREA_SELECTOR)?.focus({ + preventScroll: true, + }), + ); + } + this.requestUpdate(); + try { + const result = await scope.client.request<{ suggestion: SessionSuggestion }>( + "session.suggestions.resolve", + { + sessionKey, + id: suggestion.id, + resolution, + ...scopedAgentParamsForSession(scope.state, sessionKey), + }, + ); + if (!isCurrentTarget()) { + return; + } + if (result.suggestion.author.id === this.context.gateway.snapshot.selfUser?.id) { + this.sessionSuggestions = [ + ...this.sessionSuggestions.filter((item) => item.id !== suggestion.id), + result.suggestion, + ].toSorted( + (left, right) => left.createdAt - right.createdAt || left.id.localeCompare(right.id), + ); + } else { + this.sessionSuggestions = this.sessionSuggestions.filter( + (item) => item.id !== suggestion.id, + ); + } + } catch (error) { + if (isCurrentTarget()) { + if ( + resolution === "edit" && + error instanceof GatewayRequestError && + previousEditDraft !== undefined && + scope.state.chatMessage === suggestion.text + ) { + scope.state.handleChatDraftChange(previousEditDraft); + } + scope.state.chatError = error instanceof Error ? error.message : String(error); + scope.state.lastError = scope.state.chatError; + } + } finally { + if (isCurrentTarget()) { + if (this.sessionSuggestionEditOperation === editOperation) { + this.sessionSuggestionEditOperation = undefined; + } + this.sessionSuggestionBusyIds.delete(suggestion.id); + this.requestUpdate(); + } + } + } + + private clearTypingActors(): void { + for (const timer of this.typingTimers.values()) { + window.clearTimeout(timer); + } + this.typingTimers.clear(); + this.typingActors.clear(); + } + + private handleSessionTypingEvent(event: SessionTypingEvent): void { + const selfId = this.context.gateway.snapshot.selfUser?.id; + const state = this.state; + const selectedSession = state?.sessionsResult?.sessions.find((row) => + areUiSessionKeysEquivalent(row.key, state.sessionKey), + ); + if ( + !this.hasMultipleIdentities() || + event.actor.id === selfId || + !state || + selectedSession?.sessionId !== event.sessionId || + !uiSessionEventMatches( + { + agentsList: this.context.agents.state.agentsList, + hello: this.context.gateway.snapshot.hello, + sessionKey: state.sessionKey, + }, + event.sessionKey, + event.agentId, + ) + ) { + return; + } + const priorTimer = this.typingTimers.get(event.actor.id); + if (priorTimer !== undefined) { + window.clearTimeout(priorTimer); + this.typingTimers.delete(event.actor.id); + } + if (!event.typing) { + this.typingActors.delete(event.actor.id); + this.requestUpdate(); + return; + } + const expiresAt = Date.now() + 2_500; + this.typingActors.set(event.actor.id, { + label: event.actor.label ?? event.actor.id, + expiresAt, + }); + this.typingTimers.set( + event.actor.id, + window.setTimeout(() => { + if (this.typingActors.get(event.actor.id)?.expiresAt === expiresAt) { + this.typingActors.delete(event.actor.id); + this.typingTimers.delete(event.actor.id); + this.requestUpdate(); + } + }, 2_500), + ); + this.requestUpdate(); + } + + private typingLabel(): string | null { + const names = [...this.typingActors.values()].map((actor) => actor.label).toSorted(); + if (names.length === 0) { + return null; + } + return names.length === 1 + ? t("chat.sessionSuggestions.typing", { name: names[0] ?? "" }) + : t("chat.sessionSuggestions.typingMany", { names: names.join(", ") }); + } + + private sendTypingState(typing: boolean): void { + const scope = this.captureConnectionScope(); + if (!scope || !this.hasMultipleIdentities()) { + return; + } + const sessionKey = scope.state.sessionKey; + const sessionId = scope.state.sessionsResult?.sessions.find((row) => + areUiSessionKeysEquivalent(row.key, sessionKey), + )?.sessionId; + if (!sessionId) { + return; + } + void scope.client + .request("session.typing", { + sessionKey, + sessionId, + typing, + ...scopedAgentParamsForSession(scope.state, sessionKey), + }) + .catch(() => undefined); + } + private async refreshTaskSuggestions(): Promise { const requestVersion = ++this.taskSuggestionsRequestVersion; const scope = this.captureConnectionScope(); @@ -1114,6 +1542,8 @@ class ChatPane extends OpenClawLightDomElement { this.taskSuggestions = []; this.taskSuggestionBusyIds.clear(); this.taskSuggestionOperations.clear(); + this.resetSessionSuggestions(); + this.clearTypingActors(); this.resetSessionPullRequests(); if (catalogKey) { this.openCatalogSession(catalogKey, state); @@ -1138,6 +1568,7 @@ class ChatPane extends OpenClawLightDomElement { } state.requestUpdate(); void this.refreshTaskSuggestions(); + void this.refreshSessionSuggestions(); void this.refreshSessionPullRequests(); const scheduleHistoryScroll = () => { if (state.sessionKey !== nextSessionKey) { @@ -2525,8 +2956,15 @@ class ChatPane extends OpenClawLightDomElement { this.context.gateway.subscribeEvents((event) => { const state = this.state; if (event.event === "presence") { + const hadMultipleIdentities = this.hasMultipleIdentities(); const presence = readPresenceEntries(event.payload); this.presencePayload = presence ? { presence } : undefined; + if (!this.hasMultipleIdentities()) { + this.resetSessionSuggestions(); + this.clearTypingActors(); + } else if (!hadMultipleIdentities) { + void this.refreshSessionSuggestions(); + } } if (state) { handleQuestionPromptEvent(this.questionPromptState, event); @@ -2535,6 +2973,12 @@ class ChatPane extends OpenClawLightDomElement { if (event.event === "task.suggestion" && event.payload) { this.handleTaskSuggestionEvent(event.payload as TaskSuggestionEvent); } + if (event.event === "session.suggestion" && event.payload) { + this.handleSessionSuggestionEvent(event.payload as SessionSuggestionEvent); + } + if (event.event === "session.typing" && event.payload) { + this.handleSessionTypingEvent(event.payload as SessionTypingEvent); + } if (event.event === "session.observer" && event.payload) { this.recordObserverDigest(event.payload as SessionObserverDigest); } @@ -2650,6 +3094,8 @@ class ChatPane extends OpenClawLightDomElement { this.taskSuggestions = []; this.taskSuggestionBusyIds.clear(); this.taskSuggestionOperations.clear(); + this.resetSessionSuggestions(); + this.clearTypingActors(); this.resetSessionPullRequests(); this.resetOlderMessagesViewport(); this.nativeDraftCleanup?.(); @@ -2709,6 +3155,10 @@ class ChatPane extends OpenClawLightDomElement { if (applySelectedSessionProjection(state, selectedSession)) { this.markSessionRead(selectedSession); } + this.syncSessionSuggestionTarget( + stateValue.agentId ?? resolveChatAgentId(state) ?? "main", + selectedSession, + ); if (selectedSessionDeleted) { const agentId = parseAgentSessionKey(state.sessionKey)?.agentId ?? @@ -2801,6 +3251,8 @@ class ChatPane extends OpenClawLightDomElement { this.taskSuggestions = []; this.taskSuggestionBusyIds.clear(); this.taskSuggestionOperations.clear(); + this.resetSessionSuggestions(); + this.clearTypingActors(); this.sessionDiscussionStates.clear(); this.sessionDiscussionOpenUrls.clear(); this.sessionParticipationTracker.reset(); @@ -2934,6 +3386,7 @@ class ChatPane extends OpenClawLightDomElement { void refreshChatModelAuthStatus(state).finally(() => state.requestUpdate?.()); void state.loadAssistantIdentity(); void this.refreshTaskSuggestions(); + void this.refreshSessionSuggestions(); void this.refreshSessionPullRequests(); } this.reconcileWaitingApprovalSnapshot(); @@ -3542,20 +3995,37 @@ class ChatPane extends OpenClawLightDomElement { (agent) => agent.id === currentAgentId, ); const agentDefaultModel = selectedAgent?.model?.primary; - const selectedSessionArchived = - state.selectedChatSessionArchived || - state.sessionsResult?.sessions.some( - (row) => row.archived === true && areUiSessionKeysEquivalent(row.key, state.sessionKey), - ) === true; + const selectedSessionArchived = this.isCurrentSessionArchived(state); const sessionParticipationBlocked = this.sessionParticipationTracker.resolve({ catalog: catalogKey !== null, listLoading: state.sessionsLoading, sessionKey: `${currentAgentId ?? ""}\0${state.sessionKey}`, session: selectedSession, }); - const disabledReason = sessionParticipationBlocked - ? t("chat.sessionSharing.readOnlyNotice") - : null; + const multiIdentity = this.hasMultipleIdentities(); + const suggestionViewer = + multiIdentity && + !selectedSessionArchived && + hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null) && + selectedSession?.visibility === "suggest" && + selectedSession.sharingRole === "viewer" && + isGatewayMethodAdvertised(this.context.gateway.snapshot, "session.suggestions.add") === + true && + isGatewayMethodAdvertised(this.context.gateway.snapshot, "session.suggestions.list") === true; + const disabledReason = + sessionParticipationBlocked && !suggestionViewer + ? t("chat.sessionSharing.readOnlyNotice") + : null; + const typingEnabled = + multiIdentity && + hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null) && + !catalogKey && + isGatewayMethodAdvertised(this.context.gateway.snapshot, "session.typing") === true && + hasSessionPresenceViewers( + this.presencePayload, + this.context.gateway.snapshot.client?.instanceId, + state.sessionKey, + ); // Never flash "view-only" while metadata loads; after loading, anything short // of a continuable session (failed lookups too) explains the disabled composer. const catalogDisabledReason = @@ -3619,7 +4089,7 @@ class ChatPane extends OpenClawLightDomElement { showToolCalls: state.settings.chatShowToolCalls, persistCommentary: state.settings.chatPersistCommentary !== false, loading: catalogKey ? this.catalogLoading : state.chatLoading, - sending: state.chatSending, + sending: state.chatSending || this.sessionSuggestionAddOperation !== undefined, canAbort: sessionParticipationBlocked ? false : hasAbortableSessionRun(state), runStatus: state.chatRunStatus, startupStatus: activeChatRunStartupStatus(state.chatRunStartup), @@ -3679,9 +4149,12 @@ class ChatPane extends OpenClawLightDomElement { offline: gatewaySnapshot.offlineStable, gatewayClient: state.client, composerHoldToRecord: state.settings.composerHoldToRecord, + suggestionComposer: suggestionViewer, + typingLabel: multiIdentity ? this.typingLabel() : null, + onTypingChange: typingEnabled ? (typing) => this.sendTypingState(typing) : undefined, canSend: catalogKey ? this.catalogSession?.canContinue === true - : !selectedSessionArchived && !sessionParticipationBlocked, + : !selectedSessionArchived && (!sessionParticipationBlocked || suggestionViewer), disabledReason: catalogDisabledReason ?? disabledReason, disabledBanner: selectedSessionArchived && !catalogDisabledReason @@ -3778,6 +4251,17 @@ class ChatPane extends OpenClawLightDomElement { }, onDismissPullRequest: this.dismissSessionPullRequest, taskSuggestionBusyIds: this.taskSuggestionBusyIds, + sessionSuggestions: multiIdentity ? this.sessionSuggestions : [], + sessionSuggestionRole: this.sessionSuggestionRole, + sessionSuggestionBusyIds: this.sessionSuggestionBusyIds, + sessionSuggestionsArchived: selectedSessionArchived, + canResolveSessionSuggestions: + state.connected && + hasOperatorWriteAccess(this.context.gateway.snapshot.hello?.auth ?? null) && + isGatewayMethodAdvertised(this.context.gateway.snapshot, "session.suggestions.resolve") === + true, + onResolveSessionSuggestion: (suggestion, resolution) => + void this.resolveCurrentSessionSuggestion(suggestion, resolution), canAcceptTaskSuggestions: state.connected && hasOperatorAdminAccess(this.context.gateway.snapshot.hello?.auth ?? null), @@ -3816,7 +4300,11 @@ class ChatPane extends OpenClawLightDomElement { state.requestUpdate?.(); }, onSend: () => - catalogKey ? void this.continueCatalogSession(catalogKey) : void state.handleSendChat(), + catalogKey + ? void this.continueCatalogSession(catalogKey) + : suggestionViewer + ? void this.addCurrentSessionSuggestion() + : void state.handleSendChat(), onCompact: () => void state.handleSendChat("/compact"), onOpenSessionCheckpoints: () => { const search = new URLSearchParams({ session: state.sessionKey }); diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 23b2df403d0c..687f5f386e85 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -2,7 +2,12 @@ import { html, nothing, type TemplateResult } from "lit"; import { ref } from "lit/directives/ref.js"; import { styleMap } from "lit/directives/style-map.js"; -import type { TaskSuggestion } from "../../../../packages/gateway-protocol/src/index.js"; +import type { + SessionSharingRole, + SessionSuggestion, + SessionSuggestionResolution, + TaskSuggestion, +} from "../../../../packages/gateway-protocol/src/index.js"; import type { SessionObserverDigest, SessionsObserverAskResult, @@ -46,11 +51,12 @@ import { } from "./components/chat-image-lightbox.ts"; import { renderChatPullRequests } from "./components/chat-pull-requests.ts"; import { renderChatResizableDivider } from "./components/chat-resizable-divider.ts"; +import { renderChatSessionSuggestions } from "./components/chat-session-suggestions.ts"; +import "./components/chat-sidebar.ts"; import { renderSessionWorkspaceRail, type SessionWorkspaceProps, } from "./components/chat-session-workspace.ts"; -import "./components/chat-sidebar.ts"; import { isSideChatPanelVisible, renderSideChatPanel } from "./components/chat-side-chat.ts"; import type { DetailFullMessageResult, @@ -141,6 +147,9 @@ export type ChatProps = { offline?: boolean; gatewayClient?: GatewayBrowserClient | null; composerHoldToRecord?: boolean; + suggestionComposer?: boolean; + typingLabel?: string | null; + onTypingChange?: (typing: boolean) => void; canSend: boolean; disabledReason: string | null; disabledBanner?: { text: string; actionLabel: string; onAction: () => void }; @@ -256,6 +265,15 @@ export type ChatProps = { canDismissTaskSuggestions?: boolean; onAcceptTaskSuggestion?: (suggestion: TaskSuggestion) => void; onDismissTaskSuggestion?: (suggestion: TaskSuggestion) => void; + sessionSuggestions?: readonly SessionSuggestion[]; + sessionSuggestionRole?: SessionSharingRole; + sessionSuggestionBusyIds?: ReadonlySet; + sessionSuggestionsArchived?: boolean; + canResolveSessionSuggestions?: boolean; + onResolveSessionSuggestion?: ( + suggestion: SessionSuggestion, + resolution: SessionSuggestionResolution, + ) => void; pullRequests?: ControlUiSessionPullRequest[]; pullRequestsBranch?: ControlUiSessionBranch; pullRequestsRateLimited?: boolean; @@ -364,7 +382,7 @@ export function renderChat(props: ChatProps) { onForkMessage: props.onForkMessage, // Archived/non-composable sessions must not offer selection actions: // withholding the callback keeps the popup from rendering at all. - onSideQuestion: props.canSend ? props.onSideQuestion : undefined, + onSideQuestion: props.canSend && !props.suggestionComposer ? props.onSideQuestion : undefined, onOpenSession: props.onSessionSelect, backgroundTasks: props.backgroundTasks, onFocusComposer: () => @@ -418,6 +436,9 @@ export function renderChat(props: ChatProps) { realtimeTalkCameraError: props.realtimeTalkCameraError, gatewayClient: props.gatewayClient, composerHoldToRecord: props.composerHoldToRecord, + suggestionComposer: props.suggestionComposer, + typingLabel: props.typingLabel, + onTypingChange: props.onTypingChange, composerControls: props.composerControls, getDraft: props.getDraft, onDraftChange: props.onDraftChange, @@ -425,8 +446,8 @@ export function renderChat(props: ChatProps) { onHistoryKeydown: props.onHistoryKeydown, onSlashIntent: props.onSlashIntent, onSend: props.onSend, - onCompact: props.onCompact, - onToggleRealtimeTalk: props.onToggleRealtimeTalk, + onCompact: props.suggestionComposer ? undefined : props.onCompact, + onToggleRealtimeTalk: props.suggestionComposer ? undefined : props.onToggleRealtimeTalk, onToggleRealtimeCamera: props.onToggleRealtimeCamera, onSwitchRealtimeCamera: props.onSwitchRealtimeCamera, onDismissRealtimeTalkError: props.onDismissRealtimeTalkError, @@ -584,6 +605,15 @@ export function renderChat(props: ChatProps) { onExpand: () => props.onExpandPullRequests?.(), onDismiss: (pullRequest) => props.onDismissPullRequest?.(pullRequest), })} + ${renderChatSessionSuggestions({ + suggestions: props.sessionSuggestions ?? [], + role: props.sessionSuggestionRole, + busyIds: props.sessionSuggestionBusyIds ?? new Set(), + archived: props.sessionSuggestionsArchived === true, + canResolve: props.canResolveSessionSuggestions === true, + onResolve: (suggestion, resolution) => + props.onResolveSessionSuggestion?.(suggestion, resolution), + })} ${props.observerHudReady ? html` void; composerControls?: TemplateResult | typeof nothing; getDraft?: () => string; onDraftChange: (next: string) => void; @@ -1824,6 +1827,7 @@ type ChatRunControlsProps = { hasMessages: boolean; isBusy: boolean; followUpMode?: ControlUiFollowUpMode; + suggestionComposer?: boolean; sending: boolean; voiceActive?: boolean; voiceStatus?: RealtimeTalkStatus; @@ -1975,16 +1979,18 @@ function renderChatPrimaryActions(props: ChatRunControlsProps) { const hasComposedContent = Boolean(props.draft.trim() || props.hasAttachments); const steersActiveRun = props.followUpMode === "steer"; const interruptsActiveRun = props.followUpMode === "interrupt"; - const activeRunActionLabel = - props.followUpMode === undefined + const activeRunActionLabel = props.suggestionComposer + ? t("chat.sessionSuggestions.suggest") + : props.followUpMode === undefined ? t("chat.runControls.send") : steersActiveRun ? t("chat.queue.steer") : interruptsActiveRun ? t("chat.runControls.send") : t("chat.runControls.queue"); - const activeRunActionDescription = - props.followUpMode === undefined + const activeRunActionDescription = props.suggestionComposer + ? t("chat.sessionSuggestions.suggestMessage") + : props.followUpMode === undefined ? t("chat.runControls.sendMessage") : steersActiveRun ? t("chat.followUpModeSteer") @@ -2020,19 +2026,29 @@ function renderChatPrimaryActions(props: ChatRunControlsProps) { const voiceButton = renderComposerVoiceButton(props); const sendAction = html` @@ -2502,6 +2518,7 @@ export function renderChatComposer(props: ChatComposerProps) { return; } syncComposerValue(target); + props.onTypingChange?.(Boolean(target.value.trim())); }; const handleCompositionEnd = (event: CompositionEvent) => { state.composerComposing = false; @@ -2509,6 +2526,7 @@ export function renderChatComposer(props: ChatComposerProps) { state.composingDraft = null; } syncComposerValue(event.target as HTMLTextAreaElement); + props.onTypingChange?.(Boolean((event.target as HTMLTextAreaElement).value.trim())); }; const handleBlur = (event: FocusEvent) => { const target = event.target as HTMLTextAreaElement; @@ -2516,6 +2534,7 @@ export function renderChatComposer(props: ChatComposerProps) { state.composingDraft = null; } commitComposerDraft(props, target.value); + props.onTypingChange?.(false); }; const handleSend = () => { const draft = state.composerTextarea?.value ?? props.draft; @@ -2523,6 +2542,7 @@ export function renderChatComposer(props: ChatComposerProps) { return; } commitComposerDraft(props, draft); + props.onTypingChange?.(false); props.onSend(); syncComposerDraftAfterSend(state.composerTextarea); }; @@ -2663,10 +2683,11 @@ export function renderChatComposer(props: ChatComposerProps) { canSend: canSubmitDraft(actionDraft), connected: props.connected, draft: actionDraft, - hasAttachments: Boolean(props.attachments?.length), + hasAttachments: !props.suggestionComposer && Boolean(props.attachments?.length), hasMessages: props.messages.length > 0, isBusy, followUpMode: props.followUpMode, + suggestionComposer: props.suggestionComposer, sending: props.sending, voiceActive: props.realtimeTalkActive, voiceStatus: props.realtimeTalkStatus, @@ -2758,6 +2779,11 @@ export function renderChatComposer(props: ChatComposerProps) { : t("chat.composer.offlineHint")} ` : nothing} + ${props.typingLabel + ? html`
+ ${props.typingLabel} +
` + : nothing} ${slashMenuVisible ? renderSlashMenu(requestUpdate, props, visibleDraft) : nothing} ${renderAttachmentPreview(props)} ${props.replyTarget @@ -2884,7 +2910,10 @@ export function renderChatComposer(props: ChatComposerProps) { : nothing}
- ${renderChatAttachmentMenu({ ...props, disabled: !canCompose })} + ${renderChatAttachmentMenu({ + ...props, + disabled: !canCompose || props.suggestionComposer === true, + })}