fix(sessions): notify Home about new sessions by default (#152068)

* fix(sessions): notify Home when new sessions are created

* test(config): include Home creation notices in schema inventory

* test(sessions): verify global Home notices through prompt delivery

* fix(sessions): preserve Home creation notices through cron wakes
This commit is contained in:
Peter Steinberger 2026-09-18 16:24:59 -07:00 • committed by GitHub
parent 9a57a35aaf
commit 22da0ed41e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 625 additions and 219 deletions

View file

@ -755,7 +755,6 @@ src/secrets/runtime-web-tools.ts
src/security/audit-extra.async.ts
src/security/audit-extra.sync.ts
src/security/audit.ts
src/sessions/session-state-events.ts
src/shared/json-schema-defaults.ts
src/shared/text/assistant-visible-text.ts
src/skills/lifecycle/clawhub.test.ts

View file

@ -1,5 +1,5 @@
{
"core": 2446,
"core": 2447,
"channel": 3740,
"plugin": 4215
}

View file

@ -1,4 +1,4 @@
f24b2ae3976bc118d622686fcab79756dcb728420ecc9fb4be5a51122ad6c095 config-baseline.json
28a074cb16e4b576174363f1b34263d7b82cfc28911a994970e30edded5f3c46 config-baseline.core.json
9f4aecd2fe5bd1b54ad4b373bbe72e0bb64ac3443b28f33f322242dfe7856754 config-baseline.json
1a0266f09f7d214616c8d36f913a7ce973fdcada5883509f208360728413b0d4 config-baseline.core.json
51c84b118b136dfff84993e3cee0ff76ee06b614842b125d0fde110635fc10b8 config-baseline.channel.json
77b840d74238b0d797785d7575fd155629a2aa27213f33a3bd9eb6fa22537aed config-baseline.plugin.json

View file

@ -54,6 +54,14 @@ the Home agent additional access to another agent's sessions or files.
The main session is not just a chat log; it is the place where your agent's
world converges:
- **New sessions.** By default, creating a session queues a system notice in its
agent's Home conversation with the session key and available title, creator,
and creation source. Home sees it on its next turn or scheduled heartbeat;
creation does not trigger an extra wake-up. Set `session.notifyOnCreate: false`
to opt out. Home itself, drafts, incognito sessions, hidden internal sessions,
and scheduled cron runs are excluded. Resetting or reopening an existing
session does not send another creation notice. These notices use the bounded,
in-memory system-event queue and do not subscribe Home to future activity.
- **Group activity.** Under `session.groupScope: "per-group"` (the default),
group and room sessions stay isolated while the main session automatically watches them.
Activity queues up as compact notices — coalesced per conversation, never

View file

@ -22,6 +22,7 @@ OpenClaw appends a typed event to the shared state database (`session_state_even
| Kind | Recorded when | Notifies watchers |
| ---------------------- | -------------------------------------------------------- | ----------------- |
| `created` | A new session has trusted creation attribution | No (log only) |
| `human_direct_message` | A human sends a turn directly to a watched session | Yes |
| `upstream_missing` | An adopted session's upstream source disappears | Yes |
| `goal_changed` | The session's goal state is created, updated, or cleared | Yes |
@ -37,6 +38,12 @@ A session's **state version** is simply the highest sequence number in its log,
Log-only kinds exist for reconciliation history, not notification: ordinary child-run completion delivery stays owned by [sub-agent announcements](/tools/subagents), and the signal log never duplicates it.
Session creation separately queues a one-time Home notice by default, controlled
by `session.notifyOnCreate`. It does not register a watcher or wake Home. Unlike
durable watcher notices, it uses only the bounded, in-memory system-event queue.
See [new-session awareness](/concepts/main-session#what-flows-into-the-main-session)
for visibility exclusions.
## Watchers
A watcher is a session that holds a cursor (`session_watch_cursors`) on a target. Cursors come from three places:

View file

@ -17,6 +17,7 @@ title: "Configuration — agent sessions"
scope: "per-sender",
dmScope: "main", // main | per-peer | per-channel-peer | per-account-channel-peer
groupScope: "per-group", // main | per-group
notifyOnCreate: true, // notify Home about new sessions (default)
identityLinks: {
alice: ["telegram:123456789", "discord:987654321012345678"],
},
@ -80,6 +81,7 @@ title: "Configuration — agent sessions"
- **`groupScope`**: how groups, rooms, and channels are grouped.
- `per-group` (default): keep each non-direct peer in its channel-scoped session.
- `main`: route non-direct peers into the agent main session. Prefer a narrow `bindings[].session.groupScope` override when only selected trusted rooms should share main context.
- **`notifyOnCreate`**: queue a system notice in the owning agent's Home conversation for each new session (default: `true`). Includes available title, creator, and creation source, without copying messages. Home consumes it on the next turn or scheduled heartbeat. Set `false` to disable. Drafts, incognito sessions, Home itself, hidden internal sessions, and scheduled cron runs are excluded; reopening or resetting an existing session does not notify again. Notices are bounded and in memory, so they do not survive a Gateway restart. See [The main session](/concepts/main-session#what-flows-into-the-main-session).
- **`identityLinks`**: map canonical ids to provider-prefixed peers for cross-channel session sharing.
- **`resetTriggers`**: explicit commands or phrases that reset the session. Matching is case-insensitive; list each desired spelling because command aliases are not added automatically. For example, `["/tell"]` resets `/tell` messages, while `/steer` keeps its normal steering behavior. Follow-up text after a matching trigger is preserved, including later lines.
- **`reset`**: primary reset policy. `none` disables automatic reset and is the default; compaction bounds active context instead. `daily` resets at `atHour` local time; `idle` resets after `idleMinutes`. When both configured, whichever expires first wins. `/new` and `/reset` remain available in every mode. Daily reset freshness uses the session row's `sessionStartedAt`; idle reset freshness uses `lastInteractionAt`. Background/system-event writes such as heartbeat, cron wakeups, exec notifications, and gateway bookkeeping can update `updatedAt`, but they do not keep daily/idle sessions fresh.

View file

@ -26,10 +26,8 @@ import {
normalizeOptionalAgentId,
resolveAgentIdFromSessionKey,
} from "../../../routing/session-key.js";
import {
recordSessionCreated,
recordSubagentSpawned,
} from "../../../sessions/session-state-events.js";
import { recordSessionCreated } from "../../../sessions/session-created.js";
import { recordSubagentSpawned } from "../../../sessions/session-state-events.js";
import { deliveryContextFromSession } from "../../../utils/delivery-context.shared.js";
import { resolveSessionAgentId } from "../../agent-scope.js";
import { reserveChildAdmissionSlot } from "../../child-admission.js";
@ -512,7 +510,7 @@ export async function spawnAcpDirect(
});
// ACP bypasses the native adapter, so seed the same child lineage before dispatch.
if (childCreationEntry) {
recordSessionCreated({
recordSessionCreated(cfg, {
sessionKey,
agentId: targetAgentId,
entry: childCreationEntry,

View file

@ -13,11 +13,9 @@ import {
GatewayDrainingError,
runWithGatewayIndependentRootWorkContinuation,
} from "../../../process/gateway-work-admission.js";
import { recordSessionCreated } from "../../../sessions/session-created.js";
import { recordSessionParticipantBestEffort } from "../../../sessions/session-participant-recording.js";
import {
recordSessionCreated,
recordSubagentSpawned,
} from "../../../sessions/session-state-events.js";
import { recordSubagentSpawned } from "../../../sessions/session-state-events.js";
import { hasDeliveryTargetFields } from "../../../utils/delivery-context.shared.js";
import { hasPromptUnsafeControlCharacter } from "../../sanitize-for-prompt.js";
import {
@ -350,7 +348,7 @@ export async function spawnSubagentDirect(
swarmMaxConcurrent: swarmConfig.maxConcurrent,
});
if (childEntry) {
recordSessionCreated({
recordSessionCreated(cfg, {
sessionKey: childSessionKey,
agentId: targetAgentId,
entry: childEntry,

View file

@ -11,7 +11,7 @@ import { readPreparedModelCatalog } from "../../agents/prepared-model-catalog.js
import { resolveChannelModelOverride } from "../../channels/model-overrides.js";
import type { OpenClawConfig } from "../../config/config.js";
import { isModelSelectionLocked } from "../../sessions/model-overrides.js";
import { recordSessionCreated } from "../../sessions/session-state-events.js";
import { recordSessionCreated } from "../../sessions/session-created.js";
import { resolveStoredModelOverride } from "../../sessions/stored-model-overrides.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import type { SkillCommandSpec } from "../../skills/types.js";
@ -152,7 +152,7 @@ export async function maybeResolveNativeSlashCommandFastReply(params: {
}
const persistedInitialEntry = persistence.entry;
if (creatingSession) {
recordSessionCreated({
recordSessionCreated(params.cfg, {
sessionKey: sessionState.sessionKey,
agentId: params.agentId,
entry: persistedInitialEntry,

View file

@ -18,14 +18,19 @@ import {
peekSystemEventEntries,
type SystemEvent,
} from "../../infra/system-events.js";
import { SESSION_CREATED_NOTICE_CONTEXT_PREFIX } from "../../sessions/session-state-event-kinds.js";
import { acknowledgeSessionStateNotices } from "../../sessions/session-state-events.js";
import { decodeSessionStateNoticeContextKey } from "../../sessions/session-state-notices.js";
function compactSystemEvent(line: string): string | null {
const trimmed = line.trim();
function compactSystemEvent(event: SystemEvent): string | null {
const trimmed = event.text.trim();
if (!trimmed) {
return null;
}
// Creation metadata may mention heartbeat work; it is not a retired wake prompt.
if (event.contextKey?.startsWith(SESSION_CREATED_NOTICE_CONTEXT_PREFIX)) {
return trimmed;
}
const lower = normalizeLowercaseStringOrEmpty(trimmed);
if (lower.includes("reason periodic")) {
return null;
@ -111,7 +116,7 @@ export async function drainFormattedSystemEvents(params: {
acknowledgeSessionStateNotices(params.sessionKey, sessionStateTargets);
}
for (const event of queued) {
const compacted = compactSystemEvent(event.text);
const compacted = compactSystemEvent(event);
if (!compacted) {
continue;
}

View file

@ -87,6 +87,7 @@ import {
MODEL_SELECTION_LOCKED_RESET_MESSAGE,
ModelSelectionLockedError,
} from "../../sessions/model-overrides.js";
import { recordSessionCreated } from "../../sessions/session-created.js";
import {
SESSION_WORK_ADMISSION_DRAIN_TIMEOUT_MS,
interruptSessionWorkAdmissions,
@ -95,7 +96,6 @@ import {
import { recordAcceptedSessionParticipantInput } from "../../sessions/session-participant-input-recording.js";
import { prepareChannelParticipantObservation } from "../../sessions/session-participant-input.js";
import {
recordSessionCreated,
classifySessionStateActor,
registerMainSessionGroupWatch,
} from "../../sessions/session-state-events.js";
@ -1211,7 +1211,7 @@ async function initSessionStateAttemptLocked(
sessionKey,
});
if (createdNewEntry) {
recordSessionCreated({ sessionKey, agentId, entry: sessionEntry });
recordSessionCreated(cfg, { sessionKey, agentId, entry: sessionEntry });
}
if (
!isSystemEvent &&

View file

@ -281,6 +281,7 @@ exports[`config tier coverage > keeps the curated common leaf set reviewable 1`]
"session.mainKey",
"session.maintenance.coldStorage.enabled",
"session.maintenance.mode",
"session.notifyOnCreate",
"session.reset.mode",
"session.resetByChannel.*.mode",
"session.resetByType.direct.mode",

View file

@ -8,6 +8,8 @@ export const AUTOMATION_FIELD_HELP: Record<string, string> = {
'DM session scoping: "main" keeps continuity, while "per-peer", "per-channel-peer", and "per-account-channel-peer" increase isolation. Use isolated modes for shared inboxes or multi-account deployments.',
"session.groupScope":
'Group/channel session scoping: "per-group" keeps rooms separate while the agent main session ambiently watches them, independently of dmScope; "main" merges room context into main and needs no watch. Use "main" only for trusted rooms.',
"session.notifyOnCreate":
"Queue a system notice in the owning agent's Home session when a session is created (default: true). Notices include available title and creator metadata and are read on the next Home turn or heartbeat. Set false to disable; drafts, incognito, internal sessions, and scheduled cron runs are excluded.",
"session.identityLinks":
"Maps canonical identities to provider-prefixed peer IDs so equivalent users resolve to one DM thread (example: telegram:123456). Use this when the same human appears across multiple channels or accounts.",
"session.resetTriggers":

View file

@ -0,0 +1,51 @@
export const SESSION_FIELD_LABELS: Record<string, string> = {
session: "Session",
"session.scope": "Session Scope",
"session.dmScope": "DM Session Scope",
"session.groupScope": "Group Session Scope",
"session.notifyOnCreate": "Notify Home on Session Creation",
"session.identityLinks": "Session Identity Links",
"session.resetTriggers": "Session Reset Triggers",
"session.reset": "Session Reset Policy",
"session.reset.mode": "Session Reset Mode",
"session.reset.atHour": "Session Daily Reset Hour",
"session.reset.idleMinutes": "Session Reset Idle Minutes",
"session.resetByType": "Session Reset by Chat Type",
"session.resetByType.direct": "Session Reset (Direct)",
"session.resetByType.group": "Session Reset (Group)",
"session.resetByType.thread": "Session Reset (Thread)",
"session.resetByChannel": "Session Reset by Channel",
"session.store": "Session Store Path",
"session.mainKey": "Session Main Key",
"session.sendPolicy": "Session Send Policy",
"session.sendPolicy.default": "Session Send Policy Default Action",
"session.sendPolicy.rules": "Session Send Policy Rules",
"session.sendPolicy.rules[].action": "Session Send Rule Action",
"session.sendPolicy.rules[].match": "Session Send Rule Match",
"session.sendPolicy.rules[].match.channel": "Session Send Rule Channel",
"session.sendPolicy.rules[].match.chatType": "Session Send Rule Chat Type",
"session.sendPolicy.rules[].match.keyPrefix": "Session Send Rule Key Prefix",
"session.sendPolicy.rules[].match.rawKeyPrefix": "Session Send Rule Raw Key Prefix",
"session.threadBindings": "Session Thread Bindings",
"session.threadBindings.enabled": "Thread Binding Enabled",
"session.threadBindings.idleHours": "Thread Binding Idle Timeout (hours)",
"session.threadBindings.maxAgeHours": "Thread Binding Max Age (hours)",
"session.threadBindings.spawnSessions": "Thread-Bound Session Spawns",
"session.threadBindings.defaultSpawnContext": "Thread Spawn Context",
"session.sharing": "Session Sharing Modes",
"session.sharing.readOnly": "Allow Read-only Sessions",
"session.sharing.suggest": "Allow Suggest Sessions",
"session.sharing.drafts": "Allow Draft Sessions",
"session.maintenance": "Session Maintenance",
"session.maintenance.coldStorage": "Transcript Cold Storage",
"session.maintenance.coldStorage.enabled": "Automatically Archive Old Transcripts",
"session.maintenance.coldStorage.afterDays": "Archive After Inactive Days",
"session.maintenance.mode": "Session Maintenance Mode",
"session.maintenance.pruneAfter": "Session Prune After",
"session.maintenance.archiveDashboardAfter": "Archive Inactive Dashboard Sessions After",
"session.maintenance.maxEntries": "Session Max Entries",
"session.maintenance.preserveRecent": "Preserve Recent Sessions",
"session.maintenance.resetArchiveRetention": "Session Reset Archive Retention",
"session.maintenance.maxDiskBytes": "Session Max Disk Budget",
"session.maintenance.highWaterBytes": "Session Disk High-water Target",
};

View file

@ -1,5 +1,6 @@
// Defines user-facing config field labels used by schema metadata.
import { MEDIA_AUDIO_FIELD_LABELS } from "./media-audio-field-metadata.js";
import { SESSION_FIELD_LABELS } from "./schema.labels.session.js";
import { META_FIELD_LABELS } from "./schema.meta.js";
import { NODE_CAPABILITY_FIELD_LABELS } from "./schema.node-capabilities.js";
import { CLOUD_WORKER_FIELD_LABELS } from "./zod-schema.cloud-workers.js";
@ -746,54 +747,7 @@ export const FIELD_LABELS: Record<string, string> = {
"browser.ssrfPolicy.allowIpv6UniqueLocalRange": "Browser Allow IPv6 Unique Local Range",
"tools.exec.timeoutSeconds": "Exec Timeout (Seconds)",
"agents.entries.*.tools.exec.timeoutSeconds": "Agent Exec Timeout (Seconds)",
session: "Session",
"session.scope": "Session Scope",
"session.dmScope": "DM Session Scope",
"session.groupScope": "Group Session Scope",
"session.identityLinks": "Session Identity Links",
"session.resetTriggers": "Session Reset Triggers",
"session.reset": "Session Reset Policy",
"session.reset.mode": "Session Reset Mode",
"session.reset.atHour": "Session Daily Reset Hour",
"session.reset.idleMinutes": "Session Reset Idle Minutes",
"session.resetByType": "Session Reset by Chat Type",
"session.resetByType.direct": "Session Reset (Direct)",
"session.resetByType.group": "Session Reset (Group)",
"session.resetByType.thread": "Session Reset (Thread)",
"session.resetByChannel": "Session Reset by Channel",
"session.store": "Session Store Path",
"session.mainKey": "Session Main Key",
"session.sendPolicy": "Session Send Policy",
"session.sendPolicy.default": "Session Send Policy Default Action",
"session.sendPolicy.rules": "Session Send Policy Rules",
"session.sendPolicy.rules[].action": "Session Send Rule Action",
"session.sendPolicy.rules[].match": "Session Send Rule Match",
"session.sendPolicy.rules[].match.channel": "Session Send Rule Channel",
"session.sendPolicy.rules[].match.chatType": "Session Send Rule Chat Type",
"session.sendPolicy.rules[].match.keyPrefix": "Session Send Rule Key Prefix",
"session.sendPolicy.rules[].match.rawKeyPrefix": "Session Send Rule Raw Key Prefix",
"session.threadBindings": "Session Thread Bindings",
"session.threadBindings.enabled": "Thread Binding Enabled",
"session.threadBindings.idleHours": "Thread Binding Idle Timeout (hours)",
"session.threadBindings.maxAgeHours": "Thread Binding Max Age (hours)",
"session.threadBindings.spawnSessions": "Thread-Bound Session Spawns",
"session.threadBindings.defaultSpawnContext": "Thread Spawn Context",
"session.sharing": "Session Sharing Modes",
"session.sharing.readOnly": "Allow Read-only Sessions",
"session.sharing.suggest": "Allow Suggest Sessions",
"session.sharing.drafts": "Allow Draft Sessions",
"session.maintenance": "Session Maintenance",
"session.maintenance.coldStorage": "Transcript Cold Storage",
"session.maintenance.coldStorage.enabled": "Automatically Archive Old Transcripts",
"session.maintenance.coldStorage.afterDays": "Archive After Inactive Days",
"session.maintenance.mode": "Session Maintenance Mode",
"session.maintenance.pruneAfter": "Session Prune After",
"session.maintenance.archiveDashboardAfter": "Archive Inactive Dashboard Sessions After",
"session.maintenance.maxEntries": "Session Max Entries",
"session.maintenance.preserveRecent": "Preserve Recent Sessions",
"session.maintenance.resetArchiveRetention": "Session Reset Archive Retention",
"session.maintenance.maxDiskBytes": "Session Max Disk Budget",
"session.maintenance.highWaterBytes": "Session Disk High-water Target",
...SESSION_FIELD_LABELS,
cron: "Automations",
"cron.enabled": "Automations Enabled",
"cron.skipMissedJobs": "Skip Missed Recurring Automations",

View file

@ -40,6 +40,7 @@ export const SessionSchema = z
.enum(["main", "per-peer", "per-channel-peer", "per-account-channel-peer"])
.optional(),
groupScope: z.enum(["main", "per-group"]).optional(),
notifyOnCreate: z.boolean().optional(),
identityLinks: z.record(z.string(), z.array(z.string())).optional(),
resetTriggers: z.array(z.string()).optional(),
reset: SessionResetConfigSchema.optional(),

View file

@ -30,7 +30,7 @@ import {
} from "../../cron/scheduled-tool-policy.js";
import { assertAgentRunLifecycleGenerationCurrent } from "../../infra/agent-events.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { recordSessionCreated } from "../../sessions/session-state-events.js";
import { recordSessionCreated } from "../../sessions/session-created.js";
import { assertPreparedSkillLibrarySelection } from "../../skills/library/selection.js";
import { getGeneratedMediaTaskIdsForSessionKey } from "../../tasks/task-status-access.js";
import { sessionDeliveryChannel } from "../../utils/delivery-context.shared.js";
@ -534,7 +534,7 @@ export async function persistAgentSessionPhase(params: {
const usableRequestedSessionId = patchBuild.usableRequestedSessionId;
const freshness = patchBuild.freshness;
if (createdNewEntry && sessionEntry) {
recordSessionCreated({
recordSessionCreated(params.cfg, {
sessionKey: params.canonicalSessionKey,
agentId: params.sessionAgentId,
entry: sessionEntry,

View file

@ -16,10 +16,8 @@ import { getAgentEventLifecycleGeneration } from "../../infra/agent-events.js";
import { clearAgentRunContext } from "../../infra/agent-run-registry.js";
import { emitDiagnosticsTimelineEvent } from "../../infra/diagnostics-timeline.js";
import { formatErrorMessage } from "../../infra/errors.js";
import {
recordSessionCreated,
recordSessionGoalChanged,
} from "../../sessions/session-state-events.js";
import { recordSessionCreated } from "../../sessions/session-created.js";
import { recordSessionGoalChanged } from "../../sessions/session-state-events.js";
import type { UserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.js";
import { extractTextFromChatContent } from "../../shared/chat-content.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
@ -404,7 +402,7 @@ async function handleChatSendWithOptions(
throw new Error("Goal and its input were not durably admitted.");
}
if (admitted.value.initialSessionEntry) {
recordSessionCreated({
recordSessionCreated(preparedSession.value.cfg, {
sessionKey,
agentId: preparedSession.value.agentId,
entry: persistedUserTurn.sessionEntry,

View file

@ -22,12 +22,12 @@ import { parseInboundMediaUri } from "../../media/media-reference.js";
import { MEDIA_MAX_BYTES, readMediaBuffer } from "../../media/store.js";
import { isIncognitoSessionKey } from "../../routing/session-key.js";
import { ModelSelectionLockedError } from "../../sessions/model-overrides.js";
import { recordSessionCreated } from "../../sessions/session-created.js";
import { withSessionInitializationSource } from "../../sessions/session-initialization.js";
import {
isCompetingSessionWorkAdmissionActive,
runExclusiveSessionLifecycleMutation,
} from "../../sessions/session-lifecycle-admission.js";
import { recordSessionCreated } from "../../sessions/session-state-events.js";
import {
readSessionUpstreamLink,
type SessionUpstreamLink,
@ -622,7 +622,7 @@ async function mutateSessionAtMessage(
if (action !== "fork") {
clearSessionQueues(lifecycleIdentities);
} else {
recordSessionCreated({
recordSessionCreated(cfg, {
sessionKey: result.key,
agentId: current.target.agentId,
entry: result.entry,

View file

@ -0,0 +1,135 @@
import { expectDefined } from "@openclaw/normalization-core";
import { expect, test } from "vitest";
import { loadSessionEntry } from "../config/sessions/session-accessor.js";
import { drainSystemEvents, peekSystemEvents } from "../infra/system-events.js";
import { listSessionStateEventsSince } from "../sessions/session-state-events.js";
import {
attachGatewayLocalUserIngress,
prepareGatewayLocalUserIngress,
} from "./local-user-ingress.js";
import {
directSessionReq,
setupGatewaySessionsTestHarness,
} from "./test/server-sessions.test-helpers.js";
const { createSessionStoreDir } = setupGatewaySessionsTestHarness();
test("sessions.create stamps trusted operator provenance and records created", async () => {
const { storePath } = await createSessionStoreDir();
const profileId = "profile-session-creator";
const client = {
connect: { scopes: ["operator.write"] },
authenticatedUserProfile: {
profileId,
displayName: "Test Operator",
hasAvatar: false,
updatedAt: 1,
},
};
attachGatewayLocalUserIngress(
client,
prepareGatewayLocalUserIngress({
authenticatedUserExpected: true,
profile: { profileId, displayName: "Test Operator" },
isLocalClient: false,
}),
);
const created = await directSessionReq<{
key?: string;
entry?: {
createdVia?: string;
createdActor?: { type: string; id?: string };
createdAt?: number;
};
}>(
"sessions.create",
{ agentId: "main", label: "Investigate build failure" },
{ client: client as never },
);
expect(created.ok).toBe(true);
expect(created.payload?.entry).toMatchObject({
createdVia: "operator",
createdActor: { type: "human", source: "profile", id: profileId },
createdAt: expect.any(Number),
});
expect(created.payload?.entry).not.toHaveProperty("createdActor.label");
const key = expectDefined(created.payload?.key, "created session key");
expect(loadSessionEntry({ sessionKey: key, storePath })).not.toHaveProperty("createdActor.label");
expect(listSessionStateEventsSince(key, "main", 0, 20).events).toContainEqual(
expect.objectContaining({
kind: "created",
actorType: "human",
actorId: profileId,
summary: "session created",
}),
);
const notices = drainSystemEvents("agent:main:main");
expect(notices).toHaveLength(1);
expect(notices[0]).toContain("New session created");
expect(notices[0]).toContain("Investigate build failure");
expect(notices[0]).toContain(profileId);
expect(notices[0]).toContain(key);
expect(notices[0]).toContain("operator");
const existing = await directSessionReq("sessions.create", { key }, { client: client as never });
expect(existing.ok).toBe(true);
expect(peekSystemEvents("agent:main:main")).toEqual([]);
const synthetic = await directSessionReq<{
entry?: { createdVia?: string; createdActor?: unknown; createdAt?: number };
}>(
"sessions.create",
{ agentId: "main" },
{
client: {
connect: { scopes: ["operator.write"] },
internal: { syntheticClient: true },
} as never,
},
);
expect(synthetic.payload?.entry).toMatchObject({
createdVia: "operator",
createdAt: expect.any(Number),
});
expect(synthetic.payload?.entry?.createdActor).toBeUndefined();
for (const { actor, sandbox } of [
{ actor: { type: "agent", id: "main" }, sandbox: undefined },
{
actor: { type: "human", source: "profile", id: "profile-delegated-creator" },
sandbox: "required",
},
] as const) {
// The required parent's creation policy survives removal of gateway.roles.
const hinted = await directSessionReq<{
key?: string;
entry?: { createdVia?: string; createdActor?: unknown; sandbox?: "required" };
}>(
"sessions.create",
{ agentId: "main" },
{
client: {
connect: { scopes: ["operator.write"] },
internal: {
syntheticClient: true,
sessionCreation: {
via: "spawn",
actor,
sandbox,
requesterSessionKey: "agent:main:main",
},
},
} as never,
},
);
expect(hinted.ok, JSON.stringify(hinted.error)).toBe(true);
expect(hinted.payload?.entry).toMatchObject({ createdVia: "spawn", createdActor: actor });
expect(hinted.payload?.entry?.sandbox).toBe(sandbox);
const hintedKey = expectDefined(hinted.payload?.key, "delegated session key");
const stored = loadSessionEntry({ sessionKey: hintedKey, storePath });
expect(stored).toMatchObject({ createdVia: "spawn", createdActor: actor });
expect(stored?.sandbox).toBe(sandbox);
}
});

View file

@ -43,6 +43,7 @@ import {
import { addSessionMember, removeSessionMember } from "../config/sessions/session-sharing-store.js";
import { resolveSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js";
import type { GatewayOperatorRoleDefinition } from "../config/types.gateway.js";
import { peekSystemEvents } from "../infra/system-events.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import {
@ -80,10 +81,6 @@ import {
} from "../test-utils/openclaw-test-state.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import type { ChatAbortControllerEntry } from "./chat-abort.js";
import {
attachGatewayLocalUserIngress,
prepareGatewayLocalUserIngress,
} from "./local-user-ingress.js";
import { createMentionInbox } from "./mention-inbox.js";
import { sessionLog } from "./server-methods/sessions-shared.js";
import { identifiedClient, soloClient } from "./server-methods/sessions-sharing.test-support.js";
@ -1335,6 +1332,7 @@ test("sessions.create keeps incognito rows process-local through list, spawn, re
expect(created.ok).toBe(true);
const key = requireNonEmptyString(created.payload?.key, "incognito session key");
expect(key).toMatch(/^agent:main:dashboard:incognito-/u);
expect(peekSystemEvents("agent:main:main")).toEqual([]);
const entry = created.payload?.entry;
expect(entry?.incognito).toBe(true);
expect(entry?.parentSessionKey).toBeUndefined();
@ -2168,6 +2166,7 @@ test("sessions.create persists draft visibility in the initial session entry", a
expect(created.ok).toBe(true);
expect(created.payload?.entry.visibility).toBe("draft");
expect(peekSystemEvents("agent:main:main")).toEqual([]);
const key = requireNonEmptyString(created.payload?.key, "created session key");
expect(loadSessionEntry({ agentId: "main", sessionKey: key, storePath })?.visibility).toBe(
"draft",
@ -5895,110 +5894,6 @@ test("sessions.create model change clears a selection the new model does not sup
expect(stored?.contextWindow).toBeUndefined();
});
test("sessions.create stamps trusted operator provenance and records created", async () => {
const { storePath } = await createSessionStoreDir();
const profileId = "profile-session-creator";
const client = {
connect: { scopes: ["operator.write"] },
authenticatedUserProfile: {
profileId,
displayName: "Test Operator",
hasAvatar: false,
updatedAt: 1,
},
};
attachGatewayLocalUserIngress(
client,
prepareGatewayLocalUserIngress({
authenticatedUserExpected: true,
profile: { profileId, displayName: "Test Operator" },
isLocalClient: false,
}),
);
const created = await directSessionReq<{
key?: string;
entry?: {
createdVia?: string;
createdActor?: { type: string; id?: string };
createdAt?: number;
};
}>("sessions.create", { agentId: "main" }, { client: client as never });
expect(created.ok).toBe(true);
expect(created.payload?.entry).toMatchObject({
createdVia: "operator",
createdActor: { type: "human", source: "profile", id: profileId },
createdAt: expect.any(Number),
});
expect(created.payload?.entry).not.toHaveProperty("createdActor.label");
const key = requireNonEmptyString(created.payload?.key, "created session key");
expect(loadSessionEntry({ sessionKey: key, storePath })).not.toHaveProperty("createdActor.label");
expect(listSessionStateEventsSince(key, "main", 0, 20).events).toContainEqual(
expect.objectContaining({
kind: "created",
actorType: "human",
actorId: profileId,
summary: "session created",
}),
);
const synthetic = await directSessionReq<{
entry?: { createdVia?: string; createdActor?: unknown; createdAt?: number };
}>(
"sessions.create",
{ agentId: "main" },
{
client: {
connect: { scopes: ["operator.write"] },
internal: { syntheticClient: true },
} as never,
},
);
expect(synthetic.payload?.entry).toMatchObject({
createdVia: "operator",
createdAt: expect.any(Number),
});
expect(synthetic.payload?.entry?.createdActor).toBeUndefined();
for (const { actor, sandbox } of [
{ actor: { type: "agent", id: "main" }, sandbox: undefined },
{
actor: { type: "human", source: "profile", id: "profile-delegated-creator" },
sandbox: "required",
},
] as const) {
// The required parent's creation policy survives removal of gateway.roles.
const hinted = await directSessionReq<{
key?: string;
entry?: { createdVia?: string; createdActor?: unknown; sandbox?: "required" };
}>(
"sessions.create",
{ agentId: "main" },
{
client: {
connect: { scopes: ["operator.write"] },
internal: {
syntheticClient: true,
sessionCreation: {
via: "spawn",
actor,
sandbox,
requesterSessionKey: "agent:main:main",
},
},
} as never,
},
);
expect(hinted.ok, JSON.stringify(hinted.error)).toBe(true);
expect(hinted.payload?.entry).toMatchObject({ createdVia: "spawn", createdActor: actor });
expect(hinted.payload?.entry?.sandbox).toBe(sandbox);
const hintedKey = requireNonEmptyString(hinted.payload?.key, "delegated session key");
const stored = loadSessionEntry({ sessionKey: hintedKey, storePath });
expect(stored).toMatchObject({ createdVia: "spawn", createdActor: actor });
expect(stored?.sandbox).toBe(sandbox);
}
});
test("sessions.create reset-in-place preserves the node creation stamp", async () => {
testState.sessionConfig = { dmScope: "main" };
const { storePath } = await createSessionStoreDir();

View file

@ -84,11 +84,11 @@ import {
isAgentHarnessSessionKeyOwnedBy,
} from "../sessions/agent-harness-session-key.js";
import { isModelSelectionLocked } from "../sessions/model-overrides.js";
import { recordSessionCreated } from "../sessions/session-created.js";
import {
isSessionWorkAdmissionActive,
runExclusiveSessionLifecycleMutation,
} from "../sessions/session-lifecycle-admission.js";
import { recordSessionCreated } from "../sessions/session-state-events.js";
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
import { isUserModelAuthProfileId } from "../state/user-model-account-id.js";
import { isUserModelAuthProfileOwner } from "../state/user-model-accounts.js";
@ -1591,7 +1591,7 @@ export async function createGatewaySession(params: {
if (createdNewEntry) {
// The created fact belongs to this row generation; record it before a
// same-key delete can acquire the lifecycle fence and purge that state.
recordSessionCreated({
recordSessionCreated(params.cfg, {
sessionKey: createdContext.key,
agentId: createdContext.agentId,
entry: createdContext.entry,

View file

@ -19,13 +19,13 @@ import {
import type { InternalSessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import { recordSessionCreated } from "../sessions/session-created.js";
import {
closeSessionWorkAdmissions,
isSessionWorkAdmissionActive,
runExclusiveSessionLifecycleMutation,
} from "../sessions/session-lifecycle-admission.js";
import { normalizeSessionIdentities } from "../sessions/session-lifecycle-identity.js";
import { recordSessionCreated } from "../sessions/session-state-events.js";
import { resolveGlobalMap } from "../shared/global-singleton.js";
import { runQueuedStoreWrite, type StoreWriterQueue } from "../shared/store-writer-queue.js";
import { authorizeGatewaySessionCreation, resolveCreatorSandbox } from "./operator-role-policy.js";
@ -405,7 +405,7 @@ export async function recoverGatewaySession(params: {
}
if (committed.created) {
recordSessionCreated({
recordSessionCreated(params.cfg, {
sessionKey: committed.successorKey,
entry: committed.successorEntry,
agentId: sourceTarget.agentId,

View file

@ -90,6 +90,7 @@ import {
isModelSelectionLocked,
MODEL_SELECTION_LOCKED_RESET_MESSAGE,
} from "../sessions/model-overrides.js";
import { recordSessionCreated } from "../sessions/session-created.js";
import {
hasOnlySessionLifecycleMutationKindActive,
interruptSessionWorkAdmissions,
@ -100,7 +101,6 @@ import {
import {
handleSessionStateSessionDeleted,
handleSessionStateSessionReset,
recordSessionCreated,
} from "../sessions/session-state-events.js";
import { resolveGlobalSingleton } from "../shared/global-singleton.js";
import { getOrCreatePromise } from "../shared/lazy-promise.js";
@ -1662,7 +1662,7 @@ export async function performGatewaySessionReset(params: {
sessionKey: target.canonicalKey ?? params.key,
});
if (createdNewEntry) {
recordSessionCreated({
recordSessionCreated(cfg, {
sessionKey: target.canonicalKey ?? params.key,
agentId,
entry: mutation.nextEntry,

View file

@ -7,6 +7,7 @@ import { SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { readHeartbeatMonitorScratch } from "../cron/scratch-store.js";
import { resolveCronJobsStorePathFromConfig } from "../cron/store.js";
import { SESSION_CREATED_NOTICE_CONTEXT_PREFIX } from "../sessions/session-state-event-kinds.js";
import { formatErrorMessage } from "./errors.js";
import type { HeartbeatConfig } from "./heartbeat-config.js";
import {
@ -230,7 +231,9 @@ export function resolveHeartbeatRunPrompt(params: {
// Select once: admission owns generic text; completed delivery owns dedicated
// prompts and filtered cron noise. Late arrivals retain their queue identities.
for (const event of pendingEventEntries) {
if (isExecCompletionEvent(event.text)) {
if (event.contextKey?.startsWith(SESSION_CREATED_NOTICE_CONTEXT_PREFIX)) {
genericEvents.push(event);
} else if (isExecCompletionEvent(event.text)) {
if (params.preflight.shouldInspectPendingEvents) {
execEvents.push(event);
}

View file

@ -0,0 +1,118 @@
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import * as embeddedAgent from "../agents/embedded-agent.js";
import { withFullRuntimeReplyConfig } from "../auto-reply/reply/get-reply-fast-path.js";
import { getReplyFromConfig } from "../auto-reply/reply/get-reply.js";
import { resetCronActiveJobs } from "../cron/active-jobs.js";
import { recordSessionCreated } from "../sessions/session-created.js";
import {
createOpenClawTestState,
type OpenClawTestState,
} from "../test-utils/openclaw-test-state.js";
import { runHeartbeatOnce } from "./heartbeat-runner.js";
import {
seedMainSessionStore,
setupTelegramHeartbeatPluginRuntimeForTests,
} from "./heartbeat-runner.test-utils.js";
import { enqueueSystemEvent, peekSystemEvents, resetSystemEventsForTest } from "./system-events.js";
let state: OpenClawTestState | undefined;
beforeEach(() => {
setupTelegramHeartbeatPluginRuntimeForTests();
resetCronActiveJobs();
resetSystemEventsForTest();
});
afterEach(async () => {
vi.restoreAllMocks();
await state?.cleanup();
state = undefined;
resetSystemEventsForTest();
});
it.each(["heartbeat wake", "heartbeat poll", "build failure"])(
"delivers a creation notice about %s once through a cron wake",
async (topic) => {
state = await createOpenClawTestState({
label: "session-created-heartbeat",
env: { OPENCLAW_TEST_FAST: "0" },
});
const storePath = path.join(state.root, "sessions.json");
const cfg = withFullRuntimeReplyConfig({
agents: {
defaults: {
workspace: state.workspaceDir,
skipBootstrap: true,
model: { primary: "mock-openai/gpt-5.6-luna" },
models: { "mock-openai/gpt-5.6-luna": { agentRuntime: { id: "openclaw" } } },
heartbeat: { every: "5m", target: "none" },
},
},
plugins: { enabled: false },
session: { store: storePath },
});
await state.writeConfig(cfg);
const sessionKey = await seedMainSessionStore(storePath, cfg, {
lastChannel: "telegram",
lastProvider: "telegram",
lastTo: "-100155462274",
});
const title = `Investigate ${topic}`;
recordSessionCreated(cfg, {
sessionKey: "agent:main:dashboard:new-task",
agentId: "main",
entry: {
sessionId: "new-task",
updatedAt: Date.now(),
label: title,
createdVia: "operator",
createdActor: { type: "human", source: "profile", id: "profile-alice" },
},
});
enqueueSystemEvent("Reminder: check the work queue", {
sessionKey,
contextKey: "cron:queue-check",
});
const runAgent = vi
.spyOn(embeddedAgent, "runEmbeddedAgent")
.mockImplementation(async (params) => ({
payloads: [{ text: "Handled internally" }],
meta: {
durationMs: 1,
agentMeta: {
sessionId: params.sessionId,
provider: "mock-openai",
model: "gpt-5.6-luna",
},
},
}));
const run = () =>
runHeartbeatOnce({
cfg,
agentId: "main",
sessionKey,
source: "cron",
reason: "cron:queue-check",
deps: { getReplyFromConfig },
});
expect((await run()).status).toBe("ran");
expect(runAgent).toHaveBeenCalledTimes(1);
const input = expectDefined(runAgent.mock.calls[0]?.[0], "first agent input");
expect(input.currentInboundContext?.text ?? "").toContain(title);
expect(input.currentInboundContext?.text ?? "").toMatch(/System:.*New session created/u);
expect(input.currentInboundContext?.fragments).toContainEqual(
expect.objectContaining({ kind: "conversation-data", text: expect.stringContaining(title) }),
);
expect(input.prompt).toContain("The reminder content is:\n\nReminder: check the work queue");
expect(input.prompt).not.toContain(title);
expect(peekSystemEvents(sessionKey)).toEqual([]);
expect((await run()).status).toBe("ran");
expect(runAgent).toHaveBeenCalledTimes(2);
const next = expectDefined(runAgent.mock.calls[1]?.[0], "second agent input");
expect(next.currentInboundContext?.text ?? "").not.toContain(title);
expect(next.prompt).not.toContain(title);
},
);

View file

@ -0,0 +1,157 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import { drainFormattedSystemEvents } from "../auto-reply/reply/session-system-events.js";
import type { SessionEntry } from "../config/sessions/types.js";
import { SessionSchema } from "../config/zod-schema.session-config.js";
import {
drainSystemEvents,
peekSystemEvents,
resetSystemEventsForTest,
} from "../infra/system-events.js";
import { closeOpenClawStateDatabaseAsync } from "../state/openclaw-state-db.js";
import { recordSessionCreated } from "./session-created.js";
import {
listSessionStateEventsSince,
recordSessionHumanDirectMessage,
} from "./session-state-events.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const sessionKey = "agent:ops:dashboard:new-session";
const mainSessionKey = "agent:ops:main";
function entry(overrides: Partial<SessionEntry> = {}): SessionEntry {
return {
sessionId: "new-session",
updatedAt: Date.now(),
label: "Investigate build failure",
createdVia: "operator",
createdActor: { type: "human", source: "profile", id: "profile-alice" },
...overrides,
};
}
beforeEach(() => {
vi.stubEnv("OPENCLAW_STATE_DIR", tempDirs.make("openclaw-created-notice-"));
});
afterEach(async () => {
await closeOpenClawStateDatabaseAsync();
resetSystemEventsForTest();
vi.unstubAllEnvs();
});
describe("Home session creation notices", () => {
it.each([undefined, true, false])(
"honors notifyOnCreate=%s through the config schema",
(enabled) => {
const cfg = {
session: SessionSchema.parse(enabled === undefined ? {} : { notifyOnCreate: enabled }),
};
recordSessionCreated(cfg, { sessionKey, agentId: "ops", entry: entry() });
expect(peekSystemEvents(mainSessionKey)).toHaveLength(enabled === false ? 0 : 1);
expect(listSessionStateEventsSince(sessionKey, "ops", 0).events).toMatchObject([
{ kind: "created", actorId: "profile-alice" },
]);
},
);
it.each([
{ type: "human", source: "channel", id: "sender-alice", label: "Alice" },
{ type: "agent", id: "agent:ops:dashboard:parent" },
{ type: "system", id: "plugin-example" },
undefined,
] satisfies Array<SessionEntry["createdActor"]>)(
"notifies for creator %j without inventing provenance",
(actor) => {
recordSessionCreated(
{},
{
sessionKey,
agentId: "ops",
entry: entry({ createdActor: actor }),
},
);
const notices = peekSystemEvents(mainSessionKey);
expect(notices).toHaveLength(1);
expect(notices[0]).toContain(sessionKey);
expect(notices[0]).toContain("Investigate build failure");
if (actor) {
expect(notices[0]).toContain(actor.id);
} else {
expect(notices[0]).not.toContain("creator");
}
expect(peekSystemEvents("agent:main:main")).toEqual([]);
},
);
it.each([
{ name: "Home", key: mainSessionKey, overrides: {} },
{ name: "incognito marker", key: sessionKey, overrides: { incognito: true } },
{ name: "incognito key", key: "agent:ops:dashboard:incognito-secret", overrides: {} },
{ name: "draft", key: sessionKey, overrides: { visibility: "draft" } },
{ name: "internal stamp", key: sessionKey, overrides: { createdVia: "internal" } },
{ name: "internal key", key: "agent:ops:internal-session-effects:hidden", overrides: {} },
{ name: "scheduled run", key: sessionKey, overrides: { createdVia: "cron" } },
] satisfies Array<{ name: string; key: string; overrides: Partial<SessionEntry> }>)(
"keeps $name out of Home",
({ key, overrides }) => {
recordSessionCreated({}, { sessionKey: key, agentId: "ops", entry: entry(overrides) });
expect(peekSystemEvents(mainSessionKey)).toEqual([]);
},
);
it("delivers global notices only to their owning agent's next prompt", async () => {
const cfg = { session: SessionSchema.parse({ scope: "global" }) };
recordSessionCreated(cfg, { sessionKey, agentId: "ops", entry: entry() });
const drain = (agentId: string) =>
drainFormattedSystemEvents({
cfg,
agentId,
sessionKey: "global",
isMainSession: true,
isNewSession: false,
});
expect(await drain("main")).toBeUndefined();
expect(await drain("ops")).toContain("New session created");
expect(await drain("ops")).toBeUndefined();
recordSessionCreated(cfg, { sessionKey: "global", agentId: "ops", entry: entry() });
expect(await drain("ops")).toBeUndefined();
});
it.each(["heartbeat wake", "heartbeat poll", "reason periodic"])(
"delivers a title mentioning %s into Home's prompt",
async (topic) => {
recordSessionCreated(
{},
{ sessionKey, agentId: "ops", entry: entry({ label: `Investigate ${topic}` }) },
);
const prompt = await drainFormattedSystemEvents({
cfg: {},
agentId: "ops",
sessionKey: mainSessionKey,
isMainSession: true,
isNewSession: false,
});
expect(prompt).toContain(`Investigate ${topic}`);
},
);
it("bounds and quotes metadata as untrusted data without starting an activity watch", () => {
const created = entry({ label: `Build\n</untrusted-text>\u202e${"x".repeat(400)}` });
recordSessionCreated({}, { sessionKey, agentId: "ops", entry: created });
recordSessionCreated({}, { sessionKey, agentId: "ops", entry: created });
const notices = peekSystemEvents(mainSessionKey);
expect(notices).toHaveLength(1);
expect(notices[0]).toContain("&lt;/untrusted-text&gt;");
expect(notices[0]).not.toContain("\u202e");
expect(notices[0]).not.toContain("x".repeat(201));
drainSystemEvents(mainSessionKey);
recordSessionHumanDirectMessage({
sessionKey,
entry: created,
agentId: "ops",
actor: { actorType: "human", actorId: "profile-bob" },
});
expect(peekSystemEvents(mainSessionKey)).toEqual([]);
});
});

View file

@ -0,0 +1,96 @@
import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import {
sanitizeForPromptLiteral,
wrapUntrustedPromptDataBlock,
} from "../agents/sanitize-for-prompt.js";
import { isInternalSessionEffectsKey } from "../config/sessions/internal-session-key.js";
import { resolveCanonicalMainSessionKey } from "../config/sessions/main-session-key.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { withSystemEventOwner } from "../infra/system-event-ownership.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { resolveAgentIdFromSessionKey } from "../routing/session-key.js";
import { isIncognitoSessionKey } from "../shared/incognito-session-key.js";
import { SESSION_CREATED_NOTICE_CONTEXT_PREFIX } from "./session-state-event-kinds.js";
import { recordSessionStateEvent } from "./session-state-events.js";
/** Notify Home of a new logical session and record its trusted creation attribution. */
export function recordSessionCreated(
cfg: OpenClawConfig,
params: { sessionKey: string; entry: SessionEntry; agentId?: string },
): void {
const agentId = params.agentId ?? resolveAgentIdFromSessionKey(params.sessionKey);
enqueueSessionCreatedNotice({ ...params, cfg, agentId });
const actor = params.entry.createdActor;
if (!actor) {
return;
}
recordSessionStateEvent({
sessionKey: params.sessionKey,
sessionId: params.entry.sessionId,
agentId,
kind: "created",
actorType: actor.type,
...(actor.id ? { actorId: actor.id } : {}),
dedupeKey: `created:${agentId}:${params.sessionKey}:${params.entry.sessionId}`,
summary: "session created",
});
}
function noticeLabel(value: string | undefined): string | undefined {
const text = value && sanitizeForPromptLiteral(value).trim();
return text ? truncateUtf16Safe(text, 200) : undefined;
}
/** Creation awareness is one ambient notice, not a subscription to future session activity. */
function enqueueSessionCreatedNotice(params: {
cfg: OpenClawConfig;
sessionKey: string;
agentId: string;
entry: SessionEntry;
}): void {
const { cfg, sessionKey, agentId, entry } = params;
if (
cfg.session?.notifyOnCreate === false ||
entry.incognito ||
isIncognitoSessionKey(sessionKey) ||
entry.visibility === "draft" ||
entry.createdVia === "internal" ||
entry.createdVia === "cron" ||
isInternalSessionEffectsKey(sessionKey)
) {
return;
}
const mainSessionKey = resolveCanonicalMainSessionKey({
agentId,
sessionScope: cfg.session?.scope,
mainKey: cfg.session?.mainKey,
});
if (sessionKey === mainSessionKey) {
return;
}
const actor = entry.createdActor;
const details = {
sessionKey,
title: noticeLabel(entry.label ?? entry.displayName ?? entry.subject),
createdVia: entry.createdVia,
creator: actor
? {
type: actor.type,
...(actor.type === "human" ? { source: actor.source } : {}),
id: noticeLabel(actor.id),
label: noticeLabel(actor.label),
}
: undefined,
};
enqueueSystemEvent(
wrapUntrustedPromptDataBlock({ label: "New session created", text: JSON.stringify(details) }),
withSystemEventOwner(
{
sessionKey: mainSessionKey,
contextKey: `${SESSION_CREATED_NOTICE_CONTEXT_PREFIX}${sessionKey}:${entry.sessionId}`,
},
agentId,
),
);
}

View file

@ -1,5 +1,7 @@
export type SessionStateActorType = "human" | "agent" | "system";
export const SESSION_CREATED_NOTICE_CONTEXT_PREFIX = "session-created:";
export type SessionStateEventKind =
| "created"
| "human_direct_message"

View file

@ -16,6 +16,7 @@ import {
closeOpenClawStateDatabaseForTest,
openOpenClawStateDatabase,
} from "../state/openclaw-state-db.js";
import { recordSessionCreated } from "./session-created.js";
import {
acknowledgeSessionStateNotices,
classifySessionStateActor,
@ -25,7 +26,6 @@ import {
listAmbientGroupWatchTargets,
listSessionStateEventsSince,
recordSessionCompacted,
recordSessionCreated,
recordSessionGoalChanged,
recordSessionHumanDirectMessage,
recordSessionStateEvent,
@ -848,7 +848,7 @@ describe("session state events", () => {
it("projects spawn, terminal, goal, and compaction producer helpers", async () => {
const database = createDatabaseOptions();
recordSessionCreated({
recordSessionCreated(cfg, {
sessionKey: child,
agentId: "main",
entry: {

View file

@ -499,28 +499,6 @@ export async function recordSessionGoalChanged(params: {
}
}
/** Record the child's own creation fact when its durable stamp identifies an actor. */
export function recordSessionCreated(params: {
sessionKey: string;
entry: SessionEntry;
agentId?: string;
}): void {
const actor = params.entry.createdActor;
if (!actor) {
return;
}
recordSessionStateEvent({
sessionKey: params.sessionKey,
sessionId: params.entry.sessionId,
agentId: params.agentId ?? resolveAgentIdFromSessionKey(params.sessionKey),
kind: "created",
actorType: actor.type,
...(actor.id ? { actorId: actor.id } : {}),
dedupeKey: `created:${params.agentId ?? resolveAgentIdFromSessionKey(params.sessionKey)}:${params.sessionKey}:${params.entry.sessionId}`,
summary: "session created",
});
}
/** True when any seeded or explicitly registered watcher cursor targets this session. */
function hasSessionStateWatchers(
targetSessionKey: string,
@ -780,5 +758,3 @@ export function recordSubagentTerminalState(params: {
watcherSessionKeys: [params.requesterSessionKey],
});
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */