mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(sessions): suggestion queue + typing indicator (#113173)
* feat(protocol): add session collaboration contracts * feat(gateway): add session suggestion queue and typing * feat(ui): add session suggestion controls * fix(collaboration): restrict suggestion resolution * fix(collaboration): dedupe suggestion dispatch modes * fix(collaboration): preserve resolver identity * fix(collaboration): reconcile suggestion state * fix(collaboration): filter identityless suggestion events * fix(ui): expose full suggestion text * fix(collaboration): durably claim suggestion dispatch * fix(collaboration): harden suggestion events and typing * fix(collaboration): reconcile suggestion races * fix(ui): reconcile suggestion capabilities * fix(collaboration): close suggestion privacy races * test(ui): satisfy suggestion lifecycle lint * fix(collaboration): fence resolved suggestion events * test(collaboration): type deferred audit result * fix(collaboration): fence delayed typing events * fix(ui): coalesce suggestion refreshes * fix(ui): preserve resolved self suggestions * fix(collaboration): enforce draft suggestion visibility * fix(collaboration): fence post-dispatch finalization * fix(ui): retain suggestions across visibility changes * fix(collaboration): fence suggestion context and archives * fix(collaboration): fence suggestion resolve lifecycle * fix(collaboration): map suggestion replacement races * refactor(gateway): extract session typing state * fix(collaboration): integrate suggestion storage with session nodes * refactor(gateway): extract session sharing snapshot cache * fix(collaboration): satisfy protocol and deadcode gates * fix(ci): register iOS release script entrypoints * fix(collaboration): fence typing by session instance * fix(collaboration): enforce incognito suggestion privacy * docs(ui): clarify solo suggestion dormancy * test(gateway): preserve incognito literal type * test(gateway): split session typing coverage * test(gateway): register collaboration method expectations
This commit is contained in:
committed by
GitHub
parent
60090fc2a6
commit
90aee82793
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof SessionSuggestionStateSchema>;
|
||||
export type SessionSuggestionResolution = Static<typeof SessionSuggestionResolutionSchema>;
|
||||
export type SessionSuggestionAction = Static<typeof SessionSuggestionActionSchema>;
|
||||
export type SessionSuggestion = Static<typeof SessionSuggestionSchema>;
|
||||
export type SessionSuggestionsAddParams = Static<typeof SessionSuggestionsAddParamsSchema>;
|
||||
export type SessionSuggestionsListParams = Static<typeof SessionSuggestionsListParamsSchema>;
|
||||
export type SessionSuggestionsResolveParams = Static<typeof SessionSuggestionsResolveParamsSchema>;
|
||||
export type SessionSuggestionsAddResult = Static<typeof SessionSuggestionsAddResultSchema>;
|
||||
export type SessionSuggestionsListResult = Static<typeof SessionSuggestionsListResultSchema>;
|
||||
export type SessionSuggestionsResolveResult = Static<typeof SessionSuggestionsResolveResultSchema>;
|
||||
export type SessionSuggestionEvent = Static<typeof SessionSuggestionEventSchema>;
|
||||
export type SessionTypingParams = Static<typeof SessionTypingParamsSchema>;
|
||||
export type SessionTypingResult = Static<typeof SessionTypingResultSchema>;
|
||||
export type SessionTypingEvent = Static<typeof SessionTypingEventSchema>;
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<string> {
|
||||
@@ -195,6 +210,7 @@ function readSessionNodeArtifactTables(database: OpenClawAgentDatabase): Set<str
|
||||
"board_widgets",
|
||||
"heartbeat_outcomes",
|
||||
"session_members",
|
||||
"session_suggestions",
|
||||
]),
|
||||
).rows.flatMap((row) => (row.name ? [row.name] : [])),
|
||||
);
|
||||
|
||||
@@ -35,6 +35,7 @@ type SessionSqliteDatabase = Pick<
|
||||
| "session_conversations"
|
||||
| "session_members"
|
||||
| "session_nodes"
|
||||
| "session_suggestions"
|
||||
| "session_windows"
|
||||
| "transcript_rewrite_watermarks"
|
||||
| "trajectory_runtime_events"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<OpenClawAgentKyselyDatabase, "session_suggestions">;
|
||||
|
||||
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<DatabaseSync>();
|
||||
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<SuggestionDatabase>(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<StoredSessionSuggestionState, "pending">;
|
||||
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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string, CoreGatewayMethodSpec> = new Map(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -74,6 +74,8 @@ const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
|
||||
"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<GatewayWsClient, number>();
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -46,6 +46,8 @@ export const GATEWAY_EVENTS = [
|
||||
"session.observer",
|
||||
"session.operation",
|
||||
"session.sharing",
|
||||
"session.suggestion",
|
||||
"session.typing",
|
||||
"session.tool",
|
||||
"sessions.changed",
|
||||
"presence",
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<typeof setTimeout>;
|
||||
};
|
||||
|
||||
const typingBroadcastState = new Map<string, TypingBroadcastState>();
|
||||
const typingConnections = new Map<string, Map<string, number>>();
|
||||
|
||||
export function liveViewerIdentities(sessionKeys: ReadonlySet<string>): Set<string> {
|
||||
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<string, number>();
|
||||
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;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<ReturnType<typeof resolveSessionSharingTarget>>) {
|
||||
return {
|
||||
agentId: target.agentId,
|
||||
sessionKey: target.storeKey,
|
||||
storePath: target.storePath,
|
||||
};
|
||||
}
|
||||
|
||||
function protocolSuggestion(
|
||||
target: NonNullable<ReturnType<typeof resolveSessionSharingTarget>>,
|
||||
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<ReturnType<typeof resolveSessionSharingTarget>>;
|
||||
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<ReturnType<typeof resolveSessionSharingTarget>>,
|
||||
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<T>(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<GatewayRequestHandlers[string]>[0]["req"];
|
||||
isWebchatConnect: Parameters<GatewayRequestHandlers[string]>[0]["isWebchatConnect"];
|
||||
target: NonNullable<ReturnType<typeof resolveSessionSharingTarget>>;
|
||||
suggestion: StoredSessionSuggestion;
|
||||
resolution: "send" | "queue";
|
||||
}): Promise<{ ok: true } | { ok: false; error: Parameters<RespondFn>[2] }> {
|
||||
let response: Parameters<RespondFn> | 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<ReturnType<typeof dispatchSuggestion>>;
|
||||
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<typeof runSessionSuggestionMutation<boolean>>;
|
||||
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 });
|
||||
},
|
||||
};
|
||||
@@ -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<RespondFn>[] = [];
|
||||
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<RespondFn>) => 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, SessionSharingSnapshot>();
|
||||
const snapshotAliases = new Map<string, string>();
|
||||
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+67
-100
@@ -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<string, SessionSharingSnapshot>();
|
||||
const sharingSnapshotAliases = new Map<string, string>();
|
||||
export { invalidateSessionSharingSnapshot };
|
||||
|
||||
export function resolveSessionVisibility(
|
||||
entry: Pick<SessionEntry, "visibility">,
|
||||
@@ -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<string>();
|
||||
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"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
+14
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -51,6 +51,36 @@ function renderComposer(overrides: Partial<ComposerProps> = {}) {
|
||||
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<HTMLButtonElement>('button[aria-label="Add attachment"]')
|
||||
?.disabled,
|
||||
).toBe(true);
|
||||
|
||||
const textarea = view.container.querySelector<HTMLTextAreaElement>("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,
|
||||
|
||||
@@ -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<HTMLElement>();
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((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<SessionSuggestionsListResult>();
|
||||
const secondList = createDeferred<SessionSuggestionsListResult>();
|
||||
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<SessionSuggestionsListResult>();
|
||||
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<SessionSuggestionsListResult>();
|
||||
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<never>();
|
||||
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<never>();
|
||||
const second = createDeferred<never>();
|
||||
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";
|
||||
|
||||
@@ -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<void>;
|
||||
sessionPullRequests: ControlUiSessionPullRequest[];
|
||||
taskSuggestions: TaskSuggestion[];
|
||||
presencePayload?: { presence: unknown[] };
|
||||
sessionSuggestionAddOperation: symbol | undefined;
|
||||
sessionSuggestionRole: "admin" | "owner" | "member" | "viewer" | undefined;
|
||||
addCurrentSessionSuggestion: () => Promise<void>;
|
||||
resetSessionSuggestions: () => void;
|
||||
sessionSuggestions: SessionSuggestion[];
|
||||
sessionSuggestionsRequestVersion: number;
|
||||
sessionSuggestionsRefreshPromise: Promise<void> | undefined;
|
||||
sessionSuggestionTargetSignature: string;
|
||||
syncSessionSuggestionTarget: (agentId: string, session: GatewaySessionRow | undefined) => void;
|
||||
handleSessionSuggestionEvent: (event: SessionSuggestionEvent) => void;
|
||||
handleSessionTypingEvent: (event: SessionTypingEvent) => void;
|
||||
typingActors: Map<string, { label: string; expiresAt: number }>;
|
||||
refreshSessionSuggestions: () => Promise<void>;
|
||||
resolveCurrentSessionSuggestion: (
|
||||
suggestion: SessionSuggestion,
|
||||
resolution: "send" | "queue" | "edit" | "dismiss",
|
||||
) => Promise<void>;
|
||||
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 } },
|
||||
|
||||
+501
-13
@@ -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<string>();
|
||||
private readonly taskSuggestionOperations = new Map<string, symbol>();
|
||||
private taskSuggestionsRequestVersion = 0;
|
||||
private sessionSuggestions: SessionSuggestion[] = [];
|
||||
private sessionSuggestionRole: SessionSharingRole | undefined;
|
||||
private readonly sessionSuggestionBusyIds = new Set<string>();
|
||||
private sessionSuggestionsRequestVersion = 0;
|
||||
private sessionSuggestionsRefreshPromise: Promise<void> | undefined;
|
||||
private sessionSuggestionsRefreshVersion: number | undefined;
|
||||
private sessionSuggestionsRefreshQueued = false;
|
||||
private sessionSuggestionTargetSignature = "";
|
||||
private sessionSuggestionAddOperation: symbol | undefined;
|
||||
private sessionSuggestionEditOperation: symbol | undefined;
|
||||
private readonly typingActors = new Map<string, { label: string; expiresAt: number }>();
|
||||
private readonly typingTimers = new Map<string, number>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<SessionSuggestionsListResult>(
|
||||
"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<void> {
|
||||
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<void> {
|
||||
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<HTMLTextAreaElement>(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<void> {
|
||||
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 });
|
||||
|
||||
@@ -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<string>;
|
||||
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`
|
||||
<openclaw-chat-observer-hud
|
||||
|
||||
@@ -143,6 +143,9 @@ type ChatComposerProps = {
|
||||
realtimeTalkCameraError?: boolean;
|
||||
gatewayClient?: GatewayBrowserClient | null;
|
||||
composerHoldToRecord?: boolean;
|
||||
suggestionComposer?: boolean;
|
||||
typingLabel?: string | null;
|
||||
onTypingChange?: (typing: boolean) => 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`
|
||||
<openclaw-tooltip
|
||||
.content=${props.isBusy ? t("chat.runControls.queue") : t("chat.runControls.send")}
|
||||
.content=${props.suggestionComposer
|
||||
? t("chat.sessionSuggestions.suggestMessage")
|
||||
: props.isBusy
|
||||
? t("chat.runControls.queue")
|
||||
: t("chat.runControls.send")}
|
||||
>
|
||||
<button
|
||||
class="chat-send-btn"
|
||||
@click=${storeDraftAndSend}
|
||||
?disabled=${!props.canSend || props.sending}
|
||||
aria-label=${props.isBusy
|
||||
? t("chat.runControls.queueMessage")
|
||||
: t("chat.runControls.sendMessage")}
|
||||
aria-label=${props.suggestionComposer
|
||||
? t("chat.sessionSuggestions.suggestMessage")
|
||||
: props.isBusy
|
||||
? t("chat.runControls.queueMessage")
|
||||
: t("chat.runControls.sendMessage")}
|
||||
>
|
||||
${icons.arrowUp}
|
||||
<span class="agent-chat__control-label"
|
||||
>${props.isBusy ? t("chat.runControls.queue") : t("chat.runControls.send")}</span
|
||||
>${props.suggestionComposer
|
||||
? t("chat.sessionSuggestions.suggest")
|
||||
: props.isBusy
|
||||
? t("chat.runControls.queue")
|
||||
: t("chat.runControls.send")}</span
|
||||
>
|
||||
</button>
|
||||
</openclaw-tooltip>
|
||||
@@ -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")}
|
||||
</div>`
|
||||
: nothing}
|
||||
${props.typingLabel
|
||||
? html`<div class="agent-chat__typing-indicator" role="status">
|
||||
${props.typingLabel}
|
||||
</div>`
|
||||
: nothing}
|
||||
${slashMenuVisible ? renderSlashMenu(requestUpdate, props, visibleDraft) : nothing}
|
||||
${renderAttachmentPreview(props)}
|
||||
${props.replyTarget
|
||||
@@ -2884,7 +2910,10 @@ export function renderChatComposer(props: ChatComposerProps) {
|
||||
: nothing}
|
||||
|
||||
<div class="agent-chat__composer-input-row">
|
||||
${renderChatAttachmentMenu({ ...props, disabled: !canCompose })}
|
||||
${renderChatAttachmentMenu({
|
||||
...props,
|
||||
disabled: !canCompose || props.suggestionComposer === true,
|
||||
})}
|
||||
<div class="agent-chat__composer-combobox">
|
||||
<textarea
|
||||
${ref(state.textareaRef)}
|
||||
@@ -2912,7 +2941,7 @@ export function renderChatComposer(props: ChatComposerProps) {
|
||||
@compositionend=${handleCompositionEnd}
|
||||
@blur=${handleBlur}
|
||||
@paste=${(event: ClipboardEvent) => {
|
||||
if (canCompose) {
|
||||
if (canCompose && !props.suggestionComposer) {
|
||||
handleChatAttachmentPaste(event, props);
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { render } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { SessionSuggestion } from "../../../../../packages/gateway-protocol/src/index.js";
|
||||
import { renderChatSessionSuggestions } from "./chat-session-suggestions.ts";
|
||||
|
||||
const suggestion: SessionSuggestion = {
|
||||
id: "suggestion-1",
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
author: { type: "human", id: "alice", label: "Alice" },
|
||||
text: "Try the focused change",
|
||||
createdAt: 1,
|
||||
state: "pending",
|
||||
};
|
||||
|
||||
let container: HTMLDivElement | undefined;
|
||||
|
||||
afterEach(() => {
|
||||
container?.remove();
|
||||
container = undefined;
|
||||
});
|
||||
|
||||
function mount(role: "owner" | "viewer", row = suggestion, canResolve = true, archived = false) {
|
||||
const onResolve = vi.fn();
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
render(
|
||||
renderChatSessionSuggestions({
|
||||
suggestions: [row],
|
||||
role,
|
||||
busyIds: new Set(),
|
||||
archived,
|
||||
canResolve,
|
||||
onResolve,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
return { container, onResolve };
|
||||
}
|
||||
|
||||
describe("chat session suggestions", () => {
|
||||
it("renders the four owner actions in send, queue, edit, dismiss order", () => {
|
||||
const view = mount("owner");
|
||||
const buttons = [...view.container.querySelectorAll<HTMLButtonElement>("button")];
|
||||
expect(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",
|
||||
]);
|
||||
buttons.forEach((button) => button.click());
|
||||
expect(view.onResolve.mock.calls.map((call) => call[1])).toEqual([
|
||||
"send",
|
||||
"queue",
|
||||
"edit",
|
||||
"dismiss",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the author's resolved state without participant actions", () => {
|
||||
const view = mount("viewer", { ...suggestion, state: "accepted" });
|
||||
expect(view.container.querySelector("button")).toBeNull();
|
||||
expect(view.container.textContent).toContain("Accepted");
|
||||
expect(view.container.textContent).toContain("Try the focused change");
|
||||
});
|
||||
|
||||
it("does not expose participant actions before the role is known", () => {
|
||||
const onResolve = vi.fn();
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
render(
|
||||
renderChatSessionSuggestions({
|
||||
suggestions: [suggestion],
|
||||
role: undefined,
|
||||
busyIds: new Set(),
|
||||
archived: false,
|
||||
canResolve: true,
|
||||
onResolve,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
expect(container.querySelector("button")).toBeNull();
|
||||
expect(container.textContent).toContain("Pending");
|
||||
});
|
||||
|
||||
it("does not expose resolution actions to members", () => {
|
||||
const onResolve = vi.fn();
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
render(
|
||||
renderChatSessionSuggestions({
|
||||
suggestions: [suggestion],
|
||||
role: "member",
|
||||
busyIds: new Set(),
|
||||
archived: false,
|
||||
canResolve: true,
|
||||
onResolve,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
expect(container.querySelector("button")).toBeNull();
|
||||
expect(container.textContent).toContain("Pending");
|
||||
});
|
||||
|
||||
it("hides owner actions when the resolve method is unavailable", () => {
|
||||
const view = mount("owner", suggestion, false);
|
||||
expect(view.container.querySelector("button")).toBeNull();
|
||||
expect(view.container.textContent).toContain("Pending");
|
||||
});
|
||||
|
||||
it("keeps only dismiss available for an archived session", () => {
|
||||
const view = mount("owner", suggestion, true, true);
|
||||
const buttons = [...view.container.querySelectorAll<HTMLButtonElement>("button")];
|
||||
expect(buttons.map((button) => button.getAttribute("aria-label"))).toEqual([
|
||||
"Dismiss Alice's suggestion",
|
||||
]);
|
||||
buttons[0]?.click();
|
||||
expect(view.onResolve).toHaveBeenCalledWith(suggestion, "dismiss");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { html, nothing } from "lit";
|
||||
import type {
|
||||
SessionSharingRole,
|
||||
SessionSuggestion,
|
||||
SessionSuggestionResolution,
|
||||
} from "../../../../../packages/gateway-protocol/src/index.js";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import { t } from "../../../i18n/index.ts";
|
||||
|
||||
function actionButton(params: {
|
||||
icon: unknown;
|
||||
label: string;
|
||||
busy: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return html`
|
||||
<button
|
||||
class="btn btn--ghost btn--icon session-suggestion__action"
|
||||
type="button"
|
||||
?disabled=${params.busy}
|
||||
aria-label=${params.label}
|
||||
title=${params.label}
|
||||
@click=${params.onClick}
|
||||
>
|
||||
${params.icon}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
export function renderChatSessionSuggestions(props: {
|
||||
suggestions: readonly SessionSuggestion[];
|
||||
role?: SessionSharingRole;
|
||||
busyIds: ReadonlySet<string>;
|
||||
archived: boolean;
|
||||
canResolve: boolean;
|
||||
onResolve: (suggestion: SessionSuggestion, resolution: SessionSuggestionResolution) => void;
|
||||
}) {
|
||||
if (props.suggestions.length === 0) {
|
||||
return nothing;
|
||||
}
|
||||
const canResolve = props.canResolve && (props.role === "owner" || props.role === "admin");
|
||||
return html`
|
||||
<div class="session-suggestions" aria-live="polite">
|
||||
${props.suggestions.map((suggestion) => {
|
||||
const busy = props.busyIds.has(suggestion.id);
|
||||
const author = suggestion.author.label ?? suggestion.author.id;
|
||||
return html`
|
||||
<article class="session-suggestion" data-suggestion-id=${suggestion.id}>
|
||||
<span class="session-suggestion__author">${author}</span>
|
||||
<span class="session-suggestion__text">${suggestion.text}</span>
|
||||
${canResolve && suggestion.state === "pending"
|
||||
? html`
|
||||
<div class="session-suggestion__actions">
|
||||
${props.archived
|
||||
? nothing
|
||||
: html`
|
||||
${actionButton({
|
||||
icon: icons.arrowUp,
|
||||
label: t("chat.sessionSuggestions.sendNow", { author }),
|
||||
busy,
|
||||
onClick: () => props.onResolve(suggestion, "send"),
|
||||
})}
|
||||
${actionButton({
|
||||
icon: icons.check,
|
||||
label: t("chat.sessionSuggestions.queue", { author }),
|
||||
busy,
|
||||
onClick: () => props.onResolve(suggestion, "queue"),
|
||||
})}
|
||||
${actionButton({
|
||||
icon: icons.edit,
|
||||
label: t("chat.sessionSuggestions.edit", { author }),
|
||||
busy,
|
||||
onClick: () => props.onResolve(suggestion, "edit"),
|
||||
})}
|
||||
`}
|
||||
${actionButton({
|
||||
icon: icons.trash,
|
||||
label: t("chat.sessionSuggestions.dismiss", { author }),
|
||||
busy,
|
||||
onClick: () => props.onResolve(suggestion, "dismiss"),
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
: html`<span class="session-suggestion__state"
|
||||
>${t(`chat.sessionSuggestions.state.${suggestion.state}`)}</span
|
||||
>`}
|
||||
</article>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -1333,6 +1333,87 @@ openclaw-chat-page {
|
||||
padding: 0 14px 10px;
|
||||
}
|
||||
|
||||
.session-suggestions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 0 16px 8px;
|
||||
}
|
||||
|
||||
.session-suggestion {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 38px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--panel-strong) 74%, transparent);
|
||||
}
|
||||
|
||||
.session-suggestion__author,
|
||||
.session-suggestion__state {
|
||||
border-radius: 999px;
|
||||
padding: 3px 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-suggestion__author {
|
||||
color: var(--text-strong);
|
||||
background: var(--panel-hover);
|
||||
}
|
||||
|
||||
.session-suggestion__state {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.session-suggestion__text {
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.session-suggestion__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.session-suggestion__action {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.session-suggestion__action svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.agent-chat__typing-indicator {
|
||||
padding: 0 4px 4px;
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.session-suggestion {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.session-suggestion__actions,
|
||||
.session-suggestion__state {
|
||||
grid-column: 1 / -1;
|
||||
justify-self: end;
|
||||
}
|
||||
}
|
||||
|
||||
.task-suggestion {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
Reference in New Issue
Block a user