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:
Peter Steinberger
2026-07-23 18:59:58 -07:00
committed by GitHub
parent 60090fc2a6
commit 90aee82793
49 changed files with 5492 additions and 155 deletions
@@ -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
+11
View File
@@ -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);
+1
View File
@@ -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."
}
+1
View File
@@ -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",
+4
View File
@@ -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);
});
});
+11 -4
View File
@@ -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),
+10 -2
View File
@@ -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);
+2
View File
@@ -46,6 +46,8 @@ export const GATEWAY_EVENTS = [
"session.observer",
"session.operation",
"session.sharing",
"session.suggestion",
"session.typing",
"session.tool",
"sessions.changed",
"presence",
+4
View File
@@ -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);
});
});
});
+2
View File
@@ -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;
+9 -2
View File
@@ -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;
}
+114
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+25
View File
@@ -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,
+28 -1
View File
@@ -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);
});
+4
View File
@@ -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;
}
+232
View File
@@ -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();
});
});
+16
View File
@@ -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",
+30
View File
@@ -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,
+555 -1
View File
@@ -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";
+26 -1
View File
@@ -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
View File
@@ -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 });
+35 -5
View File
@@ -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
+41 -12
View File
@@ -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>
`;
}
+81
View File
@@ -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;