mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-18 00:23:25 -06:00
fix(ui): disambiguate multi-account session labels (#124228)
* fix(ui): disambiguate multi-account session labels Parse the optional account segment on per-account-channel-peer DM and group keys so Control UI no longer falls through to the raw routing key. Append a non-default account as " · accountId" on label, displayName, and fallback so two Alice rows from different Telegram accounts stay distinguishable. Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca> * fix(ui): take the session account from the gateway row The display resolver was reading the account out of the session key and inferring it from label text. That missed the shipped `dm` spelling of direct keys, invented an account-qualified group shape no key builder emits, and treated any name ending in "(<account>)" as already disambiguated. The Gateway already projects the canonical `accountId` onto every session row from the delivery route, so display consumes that and parses the key only for panes rendered before their row arrives. Key parsing now covers both `direct` and the pre-#11881 `dm` spelling, group keys go back to their canonical account-less shape, and the account suffix is applied at a single point instead of five. `resolveChannelSessionInfo` classifies `dm` keys as channel sessions and no longer reads a peer kind sitting in the channel slot as a channel. * fix(ui): stop inferring session identity from rendered text Three related text-inference defects in the same display neighborhood: The account suffix was skipped when the resolved name already ended in " · <account>". That reads rendered output as if it were a fact, and it cannot tell an account apart from a user label that happens to look like one. The suffix is now appended from the recorded account alone. The string it was defending against came from the sidebar rename dialog, which pre-filled with the resolved display name. Submitting persisted a derived string as a real user label, which then outranked every later derivation. Sidebar rows now carry the stored label alongside the resolved one, and rename edits that, matching the Sessions page. Channel classification accepted an account segment before any peer kind, so `agent:<x>:<channel>:<account>:group:<id>` was filed under a channel even though accounts qualify direct chats only and the canonical route parser rejects the shape. The grammar now allows an account only before `direct`/`dm`. Resolver tests now prove the row projection is authoritative rather than duplicating an account the key already carries: one case where only the projection supplies it, and one where the two disagree. * ci: retrigger after check-lint shard cancel Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca> * test(ui): prove rename opens on the stored label The existing sidebar rename coverage uses a row that already has a stored label, so it passes whether rename pre-fills the stored label or the resolved display name. It could not catch the round-trip this branch repaired. Adds a browser case on a displayName-only row with a projected account: the sidebar shows "Alice · cards" and the rename field opens blank. On the parent commit the field opens holding "Alice · cards", which is the string that used to become a real user label on submit. * fix(ui): do not double-append a stored account suffix A persisted label that already ends in " · accountId" must stay as-is so "Alice · cards" does not become "Alice · cards · cards". Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca> * docs(ui): name the producer the account suffix check defends The comment above withAccountDisambiguator claimed the rendered name is never inspected, which the suffix check contradicts. State the real contract instead: the account itself still comes only from the recorded value, and the endsWith check is idempotence against the chat pane's inline rename, which seeds its input with the rendered title and persists a partial edit verbatim. --------- Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca> Co-authored-by: vyctorbrzezowski <krzyszchweski@gmail.com>
This commit is contained in:
@@ -207,6 +207,7 @@ export function buildSidebarSessionNavigationState(input: {
|
||||
// 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, { includeSubagentPrefix: false }),
|
||||
userLabel: row.label,
|
||||
subtitle: resolveSessionWorkSubtitle(row),
|
||||
href: sessionNavigationTarget({
|
||||
face: resolveSessionPreferredFace(row),
|
||||
|
||||
@@ -61,6 +61,12 @@ export type SidebarRecentSession = {
|
||||
createdActor?: SessionCreatedActor;
|
||||
archivedBy?: SessionCreatedActor;
|
||||
label: string;
|
||||
/**
|
||||
* Stored user label, undecorated. `label` above is the resolved display name
|
||||
* and can carry a derived account or channel; rename edits this one so a
|
||||
* derived string never lands back in persisted state.
|
||||
*/
|
||||
userLabel?: string;
|
||||
/** Compact repo/branch/node line for work sessions. */
|
||||
subtitle?: string;
|
||||
href: string;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { ReactiveControllerHost } from "lit";
|
||||
import type { FsListDirResult } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
@@ -419,7 +420,10 @@ export class SessionOrganizerController {
|
||||
const nextLabel =
|
||||
(await showInputDialog?.({
|
||||
title: t("sessionsView.renameSessionPrompt"),
|
||||
defaultValue: session.label,
|
||||
// The stored label, not the resolved display name: pre-filling the
|
||||
// derived string persists it on submit and it then outranks every
|
||||
// later derivation. Matches the Sessions page rename.
|
||||
defaultValue: normalizeOptionalString(session.userLabel) ?? "",
|
||||
})) ?? null;
|
||||
if (nextLabel === null) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { expect, it } from "vitest";
|
||||
import {
|
||||
captureUiProof,
|
||||
controlUiSessionUrl,
|
||||
createSessionManagementE2eSuite,
|
||||
installMockGateway,
|
||||
sessionRow,
|
||||
sessionsListResponse,
|
||||
trimmedTextContents,
|
||||
} from "./session-management.test-support.ts";
|
||||
|
||||
const suite = createSessionManagementE2eSuite();
|
||||
|
||||
/**
|
||||
* An ordinary Gateway direct-chat row: an origin-derived `displayName`, no user
|
||||
* label, and the `accountId` the Gateway projects from the canonical route
|
||||
* (src/gateway/session-classification.ts). Without both traits this would
|
||||
* exercise the label branch instead of the shipped one.
|
||||
*/
|
||||
function gatewayDirectRow(key: string, updatedAt: number, accountId?: string) {
|
||||
return { ...sessionRow(key, "Alice", updatedAt), accountId, label: undefined };
|
||||
}
|
||||
|
||||
suite.define(() => {
|
||||
it("disambiguates same-name sessions from different Telegram accounts in the sidebar", async () => {
|
||||
const defaultKey = "agent:main:telegram:direct:42";
|
||||
const cardsKey = "agent:main:telegram:cards:direct:42";
|
||||
const context = await suite.browser.newContext({
|
||||
colorScheme: "dark",
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.list": sessionsListResponse([
|
||||
gatewayDirectRow(defaultKey, 2),
|
||||
gatewayDirectRow(cardsKey, 1, "cards"),
|
||||
]),
|
||||
},
|
||||
sessionKey: defaultKey,
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(controlUiSessionUrl(suite.server.baseUrl, defaultKey));
|
||||
const defaultRow = page.locator(`[data-session-key="${defaultKey}"]`);
|
||||
const cardsRow = page.locator(`[data-session-key="${cardsKey}"]`);
|
||||
await defaultRow.waitFor({ state: "visible", timeout: 10_000 });
|
||||
await cardsRow.waitFor({ state: "visible" });
|
||||
await expect
|
||||
.poll(() => trimmedTextContents(defaultRow.locator(".sidebar-recent-session__name")))
|
||||
.toEqual(["Alice"]);
|
||||
await expect
|
||||
.poll(() => trimmedTextContents(cardsRow.locator(".sidebar-recent-session__name")))
|
||||
.toEqual(["Alice · cards"]);
|
||||
await captureUiProof(page, "telegram-account-session-labels.png");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("opens rename on the stored label, not the account-decorated name", async () => {
|
||||
const cardsKey = "agent:main:telegram:cards:direct:42";
|
||||
const context = await suite.browser.newContext({
|
||||
locale: "en-US",
|
||||
serviceWorkers: "block",
|
||||
viewport: { height: 900, width: 1280 },
|
||||
});
|
||||
const page = await context.newPage();
|
||||
await installMockGateway(page, {
|
||||
methodResponses: {
|
||||
"sessions.list": sessionsListResponse([gatewayDirectRow(cardsKey, Date.now(), "cards")]),
|
||||
},
|
||||
sessionKey: cardsKey,
|
||||
});
|
||||
|
||||
try {
|
||||
await page.goto(`${suite.server.baseUrl}chat`);
|
||||
const row = page.locator(`[data-session-key="${cardsKey}"]`);
|
||||
await row.waitFor({ state: "visible", timeout: 10_000 });
|
||||
// The row itself carries the account discriminator, so a rename field that
|
||||
// echoed the rendered name would look plausible while persisting it.
|
||||
await expect
|
||||
.poll(() => trimmedTextContents(row.locator(".sidebar-recent-session__name")))
|
||||
.toEqual(["Alice · cards"]);
|
||||
|
||||
await row.hover();
|
||||
await row.getByRole("button", { name: "Open session menu" }).click();
|
||||
await page.getByRole("menuitem", { name: "Rename…" }).click();
|
||||
const field = page
|
||||
.locator('openclaw-modal-dialog[label="Rename session"]')
|
||||
.getByRole("textbox", { name: "Rename session" });
|
||||
await field.waitFor({ state: "visible" });
|
||||
// This row has no stored label, so the field starts empty. Submitting the
|
||||
// decorated name here is what used to freeze it into persisted state.
|
||||
expect(await field.inputValue()).toBe("");
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,96 @@ describe("resolveSessionDisplayName", () => {
|
||||
expect(resolveSessionDisplayName("agent:main:imessage:direct:+4912")).toBe("iMessage · +4912");
|
||||
});
|
||||
|
||||
// Rows are shaped like the Gateway projection: displayName plus the
|
||||
// accountId it derives from the canonical route, no user label.
|
||||
it.each([
|
||||
{
|
||||
name: "an account-less direct row keeps its plain name",
|
||||
key: "agent:main:telegram:direct:42",
|
||||
row: { displayName: "Alice" },
|
||||
expected: "Alice",
|
||||
},
|
||||
{
|
||||
name: "an account-qualified direct row names its account",
|
||||
key: "agent:main:telegram:cards:direct:42",
|
||||
row: { accountId: "cards", displayName: "Alice" },
|
||||
expected: "Alice · cards",
|
||||
},
|
||||
{
|
||||
name: "a shipped dm-spelled row names its account",
|
||||
key: "agent:main:telegram:cards:dm:42",
|
||||
row: { accountId: "cards", displayName: "Alice" },
|
||||
expected: "Alice · cards",
|
||||
},
|
||||
{
|
||||
name: "an unnamed shipped dm row reads as a friendly peer plus account",
|
||||
key: "agent:main:telegram:cards:dm:491234567890",
|
||||
row: { accountId: "cards" },
|
||||
expected: "Telegram · …567890 · cards",
|
||||
},
|
||||
{
|
||||
name: "the default account adds no discriminator",
|
||||
key: "agent:main:telegram:default:direct:42",
|
||||
row: { accountId: "default", displayName: "Alice" },
|
||||
expected: "Alice",
|
||||
},
|
||||
{
|
||||
name: "a human label that merely looks account-shaped still gets a discriminator",
|
||||
key: "agent:main:telegram:work:direct:42",
|
||||
row: { accountId: "work", label: "Alice (work)" },
|
||||
expected: "Alice (work) · work",
|
||||
},
|
||||
{
|
||||
name: "a stored label that already ends in the account suffix is left alone",
|
||||
key: "agent:main:telegram:cards:direct:42",
|
||||
row: { accountId: "cards", label: "Alice · cards" },
|
||||
expected: "Alice · cards",
|
||||
},
|
||||
{
|
||||
name: "a canonical group key is unchanged",
|
||||
key: "agent:main:telegram:group:-1001234567890",
|
||||
row: undefined,
|
||||
expected: "Telegram Group",
|
||||
},
|
||||
{
|
||||
name: "an account-looking group key is not read as an account-qualified group",
|
||||
key: "agent:main:dm:account:group:room",
|
||||
row: undefined,
|
||||
expected: "dm:account:group:room",
|
||||
},
|
||||
])("$name", ({ key, row, expected }) => {
|
||||
expect(resolveSessionDisplayName(key, row)).toBe(expected);
|
||||
});
|
||||
|
||||
it("reads the account off the key only until the gateway row arrives", () => {
|
||||
expect(resolveSessionDisplayName("agent:main:telegram:cards:direct:42")).toBe(
|
||||
"Telegram · 42 · cards",
|
||||
);
|
||||
expect(resolveSessionDisplayName("agent:main:signal:work:dm:+4912")).toBe(
|
||||
"Signal · +4912 · work",
|
||||
);
|
||||
expect(resolveSessionDisplayName("agent:main:telegram:default:direct:42")).toBe(
|
||||
"Telegram · 42",
|
||||
);
|
||||
});
|
||||
|
||||
it("takes the account from the gateway row, not the key", () => {
|
||||
// Only the projection carries account identity here; the key has none.
|
||||
expect(
|
||||
resolveSessionDisplayName("agent:main:telegram:direct:42", {
|
||||
accountId: "cards",
|
||||
displayName: "Alice",
|
||||
}),
|
||||
).toBe("Alice · cards");
|
||||
// When the two disagree, the route the Gateway parsed wins over the guess.
|
||||
expect(
|
||||
resolveSessionDisplayName("agent:main:telegram:cards:direct:42", {
|
||||
accountId: "ops",
|
||||
displayName: "Alice",
|
||||
}),
|
||||
).toBe("Alice · ops");
|
||||
});
|
||||
|
||||
it("does not split UTF-16 surrogate pairs when shortening peer ids", () => {
|
||||
expect(resolveSessionDisplayName("agent:main:telegram:direct:12345😀67890")).toBe(
|
||||
"Telegram · …67890",
|
||||
@@ -177,10 +267,30 @@ describe("resolveChannelSessionInfo", () => {
|
||||
channel: "telegram",
|
||||
channelSession: true,
|
||||
});
|
||||
expect(resolveChannelSessionInfo("agent:main:slack:acct-1:channel:C1")).toEqual({
|
||||
expect(resolveChannelSessionInfo("agent:main:slack:channel:C1")).toEqual({
|
||||
channel: "slack",
|
||||
channelSession: true,
|
||||
});
|
||||
// Shipped pre-#11881 keys spell direct chats `dm`; they are still channel sessions.
|
||||
expect(resolveChannelSessionInfo("agent:main:telegram:cards:dm:42")).toEqual({
|
||||
channel: "telegram",
|
||||
channelSession: true,
|
||||
});
|
||||
expect(resolveChannelSessionInfo("agent:main:dm:+123", "whatsapp")).toEqual({
|
||||
channel: "whatsapp",
|
||||
channelSession: true,
|
||||
});
|
||||
// Accounts qualify direct chats only, so these key shapes name no channel
|
||||
// and must not be filed under one the canonical parser would reject.
|
||||
expect(resolveChannelSessionInfo("agent:main:telegram:work:group:room")).toEqual({
|
||||
channelSession: false,
|
||||
});
|
||||
expect(resolveChannelSessionInfo("agent:main:slack:acct-1:channel:C1")).toEqual({
|
||||
channelSession: false,
|
||||
});
|
||||
expect(resolveChannelSessionInfo("agent:main:dm:account:group:room")).toEqual({
|
||||
channelSession: false,
|
||||
});
|
||||
// dmScope per-peer keys have no channel segment; the row channel wins.
|
||||
expect(resolveChannelSessionInfo("agent:main:direct:+123", "whatsapp")).toEqual({
|
||||
channel: "whatsapp",
|
||||
|
||||
@@ -37,8 +37,14 @@ function shortenOpaqueIdRuns(text: string): string {
|
||||
|
||||
const WORKTREE_BRANCH_PREFIX = "openclaw/";
|
||||
|
||||
const CHANNEL_SESSION_KEY_RE = /^agent:[^:]+:([^:]+)(?::[^:]+)?:(?:direct|group|channel|thread):/;
|
||||
const PEER_SESSION_KEY_RE = /:(?:direct|group|channel|thread):/;
|
||||
// `dm` is the pre-#11881 spelling of `direct`; those keys still persist and the
|
||||
// canonical parser still accepts both (src/sessions/session-key-utils.ts).
|
||||
// Only direct chats take an account segment (src/routing/session-key.ts), so an
|
||||
// account-qualified group or channel key is not a shape any producer emits and
|
||||
// must not name a channel here either.
|
||||
const CHANNEL_SESSION_KEY_RE =
|
||||
/^agent:[^:]+:([^:]+)(?:(?::[^:]+)?:(?:direct|dm)|:(?:group|channel|thread)):/;
|
||||
const PEER_SESSION_KEY_RE = /:(?:direct|dm|group|channel|thread):/;
|
||||
|
||||
/**
|
||||
* Classifies channel-originated sessions for the sidebar's built-in channel
|
||||
@@ -53,9 +59,7 @@ export function resolveChannelSessionInfo(
|
||||
return { channelSession: false };
|
||||
}
|
||||
const keyChannel = key.match(CHANNEL_SESSION_KEY_RE)?.[1];
|
||||
const channel =
|
||||
normalizeOptionalString(keyChannel && keyChannel !== "direct" ? keyChannel : undefined) ??
|
||||
normalizeOptionalString(rowChannel);
|
||||
const channel = normalizeOptionalString(keyChannel) ?? normalizeOptionalString(rowChannel);
|
||||
return { channel, channelSession: Boolean(channel) };
|
||||
}
|
||||
|
||||
@@ -99,8 +103,28 @@ type SessionKeyInfo = {
|
||||
prefix: string;
|
||||
/** Human-readable fallback when no label / displayName is available. */
|
||||
fallbackName: string;
|
||||
/** Raw account segment; only a fallback, Gateway rows carry the real one. */
|
||||
accountId?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Two DMs from different accounts routinely share a name, so the account is the
|
||||
* only discriminator; `default` is what key builders write for absence and says
|
||||
* nothing. Which account to show comes from the recorded fact alone, never from
|
||||
* the rendered name. The suffix check is idempotence, not inference: the chat
|
||||
* pane's inline rename seeds its input with the rendered title
|
||||
* (`beginHeaderRename`), so a partially edited submit can persist a label that
|
||||
* already ends in this suffix, and appending twice would render
|
||||
* `Alice · cards · cards`.
|
||||
*/
|
||||
function withAccountDisambiguator(name: string, accountId: string | undefined): string {
|
||||
if (!accountId || accountId === "default") {
|
||||
return name;
|
||||
}
|
||||
const suffix = ` · ${accountId}`;
|
||||
return name.endsWith(suffix) ? name : `${name}${suffix}`;
|
||||
}
|
||||
|
||||
/** Typed-session prefixes come from the i18n catalog (RFC 0026). */
|
||||
function typedSessionPrefix(kind: SessionTypedKind): string {
|
||||
return kind === "subagent"
|
||||
@@ -112,6 +136,8 @@ type SessionDisplayRow = {
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
derivedTitle?: string;
|
||||
/** Canonical account projected from the delivery route by the Gateway. */
|
||||
accountId?: string;
|
||||
} & SessionWorktreeDisplayRow;
|
||||
|
||||
type SessionDisplayOptions = {
|
||||
@@ -147,20 +173,28 @@ function parseSessionKey(key: string): SessionKeyInfo {
|
||||
return { kind: "automation", prefix, fallbackName: prefix };
|
||||
}
|
||||
|
||||
// Direct chat: agent:<x>:<channel>:direct:<id>. Never render the raw peer
|
||||
// id; the gateway sends origin-derived names, so this is a last resort.
|
||||
const directMatch = key.match(/^agent:[^:]+:([^:]+):direct:(.+)$/);
|
||||
// Direct chat: agent:<x>:<channel>[:<account>]:(direct|dm):<id>. Never render
|
||||
// the raw peer id; the gateway sends origin-derived names, so this is a last
|
||||
// resort.
|
||||
const directMatch = key.match(/^agent:[^:]+:([^:]+)(?::([^:]+))?:(?:direct|dm):(.+)$/);
|
||||
if (directMatch) {
|
||||
const channel = directMatch[1];
|
||||
const identifier = directMatch[2];
|
||||
const accountId = directMatch[2];
|
||||
const identifier = directMatch[3];
|
||||
if (!channel || !identifier) {
|
||||
return { prefix: "", fallbackName: key };
|
||||
return { prefix: "", fallbackName: key, accountId };
|
||||
}
|
||||
const channelLabel = CHANNEL_LABELS[channel] ?? capitalize(channel);
|
||||
return { prefix: "", fallbackName: `${channelLabel} · ${shortenPeerId(identifier)}` };
|
||||
return {
|
||||
prefix: "",
|
||||
fallbackName: `${channelLabel} · ${shortenPeerId(identifier)}`,
|
||||
accountId,
|
||||
};
|
||||
}
|
||||
|
||||
// Group chat: agent:<x>:<channel>:group:<id>.
|
||||
// Group chat: agent:<x>:<channel>:group:<id>. buildAgentPeerSessionKey scopes
|
||||
// accounts to DMs (src/routing/session-key.ts), so an account-looking segment
|
||||
// here belongs to a custom key and must not be read as one.
|
||||
const groupMatch = key.match(/^agent:[^:]+:([^:]+):group:(.+)$/);
|
||||
if (groupMatch) {
|
||||
const channel = groupMatch[1];
|
||||
@@ -206,7 +240,10 @@ export function resolveSessionDisplayName(
|
||||
const label = normalizeOptionalString(row?.label) ?? "";
|
||||
const displayName = normalizeOptionalString(row?.displayName) ?? "";
|
||||
const derivedTitle = normalizeOptionalString(row?.derivedTitle) ?? "";
|
||||
const { kind, prefix, fallbackName } = parseSessionKey(key);
|
||||
const { kind, prefix, fallbackName, accountId: keyAccountId } = parseSessionKey(key);
|
||||
// The Gateway records the account on the row (src/gateway/session-classification.ts);
|
||||
// the key is parsed only for panes rendered before their row arrives.
|
||||
const accountId = normalizeOptionalString(row?.accountId) ?? keyAccountId;
|
||||
|
||||
const applyTypedPrefix = (rawName: string): string => {
|
||||
if (!kind || !prefix) {
|
||||
@@ -226,21 +263,25 @@ export function resolveSessionDisplayName(
|
||||
return prefixPattern.test(name) ? name : `${prefix} ${name}`;
|
||||
};
|
||||
|
||||
if (label && label !== key) {
|
||||
return applyTypedPrefix(label);
|
||||
}
|
||||
if (displayName && displayName !== key) {
|
||||
return applyTypedPrefix(displayName);
|
||||
}
|
||||
// Unnamed work sessions read as their checkout instead of an opaque key.
|
||||
const workSubtitle = row ? resolveSessionWorkSubtitle(row) : undefined;
|
||||
if (workSubtitle && row?.worktree) {
|
||||
return applyTypedPrefix(workSubtitle);
|
||||
}
|
||||
if (derivedTitle && derivedTitle !== key) {
|
||||
return applyTypedPrefix(derivedTitle);
|
||||
}
|
||||
return fallbackName;
|
||||
const resolveNamedOrFallback = (): string => {
|
||||
if (label && label !== key) {
|
||||
return applyTypedPrefix(label);
|
||||
}
|
||||
if (displayName && displayName !== key) {
|
||||
return applyTypedPrefix(displayName);
|
||||
}
|
||||
// Unnamed work sessions read as their checkout instead of an opaque key.
|
||||
const workSubtitle = row ? resolveSessionWorkSubtitle(row) : undefined;
|
||||
if (workSubtitle && row?.worktree) {
|
||||
return applyTypedPrefix(workSubtitle);
|
||||
}
|
||||
if (derivedTitle && derivedTitle !== key) {
|
||||
return applyTypedPrefix(derivedTitle);
|
||||
}
|
||||
return fallbackName;
|
||||
};
|
||||
|
||||
return withAccountDisambiguator(resolveNamedOrFallback(), accountId);
|
||||
}
|
||||
|
||||
export function isCronSessionKey(key: string): boolean {
|
||||
|
||||
Reference in New Issue
Block a user