mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
feat(sessions): permanent creator attribution, owner avatars, person filter, multi-user docs (#112658)
* feat(sessions): persist creator attribution * feat(ui): add session creator filtering * chore(sessions): refresh sqlite schema baseline * docs(security): explain shared-agent trust * fix(sessions): project catalog creator ownership * fix(ui): restore startup JS budget headroom for creator attribution
This commit is contained in:
committed by
GitHub
parent
0226ad50fa
commit
cf2f591161
@@ -8,6 +8,10 @@ The fastest useful reports show a current, reproducible boundary bypass with dem
|
||||
|
||||
Security work is shared across a number of OpenClaw maintainers, including engineers and security researchers from organizations such as NVIDIA and Tencent. See the [maintainer list](CONTRIBUTING.md#maintainers).
|
||||
|
||||
## Shared Agents
|
||||
|
||||
Anyone who can operate an agent can make it do anything that agent can do. Session ownership, visibility, and presence are usability features, not security boundaries. Turn attribution is best-effort because steering can merge input into an active turn. Use separate agents or separate gateway/host trust boundaries when operators need real isolation.
|
||||
|
||||
## Report a Security Issue
|
||||
|
||||
Report vulnerabilities directly to the repository where the issue lives:
|
||||
|
||||
@@ -3958,6 +3958,7 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
public let includederivedtitles: Bool?
|
||||
public let includelastmessage: Bool?
|
||||
public let label: String?
|
||||
public let creatorid: String?
|
||||
public let spawnedby: String?
|
||||
public let agentid: String?
|
||||
public let search: String?
|
||||
@@ -3975,6 +3976,7 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
includederivedtitles: Bool? = nil,
|
||||
includelastmessage: Bool? = nil,
|
||||
label: String? = nil,
|
||||
creatorid: String? = nil,
|
||||
spawnedby: String? = nil,
|
||||
agentid: String? = nil,
|
||||
search: String? = nil,
|
||||
@@ -3991,6 +3993,7 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
self.includederivedtitles = includederivedtitles
|
||||
self.includelastmessage = includelastmessage
|
||||
self.label = label
|
||||
self.creatorid = creatorid
|
||||
self.spawnedby = spawnedby
|
||||
self.agentid = agentid
|
||||
self.search = search
|
||||
@@ -4009,6 +4012,7 @@ public struct SessionsListParams: Codable, Sendable {
|
||||
case includederivedtitles = "includeDerivedTitles"
|
||||
case includelastmessage = "includeLastMessage"
|
||||
case label
|
||||
case creatorid = "creatorId"
|
||||
case spawnedby = "spawnedBy"
|
||||
case agentid = "agentId"
|
||||
case search
|
||||
@@ -4098,6 +4102,7 @@ public struct SessionCatalogSession: Codable, Sendable {
|
||||
public let pullrequest: SessionCatalogPullRequestSummary?
|
||||
public let archived: Bool
|
||||
public let sessionkey: String?
|
||||
public let createdby: [String: AnyCodable]?
|
||||
public let cancontinue: Bool
|
||||
public let canarchive: Bool
|
||||
public let canopenterminal: Bool?
|
||||
@@ -4118,6 +4123,7 @@ public struct SessionCatalogSession: Codable, Sendable {
|
||||
pullrequest: SessionCatalogPullRequestSummary? = nil,
|
||||
archived: Bool,
|
||||
sessionkey: String? = nil,
|
||||
createdby: [String: AnyCodable]? = nil,
|
||||
cancontinue: Bool,
|
||||
canarchive: Bool,
|
||||
canopenterminal: Bool? = nil)
|
||||
@@ -4137,6 +4143,7 @@ public struct SessionCatalogSession: Codable, Sendable {
|
||||
self.pullrequest = pullrequest
|
||||
self.archived = archived
|
||||
self.sessionkey = sessionkey
|
||||
self.createdby = createdby
|
||||
self.cancontinue = cancontinue
|
||||
self.canarchive = canarchive
|
||||
self.canopenterminal = canopenterminal
|
||||
@@ -4158,6 +4165,7 @@ public struct SessionCatalogSession: Codable, Sendable {
|
||||
case pullrequest = "pullRequest"
|
||||
case archived
|
||||
case sessionkey = "sessionKey"
|
||||
case createdby = "createdBy"
|
||||
case cancontinue = "canContinue"
|
||||
case canarchive = "canArchive"
|
||||
case canopenterminal = "canOpenTerminal"
|
||||
|
||||
@@ -1 +1 @@
|
||||
011b9ec0e0fa64b5a4036648fcd61a50674b813a92280e8e3fe8746f6acdb62d sqlite-session-transcript-schema-baseline.sql
|
||||
0852c1b681df33646f60d239afcac6de53fbc498bd5daa58077376331205ccdf sqlite-session-transcript-schema-baseline.sql
|
||||
|
||||
@@ -1606,5 +1606,25 @@
|
||||
{
|
||||
"source": "Baseten (Inkling + Model APIs)",
|
||||
"target": "Baseten(Inkling + Model APIs)"
|
||||
},
|
||||
{
|
||||
"source": "Multi-user mode",
|
||||
"target": "多用户模式"
|
||||
},
|
||||
{
|
||||
"source": "The main session",
|
||||
"target": "主会话"
|
||||
},
|
||||
{
|
||||
"source": "Session management",
|
||||
"target": "会话管理"
|
||||
},
|
||||
{
|
||||
"source": "Presence",
|
||||
"target": "在线状态"
|
||||
},
|
||||
{
|
||||
"source": "Gateway security",
|
||||
"target": "Gateway 安全"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
summary: "How session ownership and presence work when several people operate one agent"
|
||||
read_when:
|
||||
- You share one OpenClaw agent with other operators
|
||||
- You need to understand session owner and presence indicators
|
||||
- You are deciding whether one shared agent provides enough isolation
|
||||
title: "Multi-user mode"
|
||||
---
|
||||
|
||||
Multi-user mode lets several trusted people operate the same OpenClaw agent. It adds session ownership, live presence, and creator filtering so a team can tell who started work and who is currently watching it.
|
||||
|
||||
## Trust boundary
|
||||
|
||||
Everyone who can operate an agent can make it do anything that agent can do. Session ownership, visibility in the sidebar, and presence indicators are usability features, not security boundaries.
|
||||
|
||||
If people must not access each other's sessions, tools, credentials, or files, give them separate agents or separate gateway/host trust boundaries. Do not rely on owner avatars or filters for isolation.
|
||||
|
||||
## Ownership and presence
|
||||
|
||||
New sessions record their creator when the Gateway has a trusted identity available. Trusted-proxy identity takes priority; otherwise OpenClaw uses the paired device's operator label or display name. Older sessions and sessions created without either identity have no owner stamp.
|
||||
|
||||
The web app keeps ownership and presence visually distinct:
|
||||
|
||||
- A solid owner avatar is permanent for the lifetime of that session.
|
||||
- Ringed or translucent presence avatars show people who are currently connected or watching.
|
||||
- The sidebar's person filter shows sessions created by one identity while preserving the existing custom groups.
|
||||
|
||||
When fewer than two distinct creators appear in the loaded session list, OpenClaw hides all ownership and person-filter chrome. A single-user gateway therefore looks unchanged.
|
||||
|
||||
## Turn attribution
|
||||
|
||||
Turn sender attribution is best-effort. Steering can merge input into an active turn, so the transcript cannot always represent each person's contribution as a separate turn.
|
||||
|
||||
## Related
|
||||
|
||||
- [The main session](/concepts/main-session)
|
||||
- [Session management](/concepts/session)
|
||||
- [Presence](/concepts/presence)
|
||||
- [Gateway security](/gateway/security)
|
||||
+2
-1
@@ -1233,6 +1233,7 @@
|
||||
"group": "Sessions and memory",
|
||||
"pages": [
|
||||
"concepts/main-session",
|
||||
"concepts/multi-user",
|
||||
"concepts/session",
|
||||
"concepts/session-search",
|
||||
"concepts/channel-docking",
|
||||
@@ -2056,4 +2057,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2778,6 +2778,15 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
|
||||
- H2: Per-agent sandbox and tool configuration
|
||||
- H2: Related
|
||||
|
||||
## concepts/multi-user.md
|
||||
|
||||
- Route: /concepts/multi-user
|
||||
- Headings:
|
||||
- H2: Trust boundary
|
||||
- H2: Ownership and presence
|
||||
- H2: Turn attribution
|
||||
- H2: Related
|
||||
|
||||
## concepts/oauth.md
|
||||
|
||||
- Route: /concepts/oauth
|
||||
|
||||
@@ -25,6 +25,7 @@ export type {
|
||||
MissingScopeErrorDetails,
|
||||
} from "./schema/error-codes.js";
|
||||
export * from "./schema/board.js";
|
||||
export { SessionCreatorIdentitySchema, type SessionCreatorIdentity } from "./schema/sessions.js";
|
||||
export * from "./migration-api.js";
|
||||
export type * from "./public-session-catalog.js";
|
||||
import {
|
||||
|
||||
@@ -31,6 +31,7 @@ describe("SessionsCatalogListResultSchema", () => {
|
||||
threadId: "thread-1",
|
||||
status: "idle",
|
||||
archived: false,
|
||||
createdBy: { id: "profile-ada", label: "Ada" },
|
||||
canContinue: true,
|
||||
canArchive: false,
|
||||
canOpenTerminal: true,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Type } from "typebox";
|
||||
import { closedObject } from "./closed-object.js";
|
||||
import { PluginJsonValueSchema } from "./plugins.js";
|
||||
import { NonEmptyString } from "./primitives.js";
|
||||
import { SessionCreatorIdentitySchema } from "./sessions.js";
|
||||
|
||||
const SessionCatalogErrorSchema = closedObject({ code: NonEmptyString, message: NonEmptyString });
|
||||
|
||||
@@ -55,6 +56,7 @@ export const SessionCatalogSessionSchema = closedObject({
|
||||
pullRequest: Type.Optional(SessionCatalogPullRequestSummarySchema),
|
||||
archived: Type.Boolean(),
|
||||
sessionKey: Type.Optional(NonEmptyString),
|
||||
createdBy: Type.Optional(SessionCreatorIdentitySchema),
|
||||
canContinue: Type.Boolean(),
|
||||
canArchive: Type.Boolean(),
|
||||
canOpenTerminal: Type.Optional(Type.Boolean()),
|
||||
|
||||
@@ -20,6 +20,13 @@ export const SESSION_OBSERVER_HEALTH_VALUES = [
|
||||
"failed",
|
||||
] as const;
|
||||
|
||||
/** Stable identity stamped on a session when an operator creates it. */
|
||||
export const SessionCreatorIdentitySchema = closedObject({
|
||||
id: NonEmptyString,
|
||||
label: Type.Optional(NonEmptyString),
|
||||
});
|
||||
export type SessionCreatorIdentity = Static<typeof SessionCreatorIdentitySchema>;
|
||||
|
||||
/** Trajectory judgment produced for one observed agent session. */
|
||||
export const SessionObserverHealthSchema = Type.Union([
|
||||
Type.Literal("on-track"),
|
||||
@@ -297,6 +304,8 @@ export const SessionsListParamsSchema = closedObject({
|
||||
*/
|
||||
includeLastMessage: Type.Optional(Type.Boolean()),
|
||||
label: Type.Optional(SessionLabelString),
|
||||
/** Filter rows by their permanent creator identity. */
|
||||
creatorId: Type.Optional(NonEmptyString),
|
||||
spawnedBy: Type.Optional(NonEmptyString),
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
search: Type.Optional(Type.String()),
|
||||
|
||||
@@ -12,11 +12,10 @@ const KIB = 1024;
|
||||
export const CONTROL_UI_PERFORMANCE_BUDGETS = Object.freeze({
|
||||
startupJsRequests: 18,
|
||||
startupCssRequests: 1,
|
||||
// 314 KiB accompanies cloud-workspace conflict recovery (2026-07): the live
|
||||
// notice and sidebar attention must be available on initial chat render, and
|
||||
// their bounded recovery copy exhausted the previous ceiling after rebasing.
|
||||
// One KiB restores explicit headroom without changing the request budget.
|
||||
startupJsGzipBytes: 314 * KIB,
|
||||
// 315 KiB accompanies session creator attribution (2026-07): owner chips,
|
||||
// the person filter, and their catalog strings live in the startup bundle,
|
||||
// and main again sat within 0.1 KiB of the ceiling.
|
||||
startupJsGzipBytes: 315 * KIB,
|
||||
// 45 KiB CSS ceilings maintainer-approved 2026-07 alongside the interleaved
|
||||
// sidebar zone styling; headroom over the ~36.5 KiB post-diet baseline.
|
||||
startupCssGzipBytes: 45 * KIB,
|
||||
|
||||
@@ -214,6 +214,9 @@ export function initFastReplySessionState(params: {
|
||||
const sessionEntry: SessionEntry = {
|
||||
...(!resetTriggered ? existingEntry : undefined),
|
||||
sessionId,
|
||||
...((resetTriggered || !existingEntry) && ctx.SessionCreator
|
||||
? { createdBy: { ...ctx.SessionCreator } }
|
||||
: {}),
|
||||
sessionFile,
|
||||
updatedAt: now,
|
||||
sessionStartedAt: resetTriggered ? now : (existingEntry?.sessionStartedAt ?? now),
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { upsertSessionEntry } from "../../config/sessions/session-accessor.js";
|
||||
import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js";
|
||||
import { initSessionState } from "./session.js";
|
||||
|
||||
let tempDir: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { force: true, recursive: true });
|
||||
tempDir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it("clears the previous creator when an ownerless turn starts a new generation", async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-creator-"));
|
||||
const storePath = path.join(tempDir, "sessions.json");
|
||||
const sessionKey = "agent:main:telegram:chat:creator";
|
||||
await upsertSessionEntry(
|
||||
{ sessionKey, storePath },
|
||||
{
|
||||
createdBy: { id: "alice@example.com", label: "Alice" },
|
||||
sessionId: "owned-session",
|
||||
updatedAt: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: { Body: "/new", CommandBody: "/new", SessionKey: sessionKey },
|
||||
cfg: { session: { store: storePath } } as OpenClawConfig,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(result.sessionEntry).not.toHaveProperty("createdBy");
|
||||
});
|
||||
@@ -905,6 +905,13 @@ async function initSessionStateAttemptLocked(
|
||||
sessionEntry = {
|
||||
...baseEntry,
|
||||
sessionId,
|
||||
...(isNewSession
|
||||
? ctx.SessionCreator
|
||||
? { createdBy: { ...ctx.SessionCreator } }
|
||||
: {}
|
||||
: baseEntry?.createdBy
|
||||
? { createdBy: baseEntry.createdBy }
|
||||
: {}),
|
||||
updatedAt: Date.now(),
|
||||
sessionStartedAt: isNewSession
|
||||
? now
|
||||
|
||||
@@ -276,6 +276,8 @@ export type MsgContext = {
|
||||
OwnerAllowFrom?: Array<string | number>;
|
||||
SenderName?: string;
|
||||
SenderId?: string;
|
||||
/** Trusted Gateway operator identity used only when creating a session. */
|
||||
SessionCreator?: import("../../packages/gateway-protocol/src/schema/sessions.js").SessionCreatorIdentity;
|
||||
SenderUsername?: string;
|
||||
SenderTag?: string;
|
||||
SenderE164?: string;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import {
|
||||
normalizeSqliteStatus,
|
||||
parseSqliteSessionEntryJson as parseSessionEntryRow,
|
||||
serializeSqliteSessionCreatorIdentity,
|
||||
} from "./session-accessor.sqlite-status.js";
|
||||
import {
|
||||
readTranscriptMutationStateInTransaction,
|
||||
@@ -487,6 +488,7 @@ export function writeSessionEntry(
|
||||
entry_json: JSON.stringify(normalizedEntry),
|
||||
updated_at: updatedAt,
|
||||
status: normalizeSqliteStatus(normalizedEntry.status),
|
||||
created_by_json: serializeSqliteSessionCreatorIdentity(normalizedEntry.createdBy),
|
||||
})
|
||||
.onConflict((conflict) =>
|
||||
conflict.column("session_key").doUpdateSet({
|
||||
@@ -494,6 +496,7 @@ export function writeSessionEntry(
|
||||
entry_json: JSON.stringify(normalizedEntry),
|
||||
updated_at: updatedAt,
|
||||
status: normalizeSqliteStatus(normalizedEntry.status),
|
||||
created_by_json: serializeSqliteSessionCreatorIdentity(normalizedEntry.createdBy),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -19,12 +19,42 @@ export function normalizeSqliteStatus(value: unknown): SessionEntryStatus | null
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeSessionCreatorIdentity(value: unknown): SessionEntry["createdBy"] {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = value as { id?: unknown; label?: unknown };
|
||||
const id = typeof candidate.id === "string" ? candidate.id.trim() : "";
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
const label = typeof candidate.label === "string" ? candidate.label.trim() : "";
|
||||
return { id, ...(label ? { label } : {}) };
|
||||
}
|
||||
|
||||
export function serializeSqliteSessionCreatorIdentity(
|
||||
createdBy: SessionEntry["createdBy"],
|
||||
): string | null {
|
||||
const normalized = normalizeSessionCreatorIdentity(createdBy);
|
||||
return normalized ? JSON.stringify(normalized) : null;
|
||||
}
|
||||
|
||||
export function parseSqliteSessionEntryJson(row: { entry_json: string }): SessionEntry | null {
|
||||
try {
|
||||
const parsed = JSON.parse(row.entry_json) as unknown;
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as SessionEntry)
|
||||
: null;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const entry = parsed as SessionEntry;
|
||||
// entry_json stays authoritative across downgrade/upgrade cycles: an older
|
||||
// binary can rewrite it without knowing about the additive projection column.
|
||||
const createdBy = normalizeSessionCreatorIdentity(entry.createdBy);
|
||||
if (createdBy) {
|
||||
entry.createdBy = createdBy;
|
||||
} else {
|
||||
delete entry.createdBy;
|
||||
}
|
||||
return entry;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ describe("session accessor seam", () => {
|
||||
};
|
||||
|
||||
await upsertSessionEntry(scope, {
|
||||
createdBy: { id: "profile-ada", label: "Ada Lovelace" },
|
||||
model: "gpt-5.5",
|
||||
sessionId: "session-1",
|
||||
updatedAt: 10,
|
||||
@@ -112,10 +113,20 @@ describe("session accessor seam", () => {
|
||||
|
||||
expect(loadSessionEntry(scope)).toMatchObject({
|
||||
model: "gpt-5.5",
|
||||
createdBy: { id: "profile-ada", label: "Ada Lovelace" },
|
||||
sessionId: "session-1",
|
||||
updatedAt: expect.any(Number),
|
||||
});
|
||||
expect(readSessionUpdatedAt(scope)).toEqual(expect.any(Number));
|
||||
const databasePath = resolveSqliteTargetFromSessionStorePath(storePath, {
|
||||
agentId: "main",
|
||||
}).path;
|
||||
const database = openOpenClawAgentDatabase({ agentId: "main", path: databasePath });
|
||||
expect(
|
||||
database.db
|
||||
.prepare("SELECT created_by_json FROM session_entries WHERE session_key = ?")
|
||||
.get(scope.sessionKey),
|
||||
).toEqual({ created_by_json: '{"id":"profile-ada","label":"Ada Lovelace"}' });
|
||||
expect(listSessionEntries({ storePath })).toEqual([
|
||||
{
|
||||
sessionKey: "agent:main:main",
|
||||
@@ -127,6 +138,17 @@ describe("session accessor seam", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
// A downgraded writer knows only entry_json and can leave the additive
|
||||
// projection untouched. Re-upgrade must not resurrect that stale creator.
|
||||
database.db
|
||||
.prepare("UPDATE session_entries SET entry_json = ?, updated_at = ? WHERE session_key = ?")
|
||||
.run(
|
||||
JSON.stringify({ model: "legacy-reset", sessionId: "session-1", updatedAt: 15 }),
|
||||
15,
|
||||
scope.sessionKey,
|
||||
);
|
||||
expect(loadSessionEntry(scope)).not.toHaveProperty("createdBy");
|
||||
|
||||
await upsertSessionEntry(scope, { model: "sonnet-4.6", updatedAt: 20 });
|
||||
|
||||
expect(loadSessionEntry(scope)).toMatchObject({
|
||||
@@ -134,6 +156,7 @@ describe("session accessor seam", () => {
|
||||
sessionId: "session-1",
|
||||
updatedAt: expect.any(Number),
|
||||
});
|
||||
expect(loadSessionEntry(scope)).not.toHaveProperty("createdBy");
|
||||
});
|
||||
|
||||
it("lists retained transcript instances across same-key session rotation", async () => {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
} from "@openclaw/acp-core/types";
|
||||
import { normalizeOptionalString, type FastMode } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type { SessionCreatorIdentity } from "../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/session-icon.js";
|
||||
import type { ChatType } from "../../channels/chat-type.js";
|
||||
import type { ChannelId } from "../../channels/plugins/channel-id.types.js";
|
||||
@@ -251,6 +252,8 @@ export type SessionEntry = SessionRestartRecoveryState &
|
||||
/** Durable one-shot prompt additions drained before the next agent turn. */
|
||||
pluginNextTurnInjections?: Record<string, SessionPluginNextTurnInjection[]>;
|
||||
sessionId: string;
|
||||
/** Operator identity captured once for this session generation. */
|
||||
createdBy?: SessionCreatorIdentity;
|
||||
updatedAt: number;
|
||||
/** Opaque owner revision used to reject stale lifecycle mutations. */
|
||||
lifecycleRevision?: string;
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
resolveBareSessionResetResult,
|
||||
runSessionResetFromAgent,
|
||||
} from "./agent-session-reset.js";
|
||||
import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
|
||||
import { emitSessionsChanged } from "./session-change-event.js";
|
||||
import type { GatewayRequestHandlerOptions } from "./types.js";
|
||||
|
||||
@@ -96,6 +97,7 @@ export async function runAgentResetPhase(params: {
|
||||
? { agentId: params.agentId }
|
||||
: {}),
|
||||
reason: resetReason,
|
||||
createdBy: gatewayClientSessionCreator(params.client),
|
||||
assertCurrent: () => assertAgentRunLifecycleGenerationCurrent(params.lifecycleGeneration),
|
||||
onCommitted: (commit) => {
|
||||
params.setCommittedResetCompletion({
|
||||
|
||||
@@ -23,6 +23,7 @@ import { startAgentRunExecution } from "./agent-run-execution-phase.js";
|
||||
import { buildAgentSessionPatch } from "./agent-session-patch.js";
|
||||
import { persistAgentSessionPhase } from "./agent-session-persist.js";
|
||||
import { prepareAgentSession } from "./agent-session-prepare.js";
|
||||
import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
export const agentRunHandler: GatewayRequestHandlers["agent"] = async ({
|
||||
@@ -297,6 +298,7 @@ export const agentRunHandler: GatewayRequestHandlers["agent"] = async ({
|
||||
freshEntry === undefined
|
||||
? normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId)
|
||||
: undefined,
|
||||
createdBy: gatewayClientSessionCreator(client),
|
||||
expectedExistingSessionId,
|
||||
hasRestoredCronContinuation: restoredCronContinuationIdentity !== undefined,
|
||||
resetPolicy,
|
||||
|
||||
@@ -31,6 +31,59 @@ function buildPatch(touchInteraction: boolean) {
|
||||
}
|
||||
|
||||
describe("agent session patch", () => {
|
||||
it("stamps a creator only when minting a new session", () => {
|
||||
const patch = buildAgentSessionPatch({
|
||||
freshEntry: undefined,
|
||||
initialEntry: undefined,
|
||||
cfg: {},
|
||||
sessionAgentId: "main",
|
||||
canonicalSessionKey: "agent:main:new",
|
||||
storePath: "/tmp/openclaw-agent-creator-test.json",
|
||||
normalizedSpawned: {},
|
||||
requestDeliveryHint: undefined,
|
||||
createdBy: { id: "profile-ada", label: "Ada" },
|
||||
hasRestoredCronContinuation: false,
|
||||
resetPolicy: resolveSessionResetPolicy({ resetType: "direct" }),
|
||||
now: 1_000,
|
||||
isSystemGatewayRun: false,
|
||||
visibleRequest: true,
|
||||
fallbackSessionId: "new-session",
|
||||
touchInteraction: true,
|
||||
failedSessionTranscriptMissing: () => false,
|
||||
}).patch;
|
||||
|
||||
expect(patch.createdBy).toEqual({ id: "profile-ada", label: "Ada" });
|
||||
});
|
||||
|
||||
it("clears a previous creator on an ownerless implicit rotation", () => {
|
||||
const entry: SessionEntry = {
|
||||
createdBy: { id: "profile-ada", label: "Ada" },
|
||||
sessionId: "old-session",
|
||||
updatedAt: 1,
|
||||
};
|
||||
const patch = buildAgentSessionPatch({
|
||||
freshEntry: entry,
|
||||
initialEntry: entry,
|
||||
cfg: {},
|
||||
sessionAgentId: "main",
|
||||
canonicalSessionKey: "agent:main:main",
|
||||
storePath: "/tmp/openclaw-agent-creator-rotation.json",
|
||||
normalizedSpawned: {},
|
||||
requestDeliveryHint: undefined,
|
||||
hasRestoredCronContinuation: false,
|
||||
resetPolicy: resolveSessionResetPolicy({ resetType: "direct" }),
|
||||
now: 2,
|
||||
isSystemGatewayRun: false,
|
||||
visibleRequest: true,
|
||||
fallbackSessionId: "new-session",
|
||||
touchInteraction: true,
|
||||
failedSessionTranscriptMissing: () => true,
|
||||
}).patch;
|
||||
|
||||
expect(Object.hasOwn(patch, "createdBy")).toBe(true);
|
||||
expect(patch.createdBy).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears agent status at the next human interaction boundary", () => {
|
||||
const patch = buildPatch(true);
|
||||
expect(Object.hasOwn(patch, "agentStatus")).toBe(true);
|
||||
|
||||
@@ -50,6 +50,7 @@ export function buildAgentSessionPatch(params: {
|
||||
requestLabel?: string;
|
||||
recipientChannel?: string;
|
||||
pluginOwnerId?: string;
|
||||
createdBy?: SessionEntry["createdBy"];
|
||||
expectedExistingSessionId?: string;
|
||||
hasRestoredCronContinuation: boolean;
|
||||
resetPolicy: ReturnType<typeof import("../../config/sessions.js").resolveSessionResetPolicy>;
|
||||
@@ -209,6 +210,9 @@ export function buildAgentSessionPatch(params: {
|
||||
sessionId: patchSessionId,
|
||||
updatedAt: params.now,
|
||||
...(freshIsNewSession && !freshSessionRotatedSinceLoad ? { sessionStartedAt: params.now } : {}),
|
||||
...(freshIsNewSession && !freshSessionRotatedSinceLoad
|
||||
? { createdBy: params.createdBy ? { ...params.createdBy } : undefined }
|
||||
: {}),
|
||||
...(params.touchInteraction
|
||||
? {
|
||||
lastInteractionAt: params.now,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { SessionCreatorIdentity } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import type { AgentCommandOpts } from "../../agents/command/types.js";
|
||||
import { agentCommandFromIngress } from "../../commands/agent.js";
|
||||
@@ -19,6 +20,7 @@ export async function runSessionResetFromAgent(params: {
|
||||
key: string;
|
||||
agentId?: string;
|
||||
reason: "new" | "reset";
|
||||
createdBy?: SessionCreatorIdentity;
|
||||
assertCurrent?: () => void;
|
||||
onCommitted?: (commit: { key: string; sessionId: string }) => void;
|
||||
}) {
|
||||
@@ -27,6 +29,7 @@ export async function runSessionResetFromAgent(params: {
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
reason: params.reason,
|
||||
commandSource: "gateway:agent",
|
||||
createdBy: params.createdBy,
|
||||
assertCurrent: params.assertCurrent,
|
||||
onCommitted: params.onCommitted,
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { prepareChatSendAttachments } from "./chat-send-attachments.js";
|
||||
import type { NormalizedChatSendRequest } from "./chat-send-request.js";
|
||||
import type { PreparedChatSendSession } from "./chat-send-session.js";
|
||||
import { normalizeOptionalChatText } from "./chat-text-normalization.js";
|
||||
import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlerOptions } from "./types.js";
|
||||
|
||||
type PreparedChatSendAttachments = Extract<
|
||||
@@ -179,6 +180,9 @@ function buildChatSendMessageContext(params: {
|
||||
body: commandBody,
|
||||
},
|
||||
MessageSid: params.clientRunId,
|
||||
...(gatewayClientSessionCreator(params.client)
|
||||
? { SessionCreator: gatewayClientSessionCreator(params.client) }
|
||||
: {}),
|
||||
ApprovalReviewerDeviceId: queuedFollowupOwnerDeviceId,
|
||||
...(!isOperatorUiClient(params.clientInfo)
|
||||
? {
|
||||
|
||||
@@ -17,3 +17,8 @@ export function gatewayClientSenderFields(client: GatewayClient | null): {
|
||||
}
|
||||
return client?.authenticatedUserId ? { sender: { id: client.authenticatedUserId } } : {};
|
||||
}
|
||||
|
||||
/** Returns the trusted creator identity captured during connection admission. */
|
||||
export function gatewayClientSessionCreator(client: GatewayClient | null) {
|
||||
return client?.operatorIdentity ? { ...client.operatorIdentity } : undefined;
|
||||
}
|
||||
|
||||
@@ -4,9 +4,15 @@ import { gatewaySubagentState } from "../../plugins/runtime/gateway-bindings.js"
|
||||
import { createPluginRuntime } from "../../plugins/runtime/index.js";
|
||||
import type { SessionCatalogProvider } from "../../plugins/session-catalog.js";
|
||||
|
||||
type CatalogSessionEntryLoader = (
|
||||
sessionKey: string,
|
||||
options?: { agentId?: string; clone?: boolean },
|
||||
) => { entry: { createdBy?: { id: string; label?: string } } | undefined };
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
activeRegistry: { sessionCatalogs: [] as unknown[] },
|
||||
pinnedSessionExtensionRegistry: undefined as { sessionCatalogs: unknown[] } | undefined,
|
||||
loadSessionEntryReadOnly: vi.fn<CatalogSessionEntryLoader>(() => ({ entry: undefined })),
|
||||
recordSessionStateEvent: vi.fn(),
|
||||
upsertSessionUpstreamLink: vi.fn(),
|
||||
}));
|
||||
@@ -33,6 +39,10 @@ vi.mock("../../sessions/session-upstream-links.js", () => ({
|
||||
vi.mock("../../plugins/session-conversation-binding.js", () => ({
|
||||
bindPluginSessionConversation: conversationBindingMocks.bindPluginSessionConversation,
|
||||
}));
|
||||
vi.mock("../session-utils.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../session-utils.js")>();
|
||||
return { ...actual, loadSessionEntryReadOnly: hoisted.loadSessionEntryReadOnly };
|
||||
});
|
||||
|
||||
const { resolveSessionCatalogCreateTarget, sessionCatalogHandlers } =
|
||||
await import("./session-catalog.js");
|
||||
@@ -71,6 +81,8 @@ describe("session catalog Gateway methods", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.activeRegistry.sessionCatalogs = [];
|
||||
hoisted.pinnedSessionExtensionRegistry = undefined;
|
||||
hoisted.loadSessionEntryReadOnly.mockReset();
|
||||
hoisted.loadSessionEntryReadOnly.mockReturnValue({ entry: undefined });
|
||||
hoisted.recordSessionStateEvent.mockClear();
|
||||
hoisted.upsertSessionUpstreamLink.mockClear();
|
||||
conversationBindingMocks.bindPluginSessionConversation.mockClear();
|
||||
@@ -144,6 +156,98 @@ describe("session catalog Gateway methods", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("projects authoritative creator ownership onto streamed and final catalog rows", async () => {
|
||||
const broadcastToConnIds = vi.fn();
|
||||
const host = {
|
||||
hostId: "gateway:local",
|
||||
label: "Local Claude",
|
||||
kind: "gateway" as const,
|
||||
connected: true,
|
||||
sessions: [
|
||||
{
|
||||
threadId: "owned-thread",
|
||||
status: "stored",
|
||||
archived: false,
|
||||
sessionKey: "agent:main:owned",
|
||||
createdBy: { id: "provider-spoof" },
|
||||
canContinue: true,
|
||||
canArchive: false,
|
||||
},
|
||||
{
|
||||
threadId: "missing-thread",
|
||||
status: "stored",
|
||||
archived: false,
|
||||
sessionKey: "agent:main:missing",
|
||||
createdBy: { id: "provider-spoof" },
|
||||
canContinue: true,
|
||||
canArchive: false,
|
||||
},
|
||||
{
|
||||
threadId: "external-thread",
|
||||
status: "stored",
|
||||
archived: false,
|
||||
createdBy: { id: "provider-spoof" },
|
||||
canContinue: true,
|
||||
canArchive: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
hoisted.loadSessionEntryReadOnly.mockImplementation((sessionKey: string) => ({
|
||||
entry:
|
||||
sessionKey === "agent:main:owned"
|
||||
? { createdBy: { id: "profile-ada", label: "Ada" } }
|
||||
: undefined,
|
||||
}));
|
||||
hoisted.activeRegistry.sessionCatalogs = [
|
||||
{
|
||||
provider: provider("claude", {
|
||||
list: vi.fn(async ({ onHost }) => {
|
||||
onHost?.(host);
|
||||
return [host];
|
||||
}),
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const respond = await call(
|
||||
"sessions.catalog.list",
|
||||
{ progressId: "progress-creator" },
|
||||
{},
|
||||
{ connId: "requester", connect: {} },
|
||||
{ broadcastToConnIds },
|
||||
);
|
||||
const projectedSessions = [
|
||||
expect.objectContaining({
|
||||
threadId: "owned-thread",
|
||||
createdBy: { id: "profile-ada", label: "Ada" },
|
||||
}),
|
||||
expect.not.objectContaining({ createdBy: expect.anything() }),
|
||||
expect.not.objectContaining({ createdBy: expect.anything() }),
|
||||
];
|
||||
|
||||
expect(broadcastToConnIds).toHaveBeenCalledWith(
|
||||
"sessions.catalog.host",
|
||||
expect.objectContaining({
|
||||
catalog: expect.objectContaining({
|
||||
hosts: [expect.objectContaining({ sessions: projectedSessions })],
|
||||
}),
|
||||
}),
|
||||
new Set(["requester"]),
|
||||
{ dropIfSlow: true },
|
||||
);
|
||||
expect(respond).toHaveBeenCalledWith(true, {
|
||||
catalogs: [
|
||||
expect.objectContaining({
|
||||
hosts: [expect.objectContaining({ sessions: projectedSessions })],
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(hoisted.loadSessionEntryReadOnly).toHaveBeenCalledWith("agent:main:owned", {
|
||||
agentId: "main",
|
||||
});
|
||||
expect(hoisted.loadSessionEntryReadOnly).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses the pinned Gateway catalog runtime after active registry churn", async () => {
|
||||
const previousNodesRuntime = gatewaySubagentState.nodes;
|
||||
const listNodes = vi.fn(async () => ({ nodes: [] }));
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
type SessionCatalog,
|
||||
type SessionCatalogHost,
|
||||
type SessionCatalogSession,
|
||||
type SessionsCatalogArchiveParams,
|
||||
type SessionsCatalogContinueParams,
|
||||
type SessionsCatalogListParams,
|
||||
@@ -22,6 +24,7 @@ import { bindPluginSessionConversation } from "../../plugins/session-conversatio
|
||||
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||
import { recordSessionStateEvent } from "../../sessions/session-state-events.js";
|
||||
import { upsertSessionUpstreamLink } from "../../sessions/session-upstream-links.js";
|
||||
import { loadSessionEntryReadOnly } from "../session-utils.js";
|
||||
import { resolveAgentIdOrRespondError } from "./agent-id-shared.js";
|
||||
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
@@ -155,6 +158,32 @@ function catalogResult(
|
||||
return result;
|
||||
}
|
||||
|
||||
function projectCatalogHostCreators(
|
||||
host: SessionCatalogHost,
|
||||
agentId: string,
|
||||
creatorBySessionKey: Map<string, SessionCatalogSession["createdBy"]>,
|
||||
): SessionCatalogHost {
|
||||
return {
|
||||
...host,
|
||||
sessions: host.sessions.map(({ createdBy: _providerCreatedBy, ...session }) => {
|
||||
// Catalog providers do not own creator identity; the persisted session entry does.
|
||||
const sessionKey = session.sessionKey;
|
||||
let createdBy: SessionCatalogSession["createdBy"];
|
||||
if (sessionKey && creatorBySessionKey.has(sessionKey)) {
|
||||
createdBy = creatorBySessionKey.get(sessionKey);
|
||||
} else {
|
||||
createdBy = sessionKey
|
||||
? loadSessionEntryReadOnly(sessionKey, { agentId }).entry?.createdBy
|
||||
: undefined;
|
||||
if (sessionKey) {
|
||||
creatorBySessionKey.set(sessionKey, createdBy);
|
||||
}
|
||||
}
|
||||
return createdBy ? { ...session, createdBy: { ...createdBy } } : session;
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const sessionCatalogHandlers: GatewayRequestHandlers = {
|
||||
"sessions.catalog.list": async ({ params, respond, context, client }) => {
|
||||
if (
|
||||
@@ -199,6 +228,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
|
||||
const search = normalizeSessionCatalogSearch(request.search);
|
||||
const progressId = request.progressId;
|
||||
const progressConnId = progressId && client?.connId ? client.connId : undefined;
|
||||
const creatorBySessionKey = new Map<string, SessionCatalogSession["createdBy"]>();
|
||||
const catalogList = await Promise.all(
|
||||
selected.map(async (provider): Promise<SessionCatalog> => {
|
||||
const createTarget = resolveProviderCreateTarget(provider, resolvedAgent.agentId);
|
||||
@@ -212,7 +242,12 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
|
||||
{
|
||||
progressId,
|
||||
agentId: resolvedAgent.agentId,
|
||||
catalog: catalogResult(provider, [host], undefined, createSession),
|
||||
catalog: catalogResult(
|
||||
provider,
|
||||
[projectCatalogHostCreators(host, resolvedAgent.agentId, creatorBySessionKey)],
|
||||
undefined,
|
||||
createSession,
|
||||
),
|
||||
},
|
||||
new Set([progressConnId]),
|
||||
{ dropIfSlow: true },
|
||||
@@ -227,7 +262,14 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
|
||||
...(request.cursors !== undefined ? { cursors: request.cursors } : {}),
|
||||
...(onHost ? { onHost } : {}),
|
||||
});
|
||||
return catalogResult(provider, hosts, undefined, createSession);
|
||||
return catalogResult(
|
||||
provider,
|
||||
hosts.map((host) =>
|
||||
projectCatalogHostCreators(host, resolvedAgent.agentId, creatorBySessionKey),
|
||||
),
|
||||
undefined,
|
||||
createSession,
|
||||
);
|
||||
} catch (error) {
|
||||
return catalogResult(provider, [], catalogError(error), createSession);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { resolveSessionStoreAgentId } from "../session-store-key.js";
|
||||
import { readSessionMessageCountAsync } from "../session-transcript-readers.js";
|
||||
import { loadSessionEntryReadOnly, resolveGatewaySessionStoreTarget } from "../session-utils.js";
|
||||
import { chatHandlers } from "./chat.js";
|
||||
import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
|
||||
import { resolveSessionCatalogCreateTarget } from "./session-catalog.js";
|
||||
import { emitSessionsChanged } from "./session-change-event.js";
|
||||
import {
|
||||
@@ -328,6 +329,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
|
||||
).allowed;
|
||||
const created = await createGatewaySession({
|
||||
cfg,
|
||||
createdBy: gatewayClientSessionCreator(client),
|
||||
key: sessionKey,
|
||||
agentId: sessionAgentId,
|
||||
label: p.label,
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
type SessionsPatchResult,
|
||||
} from "../session-utils.js";
|
||||
import { projectSessionsPatchEntry } from "../sessions-patch.js";
|
||||
import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
|
||||
import { hasVisibleActiveSessionRun } from "./session-active-runs.js";
|
||||
import { emitSessionsChanged } from "./session-change-event.js";
|
||||
import {
|
||||
@@ -382,7 +383,7 @@ export const sessionMutationHandlers: GatewayRequestHandlers = {
|
||||
reason: "plugin-patch",
|
||||
});
|
||||
},
|
||||
"sessions.reset": async ({ params, respond, context }) => {
|
||||
"sessions.reset": async ({ params, respond, context, client }) => {
|
||||
if (!assertValidParams(params, validateSessionsResetParams, "sessions.reset", respond)) {
|
||||
return;
|
||||
}
|
||||
@@ -399,6 +400,7 @@ export const sessionMutationHandlers: GatewayRequestHandlers = {
|
||||
...(p.agentId ? { agentId: p.agentId } : {}),
|
||||
reason,
|
||||
commandSource: "gateway:sessions.reset",
|
||||
createdBy: gatewayClientSessionCreator(client),
|
||||
});
|
||||
if (!result.ok) {
|
||||
respond(false, undefined, result.error);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
SessionCreatorIdentity,
|
||||
SessionApprovalReplay,
|
||||
SystemAgentChatQuestion,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
@@ -77,6 +78,8 @@ export type GatewayClient = {
|
||||
hasAvatar: boolean;
|
||||
updatedAt: number;
|
||||
};
|
||||
/** Trusted operator identity resolved once during connection admission. */
|
||||
operatorIdentity?: SessionCreatorIdentity;
|
||||
pluginSurfaceUrls?: Record<string, string>;
|
||||
pluginNodeCapabilitySurfaces?: Record<string, PluginNodeCapabilitySurface>;
|
||||
pluginNodeCapabilities?: Record<string, { capability: string; expiresAtMs: number }>;
|
||||
|
||||
@@ -93,6 +93,7 @@ function buildGatewaySessionSnapshot(params: {
|
||||
const session = params.includeSession
|
||||
? {
|
||||
...buildGatewaySessionEventRow(sessionRow),
|
||||
createdBy: sessionRow.createdBy ?? null,
|
||||
thinkingLevel: sessionRow.thinkingLevel ?? null,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -73,6 +73,51 @@ function requireNonEmptyString(value: string | undefined, label: string): string
|
||||
return value;
|
||||
}
|
||||
|
||||
test("sessions.create stamps the trusted creator and preserves it until reset", async () => {
|
||||
await createSessionStoreDir();
|
||||
const adaClient = {
|
||||
operatorIdentity: { id: "profile-ada", label: "Ada Lovelace" },
|
||||
connect: { scopes: ["operator.admin"] },
|
||||
} as never;
|
||||
const bobClient = {
|
||||
operatorIdentity: { id: "profile-bob", label: "Bob Hopper" },
|
||||
connect: { scopes: ["operator.admin"] },
|
||||
} as never;
|
||||
|
||||
const created = await directSessionReq<{
|
||||
key: string;
|
||||
entry: { createdBy?: { id: string; label?: string } };
|
||||
}>("sessions.create", { agentId: "main" }, { client: adaClient });
|
||||
expect(created.ok).toBe(true);
|
||||
expect(created.payload?.entry.createdBy).toEqual({
|
||||
id: "profile-ada",
|
||||
label: "Ada Lovelace",
|
||||
});
|
||||
const key = requireNonEmptyString(created.payload?.key, "created session key");
|
||||
|
||||
const reused = await directSessionReq<{ entry: { createdBy?: { id: string } } }>(
|
||||
"sessions.create",
|
||||
{ agentId: "main", key },
|
||||
{ client: bobClient },
|
||||
);
|
||||
expect(reused.payload?.entry.createdBy?.id).toBe("profile-ada");
|
||||
|
||||
const listed = await directSessionReq<{
|
||||
sessions: Array<{ key: string; createdBy?: { id: string; label?: string } }>;
|
||||
}>("sessions.list", { agentId: "main" });
|
||||
expect(listed.payload?.sessions.find((row) => row.key === key)?.createdBy).toEqual({
|
||||
id: "profile-ada",
|
||||
label: "Ada Lovelace",
|
||||
});
|
||||
|
||||
const reset = await directSessionReq<{ entry: { createdBy?: { id: string; label?: string } } }>(
|
||||
"sessions.reset",
|
||||
{ agentId: "main", key },
|
||||
{ client: bobClient },
|
||||
);
|
||||
expect(reset.payload?.entry.createdBy).toEqual({ id: "profile-bob", label: "Bob Hopper" });
|
||||
});
|
||||
|
||||
test("sessions.create provisions and reuses a session worktree for later runs", async () => {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(await fs.realpath(os.tmpdir()), "openclaw-session-worktree-"),
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { ConnectErrorDetailCodes } from "../../../../packages/gateway-protocol/src/connect-error-details.js";
|
||||
import { ErrorCodes, PROTOCOL_VERSION } from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import { getRuntimeConfig } from "../../../config/io.js";
|
||||
import { getPairedDevice } from "../../../infra/device-pairing.js";
|
||||
import {
|
||||
captureAuthenticatedNodePairingState,
|
||||
type NodePairingGeneration,
|
||||
@@ -213,6 +214,32 @@ export async function attachAuthenticatedGatewayConnect(
|
||||
);
|
||||
}
|
||||
}
|
||||
let pairedDeviceLabel: string | undefined;
|
||||
if (device?.id) {
|
||||
try {
|
||||
const pairedDevice = await getPairedDevice(device.id);
|
||||
pairedDeviceLabel =
|
||||
normalizeOptionalString(pairedDevice?.operatorLabel) ??
|
||||
normalizeOptionalString(pairedDevice?.displayName);
|
||||
} catch (error) {
|
||||
// Pairing metadata is attribution-only and must not turn into a login dependency.
|
||||
logWsControl.warn(
|
||||
`paired device label resolution failed conn=${connId}: ${formatForLog(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// SSO identity wins over device labeling so one person keeps the same creator
|
||||
// across browsers; paired-device labels cover gateways without trusted proxy auth.
|
||||
const operatorIdentity = authenticatedUserProfile
|
||||
? {
|
||||
id: authenticatedUserId,
|
||||
label: authenticatedUserProfile.displayName ?? authenticatedUserId,
|
||||
}
|
||||
: authenticatedUserId
|
||||
? { id: authenticatedUserId, label: authenticatedUserId }
|
||||
: device?.id && pairedDeviceLabel
|
||||
? { id: device.id, label: pairedDeviceLabel }
|
||||
: undefined;
|
||||
|
||||
const pluginSurfaceUrls: Record<string, string> = {};
|
||||
const pluginNodeCapabilitySurfaces = indexPluginNodeCapabilitySurfaces(pluginNodeCapabilities);
|
||||
@@ -308,6 +335,7 @@ export async function attachAuthenticatedGatewayConnect(
|
||||
presenceKey,
|
||||
...(authenticatedUserId ? { authenticatedUserId } : {}),
|
||||
...(authenticatedUserProfile ? { authenticatedUserProfile } : {}),
|
||||
...(operatorIdentity ? { operatorIdentity } : {}),
|
||||
clientIp: reportedClientIp,
|
||||
...(internal ? { internal } : {}),
|
||||
...(Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {}),
|
||||
|
||||
@@ -628,6 +628,7 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
|
||||
displayName: "alice",
|
||||
hasAvatar: false,
|
||||
},
|
||||
operatorIdentity: { id: "alice@example.com", label: "alice" },
|
||||
});
|
||||
|
||||
expect(setAvatar(profileId!, new Uint8Array([1, 2, 3]), "image/png").ok).toBe(true);
|
||||
@@ -664,7 +665,10 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(harness.client).toMatchObject({ authenticatedUserId: "alice@example.com" });
|
||||
expect(harness.client).toMatchObject({
|
||||
authenticatedUserId: "alice@example.com",
|
||||
operatorIdentity: { id: "alice@example.com", label: "alice@example.com" },
|
||||
});
|
||||
expect(harness.client).not.toMatchObject({ authenticatedUserProfile: expect.anything() });
|
||||
expect(harness.logWsControl.warn).toHaveBeenCalledTimes(1);
|
||||
expect(harness.logWsControl.warn).toHaveBeenCalledWith(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import {
|
||||
ErrorCodes,
|
||||
type ErrorShape,
|
||||
type SessionCreatorIdentity,
|
||||
errorShape,
|
||||
missingScopeErrorShape,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
@@ -260,6 +261,7 @@ export async function createGatewaySession(params: {
|
||||
thinkingLevel?: string;
|
||||
/** Trusted catalog-owned model/runtime pair, persisted and locked together. */
|
||||
catalogTarget?: TrustedCatalogSessionTarget;
|
||||
createdBy?: SessionCreatorIdentity;
|
||||
parentSessionKey?: string;
|
||||
/**
|
||||
* Spawn-lineage depth declared by spawn-owned creations (visible subagent
|
||||
@@ -535,6 +537,7 @@ export async function createGatewaySession(params: {
|
||||
: {}),
|
||||
reason: "new",
|
||||
commandSource: params.commandSource,
|
||||
createdBy: params.createdBy,
|
||||
...(spawnedCwd ? { spawnedCwd } : {}),
|
||||
...(params.worktree ? { worktree: params.worktree } : {}),
|
||||
...(params.execNode ? { execNode: params.execNode } : {}),
|
||||
@@ -768,6 +771,9 @@ export async function createGatewaySession(params: {
|
||||
: undefined;
|
||||
const initializedEntry: SessionEntry = {
|
||||
...patched.entry,
|
||||
...(existingEntry === undefined && params.createdBy
|
||||
? { createdBy: { ...params.createdBy } }
|
||||
: {}),
|
||||
...(catalogResolvedModel && catalogAgentRuntime
|
||||
? {
|
||||
providerOverride: catalogResolvedModel.provider,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { buildGatewaySessionEventFields } from "./session-event-payload.js";
|
||||
|
||||
it("projects creator identity and explicitly clears it for ownerless generations", () => {
|
||||
expect(
|
||||
buildGatewaySessionEventFields({
|
||||
sessionRow: {
|
||||
key: "agent:main:owned",
|
||||
kind: "direct",
|
||||
updatedAt: 1,
|
||||
createdBy: { id: "profile-ada", label: "Ada" },
|
||||
},
|
||||
}).createdBy,
|
||||
).toEqual({ id: "profile-ada", label: "Ada" });
|
||||
|
||||
expect(
|
||||
buildGatewaySessionEventFields({
|
||||
sessionRow: { key: "agent:main:ownerless", kind: "direct", updatedAt: 2 },
|
||||
}).createdBy,
|
||||
).toBeNull();
|
||||
});
|
||||
@@ -27,6 +27,7 @@ export function buildGatewaySessionEventFields(params: {
|
||||
return {
|
||||
updatedAt: sessionRow.updatedAt ?? undefined,
|
||||
sessionId: sessionRow.sessionId,
|
||||
createdBy: sessionRow.createdBy ?? null,
|
||||
kind: sessionRow.kind,
|
||||
channel: sessionRow.channel,
|
||||
subject: sessionRow.subject,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
// Gateway session reset/delete service.
|
||||
// Rotates transcripts and coordinates lifecycle cleanup across runtimes/hooks.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ErrorCodes, errorShape } from "../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
type SessionCreatorIdentity,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import { getAcpSessionManager } from "../acp/control-plane/manager.js";
|
||||
import { getAcpRuntimeBackend } from "../acp/runtime/registry.js";
|
||||
import {
|
||||
@@ -908,6 +912,7 @@ export async function performGatewaySessionReset(params: {
|
||||
clearSpawnedCwd?: boolean;
|
||||
reason: "new" | "reset";
|
||||
commandSource: string;
|
||||
createdBy?: SessionCreatorIdentity;
|
||||
assertCurrent?: () => void;
|
||||
onCommitted?: (commit: { key: string; sessionId: string }) => void;
|
||||
}): Promise<
|
||||
@@ -1190,6 +1195,7 @@ export async function performGatewaySessionReset(params: {
|
||||
});
|
||||
const nextEntry: SessionEntry = {
|
||||
sessionId: nextSessionId,
|
||||
...(params.createdBy ? { createdBy: { ...params.createdBy } } : {}),
|
||||
sessionFile,
|
||||
updatedAt: now,
|
||||
systemSent: false,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, it } from "vitest";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { listSessionsFromStore } from "./session-utils.js";
|
||||
|
||||
it("returns the complete deterministic creator facet independently of pagination", () => {
|
||||
const store: Record<string, SessionEntry> = {
|
||||
"agent:main:ada": {
|
||||
createdBy: { id: "profile-ada", label: "Ada" },
|
||||
sessionId: "session-ada",
|
||||
updatedAt: 2,
|
||||
},
|
||||
"agent:main:bob": {
|
||||
createdBy: { id: "profile-bob", label: "Bob" },
|
||||
sessionId: "session-bob",
|
||||
updatedAt: 1,
|
||||
},
|
||||
};
|
||||
|
||||
const result = listSessionsFromStore({
|
||||
cfg: {} as OpenClawConfig,
|
||||
storePath: "/tmp/openclaw-session-creators",
|
||||
store,
|
||||
opts: { limit: 1 },
|
||||
});
|
||||
|
||||
expect(result.count).toBe(1);
|
||||
expect(result.totalCount).toBe(2);
|
||||
expect(result.creators).toEqual([
|
||||
{ id: "profile-ada", label: "Ada" },
|
||||
{ id: "profile-bob", label: "Bob" },
|
||||
]);
|
||||
|
||||
const filtered = listSessionsFromStore({
|
||||
cfg: {} as OpenClawConfig,
|
||||
storePath: "/tmp/openclaw-session-creators",
|
||||
store,
|
||||
opts: { creatorId: "profile-bob", limit: 1 },
|
||||
});
|
||||
expect(filtered.sessions.map((row) => row.key)).toEqual(["agent:main:bob"]);
|
||||
expect(filtered.creators).toEqual(result.creators);
|
||||
});
|
||||
@@ -7,7 +7,10 @@ import {
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import type { SessionsListParams } from "../../packages/gateway-protocol/src/index.js";
|
||||
import type {
|
||||
SessionCreatorIdentity,
|
||||
SessionsListParams,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
readAcpSessionMeta,
|
||||
readAcpSessionMetaForEntry,
|
||||
@@ -2234,6 +2237,7 @@ export function buildGatewaySessionRow(params: {
|
||||
|
||||
return {
|
||||
key,
|
||||
createdBy: entry?.createdBy,
|
||||
spawnedBy: subagentOwner || entry?.spawnedBy,
|
||||
swarmGroupId: entry?.swarmGroupId,
|
||||
spawnedWorkspaceDir: entry?.spawnedWorkspaceDir,
|
||||
@@ -2535,6 +2539,7 @@ const SESSIONS_LIST_DEFAULT_LIMIT = 100;
|
||||
|
||||
type SessionEntrySelection = {
|
||||
entries: SessionEntryPair[];
|
||||
creatorEntries: SessionEntryPair[];
|
||||
totalCount: number;
|
||||
limitApplied?: number;
|
||||
offset: number;
|
||||
@@ -2724,7 +2729,11 @@ function selectSessionEntries(params: {
|
||||
getRowContext?: SessionListRowContextProvider;
|
||||
defaultLimit?: number;
|
||||
}): SessionEntrySelection {
|
||||
const filtered = filterSessionEntries(params);
|
||||
const creatorEntries = filterSessionEntries(params);
|
||||
const creatorId = normalizeOptionalString(params.opts.creatorId);
|
||||
const filtered = creatorId
|
||||
? creatorEntries.filter(([, entry]) => entry.createdBy?.id === creatorId)
|
||||
: creatorEntries;
|
||||
const limit = resolveSessionsListLimit(params.opts, params.defaultLimit);
|
||||
const offset = resolveSessionsListOffset(params.opts);
|
||||
const windowLimit = resolveSessionsListWindowLimit(limit, offset);
|
||||
@@ -2735,6 +2744,7 @@ function selectSessionEntries(params: {
|
||||
const hasMore = nextOffset < filtered.length;
|
||||
return {
|
||||
entries,
|
||||
creatorEntries,
|
||||
totalCount: filtered.length,
|
||||
limitApplied: limit,
|
||||
offset,
|
||||
@@ -2743,6 +2753,27 @@ function selectSessionEntries(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function listSessionCreatorIdentities(
|
||||
entries: readonly SessionEntryPair[],
|
||||
): SessionCreatorIdentity[] {
|
||||
const creators = new Map<string, SessionCreatorIdentity>();
|
||||
for (const [, entry] of entries) {
|
||||
const id = normalizeOptionalString(entry.createdBy?.id);
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
const label = normalizeOptionalString(entry.createdBy?.label);
|
||||
const existing = creators.get(id);
|
||||
if (!existing || (label && (!existing.label || label.localeCompare(existing.label) < 0))) {
|
||||
creators.set(id, { id, ...(label ? { label } : {}) });
|
||||
}
|
||||
}
|
||||
return [...creators.values()].toSorted((a, b) => {
|
||||
const byLabel = (a.label ?? a.id).localeCompare(b.label ?? b.id);
|
||||
return byLabel || a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function filterAndSortSessionEntries(params: {
|
||||
cfg: OpenClawConfig;
|
||||
store: Record<string, SessionEntry>;
|
||||
@@ -2785,7 +2816,8 @@ export function listSessionsFromStore(params: {
|
||||
: undefined,
|
||||
defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT,
|
||||
});
|
||||
const { entries, totalCount, limitApplied, offset, nextOffset, hasMore } = selection;
|
||||
const { entries, creatorEntries, totalCount, limitApplied, offset, nextOffset, hasMore } =
|
||||
selection;
|
||||
const fullRowContext =
|
||||
rowContext || hasSpawnedByFilter || entries.length > SESSIONS_LIST_YIELD_BATCH_SIZE
|
||||
? getRowContext()
|
||||
@@ -2829,6 +2861,7 @@ export function listSessionsFromStore(params: {
|
||||
offset: offset > 0 ? offset : undefined,
|
||||
nextOffset,
|
||||
hasMore,
|
||||
creators: listSessionCreatorIdentities(creatorEntries),
|
||||
defaults: getSessionDefaults(cfg, params.modelCatalog, { allowPluginNormalization: false }),
|
||||
sessions,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Keeps server methods and Control UI payloads aligned.
|
||||
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { SessionPlacement } from "../../packages/gateway-protocol/src/index.js";
|
||||
import type { SessionCreatorIdentity } from "../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type { SessionObserverDigest } from "../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type { QueueMode } from "../auto-reply/reply/queue/types.js";
|
||||
import type { ChatType } from "../channels/chat-type.js";
|
||||
@@ -45,6 +46,7 @@ type SessionCompactionCheckpointPreview = Pick<
|
||||
|
||||
export type GatewaySessionRow = {
|
||||
key: string;
|
||||
createdBy?: SessionCreatorIdentity;
|
||||
spawnedBy?: string;
|
||||
/** Collector swarm group that owns this child session, when applicable. */
|
||||
swarmGroupId?: string;
|
||||
|
||||
@@ -14,6 +14,7 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
|
||||
"pluginExtensionSlotKeys",
|
||||
"pluginNextTurnInjections",
|
||||
"sessionId",
|
||||
"createdBy",
|
||||
"lifecycleRevision",
|
||||
"updatedAt",
|
||||
"archivedAt",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { SessionCreatorIdentity } from "../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
|
||||
/** Agent identity fields returned by gateway session listing APIs. */
|
||||
type GatewayAgentIdentity = {
|
||||
name?: string;
|
||||
@@ -61,6 +63,8 @@ export type SessionsListResultBase<TDefaults, TRow> = {
|
||||
offset?: number;
|
||||
nextOffset?: number | null;
|
||||
hasMore?: boolean;
|
||||
/** Complete creator facet for the filtered result, independent of pagination. */
|
||||
creators?: SessionCreatorIdentity[];
|
||||
defaults: TDefaults;
|
||||
sessions: TRow[];
|
||||
};
|
||||
|
||||
@@ -252,6 +252,15 @@ function migrateOpenClawAgentSchema(db: DatabaseSync): void {
|
||||
backfillTranscriptMutationWatermarks(db);
|
||||
}
|
||||
|
||||
function ensureAdditiveSessionEntryColumns(db: DatabaseSync): void {
|
||||
const columns = readSqliteTableColumns(db, "session_entries");
|
||||
if (columns && !columns.has("created_by_json")) {
|
||||
// This nullable projection is safe for older readers and intentionally
|
||||
// stays outside the schema-version migration ladder.
|
||||
db.exec("ALTER TABLE session_entries ADD COLUMN created_by_json TEXT;");
|
||||
}
|
||||
}
|
||||
|
||||
/** Backfill one generation token without copying or rewriting transcript rows. */
|
||||
function migrateSessionTranscriptGenerations(db: DatabaseSync, previousVersion: number): void {
|
||||
if (previousVersion >= 13) {
|
||||
@@ -513,6 +522,7 @@ function ensureAgentSchema(db: DatabaseSync, agentId: string, pathname: string):
|
||||
dropLegacySessionTranscriptSearchSchema(db);
|
||||
migrateMemoryIndexSourcesIdentity(db);
|
||||
migrateOpenClawAgentSchema(db);
|
||||
ensureAdditiveSessionEntryColumns(db);
|
||||
db.exec(
|
||||
previousVersion === OPENCLAW_AGENT_SCHEMA_VERSION
|
||||
? OPENCLAW_AGENT_SCHEMA_WITHOUT_BOARD_SQL
|
||||
|
||||
+1
@@ -186,6 +186,7 @@ export interface SessionConversations {
|
||||
}
|
||||
|
||||
export interface SessionEntries {
|
||||
created_by_json: string | null;
|
||||
entry_json: string;
|
||||
session_id: string;
|
||||
session_key: string;
|
||||
|
||||
@@ -1820,6 +1820,30 @@ describe("openclaw agent database", () => {
|
||||
expect(journalMode?.journal_mode?.toLowerCase()).toBe("wal");
|
||||
});
|
||||
|
||||
it("lazy-ensures the additive session creator column without a version bump", () => {
|
||||
const stateDir = createTempStateDir();
|
||||
const env = { OPENCLAW_STATE_DIR: stateDir };
|
||||
const database = openOpenClawAgentDatabase({ agentId: "worker-1", env });
|
||||
const databasePath = database.path;
|
||||
const schemaVersion = readSqliteNumberPragma(database.db, "user_version");
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
|
||||
const { DatabaseSync } = requireNodeSqlite();
|
||||
const legacy = new DatabaseSync(databasePath);
|
||||
try {
|
||||
legacy.exec("ALTER TABLE session_entries DROP COLUMN created_by_json;");
|
||||
} finally {
|
||||
legacy.close();
|
||||
}
|
||||
|
||||
const reopened = openOpenClawAgentDatabase({ agentId: "worker-1", env });
|
||||
const columns = reopened.db.prepare("PRAGMA table_info(session_entries)").all() as Array<{
|
||||
name: string;
|
||||
}>;
|
||||
expect(columns.map((column) => column.name)).toContain("created_by_json");
|
||||
expect(readSqliteNumberPragma(reopened.db, "user_version")).toBe(schemaVersion);
|
||||
});
|
||||
|
||||
it("backfills per-entry status while migrating a v6 agent database", () => {
|
||||
const stateDir = createTempStateDir();
|
||||
const env = { OPENCLAW_STATE_DIR: stateDir };
|
||||
|
||||
@@ -169,6 +169,7 @@ CREATE TABLE IF NOT EXISTS session_entries (
|
||||
entry_json TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
status TEXT CHECK (status IS NULL OR status IN ('running', 'done', 'failed', 'killed', 'timeout')),
|
||||
created_by_json TEXT,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
||||
) STRICT;
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ CREATE TABLE IF NOT EXISTS session_entries (
|
||||
entry_json TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
status TEXT CHECK (status IS NULL OR status IN ('running', 'done', 'failed', 'killed', 'timeout')),
|
||||
created_by_json TEXT,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
||||
) STRICT;
|
||||
|
||||
|
||||
@@ -492,6 +492,7 @@ type SessionCompactionCheckpointPreview = Pick<
|
||||
|
||||
export type GatewaySessionRow = {
|
||||
key: string;
|
||||
createdBy?: import("../../../packages/gateway-protocol/src/schema/sessions.js").SessionCreatorIdentity;
|
||||
spawnedBy?: string;
|
||||
/** Collector swarm group that owns this child session, when applicable. */
|
||||
swarmGroupId?: string;
|
||||
|
||||
@@ -101,6 +101,7 @@ type SessionCatalogGroupsParams = {
|
||||
loadingMoreCatalogIds: ReadonlySet<string>;
|
||||
projectGrouping: CatalogProjectGrouping;
|
||||
liveRows: readonly GatewaySessionRow[];
|
||||
creatorId?: string | null;
|
||||
renderLiveRow: (row: GatewaySessionRow, display: CatalogBackingSessionDisplay) => unknown;
|
||||
onToggleSection: (sectionId: string) => void;
|
||||
onToggleProjectGrouping: () => void;
|
||||
@@ -167,7 +168,15 @@ export function renderSessionCatalogGroups(params: SessionCatalogGroupsParams) {
|
||||
const sectionId = `catalog:${catalog.id}`;
|
||||
const collapsed = params.collapsedSections.has(sectionId);
|
||||
const hosts = catalog.hosts;
|
||||
const visibleHosts = hosts.filter((host) => host.sessions.length > 0);
|
||||
const visibleHosts: SessionCatalogHost[] = [];
|
||||
for (const host of hosts) {
|
||||
const sessions = host.sessions.filter(
|
||||
(session) => !params.creatorId || session.createdBy?.id === params.creatorId,
|
||||
);
|
||||
if (sessions.length > 0) {
|
||||
visibleHosts.push(sessions.length === host.sessions.length ? host : { ...host, sessions });
|
||||
}
|
||||
}
|
||||
const rows = visibleHosts.flatMap((host) =>
|
||||
host.sessions.map((session) => ({ host, session })),
|
||||
);
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
type SidebarSessionStatusFilter,
|
||||
type SidebarSessionsScrollState,
|
||||
} from "./app-sidebar-session-types.ts";
|
||||
|
||||
/** Gateway-backed session and external-catalog synchronization. */
|
||||
export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCatalogDataElement {
|
||||
@state() protected visibleSessionLimit = SIDEBAR_SESSION_PAGE_SIZE;
|
||||
@@ -45,7 +44,6 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
|
||||
|
||||
protected sessionRowsByAgent: Record<string, SessionsListResult["sessions"]> = {};
|
||||
protected sessionCreatedOrder = new Map<string, number>();
|
||||
|
||||
private readonly subscriptions = new SubscriptionsController(this);
|
||||
private sessionsSource: SessionCapability | null = null;
|
||||
private childSessionGeneration = 0;
|
||||
@@ -59,8 +57,7 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
|
||||
private gatewaySource: ApplicationContext<RouteId>["gateway"] | null = null;
|
||||
private gatewayClient: GatewayBrowserClient | null = null;
|
||||
private gatewayConnected = false;
|
||||
// Mutation completions belong to one context/capability/connection epoch.
|
||||
// Bumping this prevents old failures or batch tails crossing a reconnect.
|
||||
// Bind mutation completions to one epoch so stale failures cannot cross reconnects.
|
||||
private sessionMutationEpoch = 0;
|
||||
private sessionsScrollElement: HTMLElement | null = null;
|
||||
private sessionsScrollResizeObserver: ResizeObserver | null = null;
|
||||
@@ -182,8 +179,7 @@ export abstract class AppSidebarSessionDataElement extends AppSidebarSessionCata
|
||||
}
|
||||
}
|
||||
|
||||
// Reading scrollHeight/scrollTop inside updated() forces a layout flush per
|
||||
// render; one rAF-coalesced read rides the layout computed for paint anyway.
|
||||
// One rAF-coalesced scroll read rides paint layout instead of flushing every update.
|
||||
private scheduleSessionsScrollStateSync() {
|
||||
if (this.sessionsScrollStateFrame !== null) {
|
||||
return;
|
||||
|
||||
@@ -139,7 +139,8 @@ export abstract class AppSidebarSessionListElement extends AppSidebarSessionNarr
|
||||
aria-describedby=${metaId ?? nothing}
|
||||
@click=${(event: MouseEvent) => this.handleSessionRowClick(event, session)}
|
||||
>
|
||||
<span class="sidebar-session-indicator">${leadingIndicator}</span>
|
||||
<span class="sidebar-session-indicator">${leadingIndicator}</span
|
||||
>${this.renderSidebarSessionOwnerChip(session)}
|
||||
<span class="sidebar-recent-session__text">
|
||||
<span class="sidebar-recent-session__name hover-marquee"
|
||||
>${session.archived
|
||||
@@ -559,10 +560,8 @@ export abstract class AppSidebarSessionListElement extends AppSidebarSessionNarr
|
||||
}
|
||||
return this.renderSessionSection(section, options.codingTrailing ?? nothing);
|
||||
}
|
||||
// Threads hides its bare header when empty, except while a draft needs
|
||||
// a home or a session drag needs the unpin drop target. Empty custom
|
||||
// categories keep rendering: they are user-created containers and the
|
||||
// "New group…" / drag-into-group flows depend on seeing them.
|
||||
// Threads hides its bare empty header; unfiltered custom categories stay
|
||||
// visible because creation and drag flows depend on them as drop targets.
|
||||
if (
|
||||
section.id === "ungrouped" &&
|
||||
section.totalRowCount === 0 &&
|
||||
@@ -653,6 +652,7 @@ export abstract class AppSidebarSessionListElement extends AppSidebarSessionNarr
|
||||
`
|
||||
: nothing}
|
||||
<div class="sidebar-recent-sessions" aria-label=${titleForRoute("sessions")}>
|
||||
${this.renderSidebarSessionCreatorFilter()}
|
||||
${this.renderSessionListBody(visibleSessions, {
|
||||
showDraft:
|
||||
Boolean(this.draftSessionAgentId) &&
|
||||
@@ -690,6 +690,7 @@ export abstract class AppSidebarSessionListElement extends AppSidebarSessionNarr
|
||||
...(this.sessionsResult?.sessions ?? []),
|
||||
...Object.values(this.sessionRowsByAgent).flat(),
|
||||
],
|
||||
creatorId: this.activeSessionCreatorId,
|
||||
renderLiveRow: (row, display) =>
|
||||
this.renderRecentSession(navigationState.toSidebarSession(row), display),
|
||||
onToggleSection: (sectionId) => this.toggleSessionSection(sectionId),
|
||||
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
adoptedCatalogSessionKeys,
|
||||
formatSidebarTimestamp,
|
||||
} from "./app-sidebar-session-catalogs.ts";
|
||||
import { AppSidebarSessionProjectionElement } from "./app-sidebar-session-projection.ts";
|
||||
import { AppSidebarSessionOwnershipElement } from "./app-sidebar-session-ownership.ts";
|
||||
import { projectSessionTree } from "./app-sidebar-session-tree.ts";
|
||||
import {
|
||||
limitSidebarSessionRows,
|
||||
@@ -55,7 +55,7 @@ import {
|
||||
import { isStoppableCloudWorkerPlacement } from "./session-row-badges.ts";
|
||||
|
||||
/** Session-row projection, selection, sorting, and agent scope navigation. */
|
||||
export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessionProjectionElement {
|
||||
export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessionOwnershipElement {
|
||||
@state() protected selectedSessionKeys: ReadonlySet<string> = new Set();
|
||||
@state() protected expandedChildSessionKeys: ReadonlySet<string> = new Set();
|
||||
@state() protected collapsedActiveChildSessionKeys: ReadonlySet<string> = new Set();
|
||||
@@ -148,6 +148,7 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
|
||||
}
|
||||
return {
|
||||
key: row.key,
|
||||
createdBy: row.createdBy,
|
||||
// The sidebar's zone structure already says what forked from what;
|
||||
// a "Subagent:" prefix on named threads is noise (other surfaces keep it).
|
||||
label: resolveSessionDisplayName(row.key, row, {
|
||||
@@ -249,7 +250,11 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
|
||||
const sections = groupSidebarSessionRows(rows, {
|
||||
grouping: this.sessionsGrouping,
|
||||
knownGroups: this.sessionsGrouping === "category" ? this.knownSessionGroups() : undefined,
|
||||
}).filter((section) => section.id !== "pinned");
|
||||
}).filter(
|
||||
(section) =>
|
||||
section.id !== "pinned" &&
|
||||
!this.hideEmptyCreatorFilteredGroup(section.category, section.rows.length),
|
||||
);
|
||||
const expandedRows = sections.flatMap((section) =>
|
||||
this.isSessionSectionCollapsed(section.id) ? [] : section.rows,
|
||||
);
|
||||
@@ -612,7 +617,7 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
|
||||
// `adopted` holds only catalog-bound keys (adoptedCatalogSessionKeys), not
|
||||
// fetched child rows: a catalog-adopted promoted child intentionally
|
||||
// renders as its live row inside the Coding catalog, never as a thread.
|
||||
return projectSessionTree({
|
||||
const projected = projectSessionTree({
|
||||
roots: orderedRootRows.filter((row) => !adopted.has(row.key)),
|
||||
agentRows: rows,
|
||||
childRowsByParent: this.childSessionRowsByParent,
|
||||
@@ -620,6 +625,9 @@ export abstract class AppSidebarSessionNavigationElement extends AppSidebarSessi
|
||||
knownSessionAttention: this.knownSessionAttention(),
|
||||
toSidebarSession: navigationState.toSidebarSession,
|
||||
});
|
||||
const creatorFacet =
|
||||
rows === this.sessionsResult?.sessions ? this.sessionsResult.creators : undefined;
|
||||
return this.applySessionCreatorFilter(projected, rows, creatorFacet);
|
||||
}
|
||||
|
||||
/** Canonical main-session key for the selected (or given) agent. */
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { state } from "lit/decorators.js";
|
||||
import { AppSidebarSessionProjectionElement } from "./app-sidebar-session-projection.ts";
|
||||
import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
|
||||
import {
|
||||
listSessionCreators,
|
||||
renderSessionCreatorFilter,
|
||||
renderSessionOwnerChip,
|
||||
type SessionCreatedBy,
|
||||
} from "./session-owner-chip.ts";
|
||||
|
||||
/** Creator attribution, solo dormancy, and filtering shared by sidebar session surfaces. */
|
||||
export abstract class AppSidebarSessionOwnershipElement extends AppSidebarSessionProjectionElement {
|
||||
@state() protected sessionCreatorFilterId: string | null = null;
|
||||
|
||||
protected sessionCreatorOptions: readonly SessionCreatedBy[] = [];
|
||||
protected activeSessionCreatorId: string | null = null;
|
||||
protected sessionCreatorFilterActive = false;
|
||||
protected sessionOwnershipVisible = false;
|
||||
|
||||
override updated() {
|
||||
super.updated();
|
||||
const selectedId = this.sessionCreatorFilterId;
|
||||
const creators = this.sessionsResult?.creators;
|
||||
if (
|
||||
selectedId &&
|
||||
creators &&
|
||||
(creators.length < 2 || !creators.some((creator) => creator.id === selectedId))
|
||||
) {
|
||||
this.sessionCreatorFilterId = null;
|
||||
void this.context?.sessions.setCreatorFilter(null);
|
||||
}
|
||||
}
|
||||
|
||||
protected applySessionCreatorFilter(
|
||||
projected: readonly SidebarRecentSession[],
|
||||
creatorRows: readonly { createdBy?: SessionCreatedBy }[] = [],
|
||||
creatorFacet?: readonly SessionCreatedBy[],
|
||||
): SidebarRecentSession[] {
|
||||
const flattened: SidebarRecentSession[] = [];
|
||||
const pending = [...projected];
|
||||
while (pending.length > 0) {
|
||||
const row = pending.shift();
|
||||
if (row) {
|
||||
flattened.push(row);
|
||||
pending.push(...row.children);
|
||||
}
|
||||
}
|
||||
const completeFacet = creatorFacet ?? this.sessionsResult?.creators;
|
||||
this.sessionCreatorOptions = listSessionCreators([
|
||||
...(completeFacet ?? []).map((createdBy) => ({ createdBy })),
|
||||
...flattened,
|
||||
...creatorRows,
|
||||
]);
|
||||
this.sessionOwnershipVisible = this.sessionCreatorOptions.length >= 2;
|
||||
const creatorId = this.sessionOwnershipVisible
|
||||
? this.sessionCreatorOptions.some((creator) => creator.id === this.sessionCreatorFilterId)
|
||||
? this.sessionCreatorFilterId
|
||||
: null
|
||||
: null;
|
||||
this.sessionCreatorFilterActive = creatorId !== null;
|
||||
this.activeSessionCreatorId = creatorId;
|
||||
if (!creatorId) {
|
||||
return [...projected];
|
||||
}
|
||||
const filterTree = (treeRows: readonly SidebarRecentSession[]): SidebarRecentSession[] => {
|
||||
const filtered: SidebarRecentSession[] = [];
|
||||
for (const row of treeRows) {
|
||||
const children = filterTree(row.children);
|
||||
if (row.createdBy?.id === creatorId) {
|
||||
filtered.push({ ...row, children });
|
||||
} else {
|
||||
for (const child of children) {
|
||||
filtered.push({ ...child, isChild: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
};
|
||||
return filterTree(projected);
|
||||
}
|
||||
|
||||
protected renderSidebarSessionOwnerChip(session: SidebarRecentSession) {
|
||||
return renderSessionOwnerChip(
|
||||
this.sessionOwnershipVisible ? session.createdBy : undefined,
|
||||
"row",
|
||||
);
|
||||
}
|
||||
|
||||
protected renderSidebarSessionCreatorFilter() {
|
||||
return renderSessionCreatorFilter({
|
||||
creators: this.sessionOwnershipVisible ? this.sessionCreatorOptions : [],
|
||||
selectedId: this.sessionCreatorFilterActive ? this.sessionCreatorFilterId : null,
|
||||
onChange: (creatorId) => {
|
||||
this.sessionCreatorFilterId = creatorId;
|
||||
void this.context?.sessions.setCreatorFilter(creatorId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected hideEmptyCreatorFilteredGroup(category: string | undefined, rowCount: number): boolean {
|
||||
return this.sessionCreatorFilterActive && Boolean(category) && rowCount === 0;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { SessionCatalogPullRequestSummary } from "../../../packages/gateway-protocol/src/schema/sessions-catalog.js";
|
||||
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type { SessionCreatorIdentity } from "../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type { SessionAgentAttentionIconId } from "../../../packages/gateway-protocol/src/session-icon.js";
|
||||
import type { GatewayBrowserClient } from "../api/gateway.ts";
|
||||
import type { SessionRunStatus } from "../api/types.ts";
|
||||
@@ -50,6 +51,7 @@ export function sidebarSessionAttentionPriority(attention: SidebarSessionAttenti
|
||||
|
||||
export type SidebarRecentSession = {
|
||||
key: string;
|
||||
createdBy?: SessionCreatorIdentity;
|
||||
label: string;
|
||||
meta: string;
|
||||
/** Compact repo/branch/node line for work sessions. */
|
||||
|
||||
@@ -17,5 +17,6 @@ import "../test-helpers/app-sidebar-cases/narration.ts";
|
||||
import "../test-helpers/app-sidebar-cases/pull-request-state.ts";
|
||||
import "../test-helpers/app-sidebar-cases/sidebar-scroll.ts";
|
||||
import "../test-helpers/app-sidebar-cases/sessions.ts";
|
||||
import "../test-helpers/app-sidebar-cases/session-ownership.ts";
|
||||
import "../test-helpers/app-sidebar-cases/session-list-sections.ts";
|
||||
import "../test-helpers/app-sidebar-cases/sidebar-zone.ts";
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { property } from "lit/decorators.js";
|
||||
import type { SessionCreatorIdentity } from "../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import { t } from "../i18n/index.ts";
|
||||
import { OpenClawLightDomElement } from "../lit/openclaw-element.ts";
|
||||
|
||||
export type SessionCreatedBy = SessionCreatorIdentity;
|
||||
|
||||
export function listSessionCreators(
|
||||
sessions: readonly { createdBy?: SessionCreatedBy }[],
|
||||
): SessionCreatedBy[] {
|
||||
const creators = new Map<string, SessionCreatedBy>();
|
||||
for (const session of sessions) {
|
||||
const id = session.createdBy?.id.trim();
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
const label = session.createdBy?.label?.trim();
|
||||
const existing = creators.get(id);
|
||||
if (!existing || (label && (!existing.label || label.localeCompare(existing.label) < 0))) {
|
||||
creators.set(id, { id, ...(label ? { label } : {}) });
|
||||
}
|
||||
}
|
||||
return [...creators.values()].toSorted((a, b) => {
|
||||
const byLabel = (a.label ?? a.id).localeCompare(b.label ?? b.id);
|
||||
return byLabel || a.id.localeCompare(b.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function renderSessionOwnerChip(
|
||||
createdBy: SessionCreatedBy | null | undefined,
|
||||
size: "row" | "header",
|
||||
) {
|
||||
return createdBy
|
||||
? html`<openclaw-session-owner-chip
|
||||
.createdBy=${createdBy}
|
||||
size=${size}
|
||||
></openclaw-session-owner-chip>`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
export function renderSessionCreatorFilter(params: {
|
||||
creators: readonly SessionCreatedBy[];
|
||||
selectedId: string | null;
|
||||
onChange: (creatorId: string | null) => void;
|
||||
}) {
|
||||
if (params.creators.length < 2) {
|
||||
return nothing;
|
||||
}
|
||||
return html`<label class="sidebar-session-creator-filter">
|
||||
<span>${t("sessionsView.filterByCreator")}</span>
|
||||
<select
|
||||
aria-label=${t("sessionsView.filterByCreator")}
|
||||
.value=${params.selectedId ?? ""}
|
||||
@change=${(event: Event) =>
|
||||
params.onChange((event.currentTarget as HTMLSelectElement).value || null)}
|
||||
>
|
||||
<option value="">${t("sessionsView.allCreators")}</option>
|
||||
${params.creators.map(
|
||||
(creator) => html`<option value=${creator.id}>${creator.label ?? creator.id}</option>`,
|
||||
)}
|
||||
</select>
|
||||
</label>`;
|
||||
}
|
||||
|
||||
function ownerInitials(createdBy: SessionCreatedBy): string {
|
||||
const source = createdBy.label?.trim() || createdBy.id.trim();
|
||||
if (!source) {
|
||||
return "";
|
||||
}
|
||||
const parts = source
|
||||
.replace(/@.*$/u, "")
|
||||
.split(/[\s._-]+/u)
|
||||
.filter(Boolean);
|
||||
const initials = ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase();
|
||||
return initials || source[0]!.toUpperCase();
|
||||
}
|
||||
|
||||
// Deterministic hue per identity so a person keeps one color everywhere.
|
||||
function ownerHue(id: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < id.length; i += 1) {
|
||||
hash = (hash * 31 + id.charCodeAt(i)) | 0;
|
||||
}
|
||||
return Math.abs(hash) % 360;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanent session-owner avatar. Ownership is provenance, not presence:
|
||||
* this chip is solid and never pulses/expires, in deliberate contrast to the
|
||||
* translucent, ring-styled live-presence chips. Render only when the gateway
|
||||
* has 2+ distinct creator identities (solo mode shows no attribution chrome).
|
||||
*/
|
||||
class SessionOwnerChip extends OpenClawLightDomElement {
|
||||
@property({ attribute: false }) createdBy: SessionCreatedBy | null = null;
|
||||
@property({ type: String }) size: "row" | "header" = "row";
|
||||
|
||||
override render() {
|
||||
const createdBy = this.createdBy;
|
||||
if (!createdBy) {
|
||||
return nothing;
|
||||
}
|
||||
const initials = ownerInitials(createdBy);
|
||||
if (!initials) {
|
||||
return nothing;
|
||||
}
|
||||
const title = createdBy.label || createdBy.id;
|
||||
const accessibleLabel = t("sessionsView.createdBy", { name: title });
|
||||
return html`
|
||||
<span
|
||||
class="session-owner-chip session-owner-chip--${this.size}"
|
||||
style="--owner-hue: ${ownerHue(createdBy.id)}"
|
||||
role="img"
|
||||
aria-label=${accessibleLabel}
|
||||
title=${accessibleLabel}
|
||||
>${initials}</span
|
||||
>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-session-owner-chip")) {
|
||||
customElements.define("openclaw-session-owner-chip", SessionOwnerChip);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"openclaw-session-owner-chip": SessionOwnerChip;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Control UI E2E tests cover session ownership dormancy and creator filtering.
|
||||
import { chromium, type Browser, type Page } from "playwright";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, 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;
|
||||
|
||||
let browser: Browser;
|
||||
let page: Page | undefined;
|
||||
let server: ControlUiE2eServer | undefined;
|
||||
|
||||
function sessionsList(creators: [string, string]) {
|
||||
const creatorFacet = [
|
||||
{ id: creators[0], label: "Ada" },
|
||||
...(creators[1] === creators[0] ? [] : [{ id: creators[1], label: "Bob" }]),
|
||||
];
|
||||
return {
|
||||
count: 2,
|
||||
creators: creatorFacet,
|
||||
defaults: { contextTokens: null, model: null, modelProvider: null },
|
||||
path: "",
|
||||
sessions: [
|
||||
{
|
||||
key: "agent:main:ada",
|
||||
kind: "direct",
|
||||
label: "Ada research",
|
||||
category: "Research",
|
||||
createdBy: { id: creators[0], label: "Ada" },
|
||||
updatedAt: 2,
|
||||
},
|
||||
{
|
||||
key: "agent:main:bob",
|
||||
kind: "direct",
|
||||
label: "Bob operations",
|
||||
category: "Operations",
|
||||
createdBy: { id: creators[1], label: creators[1] === creators[0] ? "Ada" : "Bob" },
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
ts: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describeControlUiE2e("Control UI session ownership", () => {
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({ executablePath: chromiumExecutablePath });
|
||||
try {
|
||||
server = await startControlUiE2eServer();
|
||||
} catch (error) {
|
||||
await browser.close();
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await page
|
||||
?.context()
|
||||
.close()
|
||||
.catch(() => {});
|
||||
page = undefined;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close().catch(() => {});
|
||||
await server?.close();
|
||||
});
|
||||
|
||||
it("shows permanent owner chips and filters existing custom groups", async () => {
|
||||
const context = await browser.newContext({ viewport: { height: 800, width: 1200 } });
|
||||
const currentPage = await context.newPage();
|
||||
page = currentPage;
|
||||
const gateway = await installMockGateway(currentPage, {
|
||||
sessionKey: "agent:main:ada",
|
||||
historyMessages: [{ role: "assistant", content: [{ type: "text", text: "Ready." }] }],
|
||||
methodResponses: { "sessions.list": sessionsList(["profile-ada", "profile-bob"]) },
|
||||
});
|
||||
|
||||
await currentPage.goto(`${server?.baseUrl ?? ""}chat`);
|
||||
await currentPage.getByText("Ada research", { exact: true }).first().waitFor();
|
||||
await currentPage.getByText("Bob operations", { exact: true }).first().waitFor();
|
||||
await currentPage.locator('[data-session-key="agent:main:ada"] a').click();
|
||||
await currentPage.getByText("Ready.", { exact: true }).waitFor();
|
||||
await expect.poll(() => currentPage.locator("openclaw-session-owner-chip").count()).toBe(3);
|
||||
|
||||
await currentPage.getByLabel("Filter by creator").selectOption("profile-ada");
|
||||
await currentPage.getByText("Ada research", { exact: true }).first().waitFor();
|
||||
await expect
|
||||
.poll(() => currentPage.locator('[data-session-key="agent:main:bob"]').count())
|
||||
.toBe(0);
|
||||
expect(await currentPage.locator('[data-session-section="category:Research"]').count()).toBe(1);
|
||||
expect(await currentPage.locator('[data-session-section="category:Operations"]').count()).toBe(
|
||||
0,
|
||||
);
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await gateway.getRequests("sessions.list")).some(
|
||||
(request) =>
|
||||
(request.params as { creatorId?: unknown } | undefined)?.creatorId === "profile-ada",
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
it("renders zero ownership chrome for a single creator", async () => {
|
||||
const context = await browser.newContext({ viewport: { height: 800, width: 1200 } });
|
||||
const currentPage = await context.newPage();
|
||||
page = currentPage;
|
||||
await installMockGateway(currentPage, {
|
||||
sessionKey: "agent:main:ada",
|
||||
historyMessages: [{ role: "assistant", content: [{ type: "text", text: "Ready." }] }],
|
||||
methodResponses: { "sessions.list": sessionsList(["profile-ada", "profile-ada"]) },
|
||||
});
|
||||
|
||||
await currentPage.goto(`${server?.baseUrl ?? ""}chat`);
|
||||
await currentPage.getByText("Ada research", { exact: true }).first().waitFor();
|
||||
await currentPage.getByText("Bob operations", { exact: true }).first().waitFor();
|
||||
await currentPage.locator('[data-session-key="agent:main:ada"] a').click();
|
||||
await currentPage.getByText("Ready.", { exact: true }).waitFor();
|
||||
expect(await currentPage.getByLabel("Filter by creator").count()).toBe(0);
|
||||
expect(await currentPage.locator("openclaw-session-owner-chip").count()).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -584,6 +584,9 @@ export const en: TranslationMap = {
|
||||
active: "Updated within",
|
||||
limit: "Limit",
|
||||
filters: "Filters",
|
||||
createdBy: "Created by {name}",
|
||||
filterByCreator: "Filter by creator",
|
||||
allCreators: "All people",
|
||||
filterControls: "Thread filters",
|
||||
sourceFilters: "Thread source filters",
|
||||
global: "Global",
|
||||
|
||||
@@ -79,6 +79,7 @@ export type SessionListOptions = {
|
||||
spawnedBy?: string;
|
||||
activeMinutes?: number;
|
||||
search?: string;
|
||||
creatorId?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
includeGlobal?: boolean;
|
||||
@@ -191,6 +192,7 @@ export type SessionCapability = {
|
||||
/** Advances only when a canonical sessions.list response is published. */
|
||||
readonly canonicalListRevision: number;
|
||||
list: (options?: SessionListOptions) => Promise<SessionsListResult | null>;
|
||||
setCreatorFilter: (creatorId: string | null) => Promise<void>;
|
||||
reconcile: (
|
||||
row: GatewaySessionRow | undefined,
|
||||
defaults?: SessionsListResult["defaults"],
|
||||
@@ -376,6 +378,7 @@ function buildSessionListParams(options: SessionListOptions = {}): Record<string
|
||||
const agentId = options.agentId?.trim();
|
||||
const spawnedBy = options.spawnedBy?.trim();
|
||||
const search = options.search?.trim();
|
||||
const creatorId = options.creatorId?.trim();
|
||||
if (agentId) {
|
||||
params.agentId = agentId;
|
||||
}
|
||||
@@ -385,6 +388,9 @@ function buildSessionListParams(options: SessionListOptions = {}): Record<string
|
||||
if (search) {
|
||||
params.search = search;
|
||||
}
|
||||
if (creatorId) {
|
||||
params.creatorId = creatorId;
|
||||
}
|
||||
if (typeof options.offset === "number" && options.offset > 0) {
|
||||
params.offset = Math.floor(options.offset);
|
||||
}
|
||||
@@ -1070,6 +1076,12 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
|
||||
return refresh({ ...options, force: true });
|
||||
};
|
||||
|
||||
const setCreatorFilter = (creatorId: string | null) => {
|
||||
const options = { ...lastListOptions, creatorId: creatorId?.trim() || undefined };
|
||||
delete options.offset;
|
||||
return refresh({ ...options, force: true });
|
||||
};
|
||||
|
||||
const createResult = async (
|
||||
params: SessionCreateParams = {},
|
||||
options: { reconciliation?: SessionCreateReconciliation } = {},
|
||||
@@ -1878,6 +1890,7 @@ export function createSessionCapability(gateway: SessionGateway): SessionCapabil
|
||||
return canonicalListRevision;
|
||||
},
|
||||
list: requestList,
|
||||
setCreatorFilter,
|
||||
reconcile,
|
||||
reconcileChanged,
|
||||
reconcileRunTerminal,
|
||||
|
||||
@@ -43,6 +43,45 @@ test("sessions.changed removes a label when the event carries null", () => {
|
||||
expect(reconciled.result?.sessions[0]?.displayName).toBeUndefined();
|
||||
});
|
||||
|
||||
test("sessions.changed invalidates the complete creator facet until canonical refresh", () => {
|
||||
const key = "agent:main:main";
|
||||
const result = buildResult([
|
||||
{
|
||||
key,
|
||||
kind: "global",
|
||||
updatedAt: 1,
|
||||
createdBy: { id: "profile-ada", label: "Ada" },
|
||||
},
|
||||
]);
|
||||
result.creators = [{ id: "profile-ada", label: "Ada" }];
|
||||
|
||||
const reconciled = reconcileSessionChanged(result, {
|
||||
sessionKey: key,
|
||||
reason: "reset",
|
||||
updatedAt: 2,
|
||||
createdBy: { id: "profile-bob", label: "Bob" },
|
||||
});
|
||||
|
||||
expect(reconciled.result?.sessions[0]?.createdBy?.id).toBe("profile-bob");
|
||||
expect(reconciled.result?.creators).toBeUndefined();
|
||||
});
|
||||
|
||||
test("sessions.changed preserves the creator facet when ownership is unchanged", () => {
|
||||
const key = "agent:main:main";
|
||||
const createdBy = { id: "profile-ada", label: "Ada" };
|
||||
const result = buildResult([{ key, kind: "global", updatedAt: 1, createdBy }]);
|
||||
result.creators = [createdBy];
|
||||
|
||||
const reconciled = reconcileSessionChanged(result, {
|
||||
sessionKey: key,
|
||||
reason: "send",
|
||||
updatedAt: 2,
|
||||
createdBy,
|
||||
});
|
||||
|
||||
expect(reconciled.result?.creators).toEqual([createdBy]);
|
||||
});
|
||||
|
||||
describe("reconcileSessionChanged", () => {
|
||||
it("drops a cleared icon from the merged row", () => {
|
||||
const key = "agent:main:main";
|
||||
|
||||
@@ -378,6 +378,9 @@ export function reconcileSessionChanged(
|
||||
if (rowFields.displayName === null) {
|
||||
delete row.displayName;
|
||||
}
|
||||
if (rowFields.createdBy === null) {
|
||||
delete row.createdBy;
|
||||
}
|
||||
if (rowFields.thinkingLevel === null) {
|
||||
delete row.thinkingLevel;
|
||||
}
|
||||
@@ -395,7 +398,14 @@ export function reconcileSessionChanged(
|
||||
return { applied: false, result };
|
||||
}
|
||||
const eventTs = typeof event.ts === "number" && Number.isFinite(event.ts) ? event.ts : null;
|
||||
const reconciledResult = eventTs === null ? next : { ...next, ts: Math.max(next.ts, eventTs) };
|
||||
const timestamped = eventTs === null ? next : { ...next, ts: Math.max(next.ts, eventTs) };
|
||||
const ownershipChanged =
|
||||
Object.hasOwn(rowFields, "createdBy") &&
|
||||
(existing?.createdBy?.id !== row.createdBy?.id ||
|
||||
existing?.createdBy?.label !== row.createdBy?.label);
|
||||
// The facet covers unloaded pages, so an ownership event invalidates it until
|
||||
// the session capability's canonical list refresh supplies a complete replacement.
|
||||
const reconciledResult = ownershipChanged ? { ...timestamped, creators: undefined } : timestamped;
|
||||
const reconciledRow = reconciledResult.sessions.find((candidate) =>
|
||||
matchesExistingSession(
|
||||
candidate,
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
import "../../components/modal-dialog.ts";
|
||||
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 { t } from "../../i18n/index.ts";
|
||||
import { resolveBoardChatLayoutWidth } from "../../lib/board/chat-layout.ts";
|
||||
@@ -3194,6 +3195,11 @@ class ChatPane extends OpenClawLightDomElement {
|
||||
mergedChrome: this.mergedChrome,
|
||||
title: this.paneTitle,
|
||||
session: row,
|
||||
showOwnerChip:
|
||||
(
|
||||
this.state?.sessionsResult?.creators ??
|
||||
listSessionCreators(this.state?.sessionsResult?.sessions ?? [])
|
||||
).length >= 2,
|
||||
catalog,
|
||||
editing: this.headerEditing && this.headerRenameSessionKey === row?.key,
|
||||
renameValue: this.headerRenameValue,
|
||||
|
||||
@@ -109,6 +109,20 @@ describe("chat pane header", () => {
|
||||
expect(props.onBeginRename).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("renders the permanent owner chip only when attribution chrome is enabled", () => {
|
||||
const shown = mount({
|
||||
showOwnerChip: true,
|
||||
session: row({ createdBy: { id: "profile-ada", label: "Ada" } }),
|
||||
});
|
||||
expect(shown.container.querySelector("openclaw-session-owner-chip")).not.toBeNull();
|
||||
|
||||
const dormant = mount({
|
||||
showOwnerChip: false,
|
||||
session: row({ createdBy: { id: "profile-ada", label: "Ada" } }),
|
||||
});
|
||||
expect(dormant.container.querySelector("openclaw-session-owner-chip")).toBeNull();
|
||||
});
|
||||
|
||||
it("routes Enter and Escape from the rename input", () => {
|
||||
const enter = mount({ editing: true, renameValue: " Updated " });
|
||||
const enterInput = enter.container.querySelector<HTMLInputElement>("input");
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type ShellNavDrawerToggleDetail,
|
||||
} from "../../../components/command-palette-contract.ts";
|
||||
import { icons } from "../../../components/icons.ts";
|
||||
import { renderSessionOwnerChip } from "../../../components/session-owner-chip.ts";
|
||||
import { isCloudWorkerPlacementState } from "../../../components/session-row-badges.ts";
|
||||
import "../../../components/tooltip.ts";
|
||||
import "../../../components/web-awesome.ts";
|
||||
@@ -21,6 +22,7 @@ type ChatPaneHeaderProps = {
|
||||
mergedChrome: boolean;
|
||||
title: string;
|
||||
session: GatewaySessionRow | undefined;
|
||||
showOwnerChip?: boolean;
|
||||
catalog: boolean;
|
||||
editing: boolean;
|
||||
renameValue: string;
|
||||
@@ -192,6 +194,10 @@ export function renderChatPaneHeader(props: ChatPaneHeaderProps) {
|
||||
>
|
||||
${props.title}
|
||||
</button>`}
|
||||
${renderSessionOwnerChip(
|
||||
props.showOwnerChip ? props.session?.createdBy : undefined,
|
||||
"header",
|
||||
)}
|
||||
${!props.catalog && props.workspaceLabel
|
||||
? html`
|
||||
<wa-dropdown
|
||||
|
||||
@@ -15,6 +15,56 @@
|
||||
animation: fade-in 0.3s var(--ease-out) 0.2s forwards;
|
||||
}
|
||||
|
||||
openclaw-session-owner-chip {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.session-owner-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid hsl(var(--owner-hue) 52% 18% / 0.9);
|
||||
border-radius: 999px;
|
||||
background: hsl(var(--owner-hue) 58% 26%);
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / 0.24);
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.session-owner-chip--row {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 8px;
|
||||
}
|
||||
|
||||
.session-owner-chip--header {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.sidebar-session-creator-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 8px 6px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.sidebar-session-creator-filter select {
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
padding: 3px 22px 3px 6px;
|
||||
}
|
||||
|
||||
.connect-splash__logo {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GatewayBrowserClient } from "../../api/gateway.ts";
|
||||
import { createGateway, createSessionsHarness, mountSidebar } from "../app-sidebar.ts";
|
||||
import "../../components/app-sidebar.ts";
|
||||
|
||||
describe("AppSidebar session ownership", () => {
|
||||
it("uses the complete facet and requests unloaded creators from the Gateway", async () => {
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const harness = createSessionsHarness("main", ["agent:main:main", "agent:main:ada"]);
|
||||
const result = harness.sessions.state.result;
|
||||
if (!result) {
|
||||
throw new Error("expected session list");
|
||||
}
|
||||
const ada = result.sessions.find((row) => row.key.endsWith(":ada"));
|
||||
if (!ada) {
|
||||
throw new Error("expected creator row");
|
||||
}
|
||||
ada.createdBy = { id: "profile-ada", label: "Ada" };
|
||||
result.creators = [
|
||||
{ id: "profile-ada", label: "Ada" },
|
||||
{ id: "profile-bob", label: "Bob" },
|
||||
];
|
||||
|
||||
const { sidebar } = await mountSidebar(gateway, harness.sessions);
|
||||
harness.publishList({ result, agentId: "main" });
|
||||
await sidebar.updateComplete;
|
||||
|
||||
expect(sidebar.sessionsResult?.creators).toHaveLength(2);
|
||||
expect(sidebar.querySelector('[data-session-key="agent:main:ada"]')).not.toBeNull();
|
||||
expect(sidebar.querySelectorAll("openclaw-session-owner-chip")).toHaveLength(1);
|
||||
const select = sidebar.querySelector<HTMLSelectElement>(
|
||||
'.sidebar-session-creator-filter select[aria-label="Filter by creator"]',
|
||||
);
|
||||
select!.value = "profile-bob";
|
||||
select!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await sidebar.updateComplete;
|
||||
expect(harness.setCreatorFilter).toHaveBeenCalledWith("profile-bob");
|
||||
|
||||
result.creators = [{ id: "profile-bob", label: "Bob" }];
|
||||
harness.publishList({ result, agentId: "main" });
|
||||
await sidebar.updateComplete;
|
||||
await sidebar.updateComplete;
|
||||
expect(harness.setCreatorFilter).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it("renders no ownership chrome when the listed sessions have fewer than two creators", async () => {
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const harness = createSessionsHarness("main", [
|
||||
"agent:main:main",
|
||||
"agent:main:a",
|
||||
"agent:main:b",
|
||||
]);
|
||||
const result = harness.sessions.state.result;
|
||||
if (!result) {
|
||||
throw new Error("expected session list");
|
||||
}
|
||||
for (const row of result.sessions) {
|
||||
row.createdBy = { id: "profile-ada", label: "Ada" };
|
||||
}
|
||||
const { sidebar } = await mountSidebar(gateway, harness.sessions);
|
||||
harness.publishList({ result, agentId: "main" });
|
||||
await sidebar.updateComplete;
|
||||
|
||||
expect(sidebar.querySelector(".sidebar-session-creator-filter")).toBeNull();
|
||||
expect(sidebar.querySelector("openclaw-session-owner-chip")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters by creator and hides custom groups without matching sessions", async () => {
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const harness = createSessionsHarness("main", [
|
||||
"agent:main:main",
|
||||
"agent:main:ada",
|
||||
"agent:main:bob",
|
||||
]);
|
||||
const result = harness.sessions.state.result;
|
||||
if (!result) {
|
||||
throw new Error("expected session list");
|
||||
}
|
||||
const ada = result.sessions.find((row) => row.key.endsWith(":ada"));
|
||||
const bob = result.sessions.find((row) => row.key.endsWith(":bob"));
|
||||
if (!ada || !bob) {
|
||||
throw new Error("expected creator rows");
|
||||
}
|
||||
ada.createdBy = { id: "profile-ada", label: "Ada" };
|
||||
ada.category = "Research";
|
||||
bob.createdBy = { id: "profile-bob", label: "Bob" };
|
||||
bob.category = "Operations";
|
||||
harness.publish({ groups: ["Research", "Operations"] });
|
||||
const { sidebar } = await mountSidebar(gateway, harness.sessions);
|
||||
harness.publishList({ result, agentId: "main" });
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const select = sidebar.querySelector<HTMLSelectElement>(
|
||||
'.sidebar-session-creator-filter select[aria-label="Filter by creator"]',
|
||||
);
|
||||
expect(select).not.toBeNull();
|
||||
expect(sidebar.querySelectorAll("openclaw-session-owner-chip")).toHaveLength(2);
|
||||
|
||||
select!.value = "profile-ada";
|
||||
select!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await sidebar.updateComplete;
|
||||
|
||||
expect(sidebar.querySelector('[data-session-key="agent:main:ada"]')).not.toBeNull();
|
||||
expect(sidebar.querySelector('[data-session-key="agent:main:bob"]')).toBeNull();
|
||||
expect(sidebar.querySelector('[data-session-section="category:Research"]')).not.toBeNull();
|
||||
expect(sidebar.querySelector('[data-session-section="category:Operations"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("filters catalog rows by authoritative creator ownership", async () => {
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const backingSessionKey = "agent:main:claude-bound";
|
||||
const harness = createSessionsHarness("main", [
|
||||
"agent:main:main",
|
||||
"agent:main:ada",
|
||||
backingSessionKey,
|
||||
]);
|
||||
const result = harness.sessions.state.result;
|
||||
if (!result) {
|
||||
throw new Error("expected session list");
|
||||
}
|
||||
const ada = result.sessions.find((row) => row.key.endsWith(":ada"));
|
||||
const adopted = result.sessions.find((row) => row.key === backingSessionKey);
|
||||
if (!ada || !adopted) {
|
||||
throw new Error("expected ownership rows");
|
||||
}
|
||||
ada.createdBy = { id: "profile-ada", label: "Ada" };
|
||||
adopted.createdBy = { id: "profile-bob", label: "Bob" };
|
||||
result.creators = [
|
||||
{ id: "profile-ada", label: "Ada" },
|
||||
{ id: "profile-bob", label: "Bob" },
|
||||
];
|
||||
|
||||
const { sidebar } = await mountSidebar(gateway, harness.sessions);
|
||||
sidebar.sessionCatalogs = [
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude Code",
|
||||
capabilities: { continueSession: true, archive: false },
|
||||
hosts: [
|
||||
{
|
||||
hostId: "gateway:local",
|
||||
label: "Local Claude",
|
||||
kind: "gateway",
|
||||
connected: true,
|
||||
sessions: [
|
||||
{
|
||||
threadId: "claude-thread",
|
||||
name: "Claude session",
|
||||
status: "stored",
|
||||
archived: false,
|
||||
sessionKey: backingSessionKey,
|
||||
createdBy: { id: "profile-bob", label: "Bob" },
|
||||
canContinue: true,
|
||||
canArchive: false,
|
||||
},
|
||||
{
|
||||
threadId: "external-thread",
|
||||
name: "External unowned session",
|
||||
status: "stored",
|
||||
archived: false,
|
||||
canContinue: true,
|
||||
canArchive: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
harness.publishList({ result, agentId: "main" });
|
||||
await sidebar.updateComplete;
|
||||
|
||||
expect(sidebar.querySelector(`[data-session-key="${backingSessionKey}"]`)).not.toBeNull();
|
||||
expect(sidebar.textContent).toContain("External unowned session");
|
||||
const select = sidebar.querySelector<HTMLSelectElement>(
|
||||
'.sidebar-session-creator-filter select[aria-label="Filter by creator"]',
|
||||
);
|
||||
select!.value = "profile-ada";
|
||||
select!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await sidebar.updateComplete;
|
||||
|
||||
expect(sidebar.querySelector(`[data-session-key="${backingSessionKey}"]`)).toBeNull();
|
||||
expect(sidebar.textContent).not.toContain("External unowned session");
|
||||
|
||||
harness.publishList({
|
||||
result: { ...result, count: 1, sessions: [ada] },
|
||||
agentId: "main",
|
||||
});
|
||||
await sidebar.updateComplete;
|
||||
|
||||
expect(sidebar.querySelector(`[data-session-key="${backingSessionKey}"]`)).toBeNull();
|
||||
expect(sidebar.textContent).not.toContain("External unowned session");
|
||||
});
|
||||
|
||||
it("keeps catalog rows whose backing ownership is outside the loaded page", async () => {
|
||||
const gateway = createGateway({} as GatewayBrowserClient);
|
||||
const harness = createSessionsHarness("main", [
|
||||
"agent:main:main",
|
||||
"agent:main:ada",
|
||||
"agent:main:bob",
|
||||
]);
|
||||
const result = harness.sessions.state.result;
|
||||
if (!result) {
|
||||
throw new Error("expected session list");
|
||||
}
|
||||
const ada = result.sessions.find((row) => row.key.endsWith(":ada"));
|
||||
const bob = result.sessions.find((row) => row.key.endsWith(":bob"));
|
||||
if (!ada || !bob) {
|
||||
throw new Error("expected creator rows");
|
||||
}
|
||||
ada.createdBy = { id: "profile-ada", label: "Ada" };
|
||||
bob.createdBy = { id: "profile-bob", label: "Bob" };
|
||||
result.creators = [
|
||||
{ id: "profile-ada", label: "Ada" },
|
||||
{ id: "profile-bob", label: "Bob" },
|
||||
];
|
||||
|
||||
const unloadedSessionKey = "agent:main:beyond-loaded-page";
|
||||
const { sidebar } = await mountSidebar(gateway, harness.sessions);
|
||||
sidebar.sessionCatalogs = [
|
||||
{
|
||||
id: "claude",
|
||||
label: "Claude Code",
|
||||
capabilities: { continueSession: true, archive: false },
|
||||
hosts: [
|
||||
{
|
||||
hostId: "gateway:local",
|
||||
label: "Local Claude",
|
||||
kind: "gateway",
|
||||
connected: true,
|
||||
sessions: [
|
||||
{
|
||||
threadId: "unloaded-thread",
|
||||
name: "Unloaded backing session",
|
||||
status: "stored",
|
||||
archived: false,
|
||||
sessionKey: unloadedSessionKey,
|
||||
createdBy: { id: "profile-ada", label: "Ada" },
|
||||
canContinue: true,
|
||||
canArchive: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
harness.publishList({ result, agentId: "main" });
|
||||
await sidebar.updateComplete;
|
||||
|
||||
const select = sidebar.querySelector<HTMLSelectElement>(
|
||||
'.sidebar-session-creator-filter select[aria-label="Filter by creator"]',
|
||||
);
|
||||
select!.value = "profile-ada";
|
||||
select!.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
await sidebar.updateComplete;
|
||||
|
||||
expect(sidebar.querySelector(`[data-session-key="${unloadedSessionKey}"]`)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -203,6 +203,7 @@ export function createSessionsHarness(agentId: string, keys: string[]) {
|
||||
);
|
||||
const refresh = vi.fn(() => Promise.resolve());
|
||||
const refreshReplacement = vi.fn(() => Promise.resolve());
|
||||
const setCreatorFilter = vi.fn(() => Promise.resolve());
|
||||
const subscribeMessages = vi.fn((key: string, options?: { agentId?: string | null }) =>
|
||||
Promise.resolve({ key, agentId: options?.agentId ?? null }),
|
||||
);
|
||||
@@ -244,6 +245,7 @@ export function createSessionsHarness(agentId: string, keys: string[]) {
|
||||
delete: deleteSession,
|
||||
deleteMany,
|
||||
list,
|
||||
setCreatorFilter,
|
||||
refresh,
|
||||
refreshReplacement,
|
||||
subscribeMessages,
|
||||
@@ -265,6 +267,7 @@ export function createSessionsHarness(agentId: string, keys: string[]) {
|
||||
deleteSession,
|
||||
deleteMany,
|
||||
list,
|
||||
setCreatorFilter,
|
||||
refresh,
|
||||
refreshReplacement,
|
||||
subscribeMessages,
|
||||
|
||||
Reference in New Issue
Block a user