From 22da0ed41e71efce64cc22c6f679089b0d6eeb71 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 18 Sep 2026 16:24:59 -0700 Subject: [PATCH] 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 --- config/max-lines-baseline.txt | 1 - docs/.generated/config-baseline.counts.json | 2 +- docs/.generated/config-baseline.sha256 | 4 +- docs/concepts/main-session.md | 8 + docs/concepts/session-state.md | 7 + docs/gateway/config-agents/sessions.md | 2 + src/agents/subagents/spawn/acp-spawn.ts | 8 +- src/agents/subagents/spawn/subagent-spawn.ts | 8 +- .../reply/get-reply-native-slash-fast-path.ts | 4 +- src/auto-reply/reply/session-system-events.ts | 11 +- src/auto-reply/reply/session.ts | 4 +- .../schema.help.quality.test.ts.snap | 1 + src/config/schema.help.automation.ts | 2 + src/config/schema.labels.session.ts | 51 ++++++ src/config/schema.labels.ts | 50 +----- src/config/zod-schema.session-config.ts | 1 + .../agent-turn/agent-session-persist.ts | 4 +- .../server-methods/chat-send-handler.ts | 8 +- src/gateway/server-methods/sessions-rewind.ts | 4 +- .../server.sessions.create-notices.test.ts | 135 +++++++++++++++ src/gateway/server.sessions.create.test.ts | 111 +------------ src/gateway/session-create-service.ts | 4 +- src/gateway/session-recovery-service.ts | 4 +- src/gateway/session-reset-service.ts | 4 +- src/infra/heartbeat-runner-prompt.ts | 5 +- .../heartbeat-runner.session-created.test.ts | 118 +++++++++++++ src/sessions/session-created.test.ts | 157 ++++++++++++++++++ src/sessions/session-created.ts | 96 +++++++++++ src/sessions/session-state-event-kinds.ts | 2 + src/sessions/session-state-events.test.ts | 4 +- src/sessions/session-state-events.ts | 24 --- 31 files changed, 625 insertions(+), 219 deletions(-) create mode 100644 src/config/schema.labels.session.ts create mode 100644 src/gateway/server.sessions.create-notices.test.ts create mode 100644 src/infra/heartbeat-runner.session-created.test.ts create mode 100644 src/sessions/session-created.test.ts create mode 100644 src/sessions/session-created.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 613b0045d47a..d3014755c5bf 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/docs/.generated/config-baseline.counts.json b/docs/.generated/config-baseline.counts.json index af75366fa49b..269c5919862c 100644 --- a/docs/.generated/config-baseline.counts.json +++ b/docs/.generated/config-baseline.counts.json @@ -1,5 +1,5 @@ { - "core": 2446, + "core": 2447, "channel": 3740, "plugin": 4215 } diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 2ef58dde0e4e..d8718823c7e7 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -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 diff --git a/docs/concepts/main-session.md b/docs/concepts/main-session.md index 14ceea95b4ba..1a1b217bf4f0 100644 --- a/docs/concepts/main-session.md +++ b/docs/concepts/main-session.md @@ -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 diff --git a/docs/concepts/session-state.md b/docs/concepts/session-state.md index e1238d38d933..6358158966d4 100644 --- a/docs/concepts/session-state.md +++ b/docs/concepts/session-state.md @@ -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: diff --git a/docs/gateway/config-agents/sessions.md b/docs/gateway/config-agents/sessions.md index 9321c092cd16..2e57439b8285 100644 --- a/docs/gateway/config-agents/sessions.md +++ b/docs/gateway/config-agents/sessions.md @@ -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. diff --git a/src/agents/subagents/spawn/acp-spawn.ts b/src/agents/subagents/spawn/acp-spawn.ts index b969d6fb3cee..75f2d1280d63 100644 --- a/src/agents/subagents/spawn/acp-spawn.ts +++ b/src/agents/subagents/spawn/acp-spawn.ts @@ -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, diff --git a/src/agents/subagents/spawn/subagent-spawn.ts b/src/agents/subagents/spawn/subagent-spawn.ts index 66623d6c13a3..a1d116c6b120 100644 --- a/src/agents/subagents/spawn/subagent-spawn.ts +++ b/src/agents/subagents/spawn/subagent-spawn.ts @@ -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, diff --git a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts index f054d57bc68f..4efd6c05a3cb 100644 --- a/src/auto-reply/reply/get-reply-native-slash-fast-path.ts +++ b/src/auto-reply/reply/get-reply-native-slash-fast-path.ts @@ -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, diff --git a/src/auto-reply/reply/session-system-events.ts b/src/auto-reply/reply/session-system-events.ts index ff4c5c09434f..86f1c96f2a4a 100644 --- a/src/auto-reply/reply/session-system-events.ts +++ b/src/auto-reply/reply/session-system-events.ts @@ -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; } diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index 7531b30dd6f4..7546b7c0cacf 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -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 && diff --git a/src/config/__snapshots__/schema.help.quality.test.ts.snap b/src/config/__snapshots__/schema.help.quality.test.ts.snap index 4bc8bc339119..cbef1f5f70eb 100644 --- a/src/config/__snapshots__/schema.help.quality.test.ts.snap +++ b/src/config/__snapshots__/schema.help.quality.test.ts.snap @@ -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", diff --git a/src/config/schema.help.automation.ts b/src/config/schema.help.automation.ts index 52fb059fa713..c337f1b86538 100644 --- a/src/config/schema.help.automation.ts +++ b/src/config/schema.help.automation.ts @@ -8,6 +8,8 @@ export const AUTOMATION_FIELD_HELP: Record = { '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": diff --git a/src/config/schema.labels.session.ts b/src/config/schema.labels.session.ts new file mode 100644 index 000000000000..d9f40a326a74 --- /dev/null +++ b/src/config/schema.labels.session.ts @@ -0,0 +1,51 @@ +export const SESSION_FIELD_LABELS: Record = { + 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", +}; diff --git a/src/config/schema.labels.ts b/src/config/schema.labels.ts index 15c3f7c90617..be8e2dc599f6 100644 --- a/src/config/schema.labels.ts +++ b/src/config/schema.labels.ts @@ -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 = { "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", diff --git a/src/config/zod-schema.session-config.ts b/src/config/zod-schema.session-config.ts index 2afc59bbbb4f..da2181d54628 100644 --- a/src/config/zod-schema.session-config.ts +++ b/src/config/zod-schema.session-config.ts @@ -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(), diff --git a/src/gateway/agent-turn/agent-session-persist.ts b/src/gateway/agent-turn/agent-session-persist.ts index 0865f4ad5108..d4f977739b18 100644 --- a/src/gateway/agent-turn/agent-session-persist.ts +++ b/src/gateway/agent-turn/agent-session-persist.ts @@ -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, diff --git a/src/gateway/server-methods/chat-send-handler.ts b/src/gateway/server-methods/chat-send-handler.ts index 17a6aa956f55..8a4412fd04cb 100644 --- a/src/gateway/server-methods/chat-send-handler.ts +++ b/src/gateway/server-methods/chat-send-handler.ts @@ -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, diff --git a/src/gateway/server-methods/sessions-rewind.ts b/src/gateway/server-methods/sessions-rewind.ts index 6750d1d976bc..e7e2050fe687 100644 --- a/src/gateway/server-methods/sessions-rewind.ts +++ b/src/gateway/server-methods/sessions-rewind.ts @@ -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, diff --git a/src/gateway/server.sessions.create-notices.test.ts b/src/gateway/server.sessions.create-notices.test.ts new file mode 100644 index 000000000000..9713f4b1cffd --- /dev/null +++ b/src/gateway/server.sessions.create-notices.test.ts @@ -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); + } +}); diff --git a/src/gateway/server.sessions.create.test.ts b/src/gateway/server.sessions.create.test.ts index bd93641b27f9..a54bb0ef0d94 100644 --- a/src/gateway/server.sessions.create.test.ts +++ b/src/gateway/server.sessions.create.test.ts @@ -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(); diff --git a/src/gateway/session-create-service.ts b/src/gateway/session-create-service.ts index b9422a716a3d..74cc18491bb5 100644 --- a/src/gateway/session-create-service.ts +++ b/src/gateway/session-create-service.ts @@ -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, diff --git a/src/gateway/session-recovery-service.ts b/src/gateway/session-recovery-service.ts index 8c3e81550e33..b5857e863abc 100644 --- a/src/gateway/session-recovery-service.ts +++ b/src/gateway/session-recovery-service.ts @@ -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, diff --git a/src/gateway/session-reset-service.ts b/src/gateway/session-reset-service.ts index 379a9678e74d..b163d1ea33f2 100644 --- a/src/gateway/session-reset-service.ts +++ b/src/gateway/session-reset-service.ts @@ -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, diff --git a/src/infra/heartbeat-runner-prompt.ts b/src/infra/heartbeat-runner-prompt.ts index 086552e3e914..4347936dc8dc 100644 --- a/src/infra/heartbeat-runner-prompt.ts +++ b/src/infra/heartbeat-runner-prompt.ts @@ -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); } diff --git a/src/infra/heartbeat-runner.session-created.test.ts b/src/infra/heartbeat-runner.session-created.test.ts new file mode 100644 index 000000000000..f877b577f5b3 --- /dev/null +++ b/src/infra/heartbeat-runner.session-created.test.ts @@ -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); + }, +); diff --git a/src/sessions/session-created.test.ts b/src/sessions/session-created.test.ts new file mode 100644 index 000000000000..351385b4c686 --- /dev/null +++ b/src/sessions/session-created.test.ts @@ -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 { + 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)( + "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 }>)( + "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\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("</untrusted-text>"); + 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([]); + }); +}); diff --git a/src/sessions/session-created.ts b/src/sessions/session-created.ts new file mode 100644 index 000000000000..04f7b9fbaa40 --- /dev/null +++ b/src/sessions/session-created.ts @@ -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, + ), + ); +} diff --git a/src/sessions/session-state-event-kinds.ts b/src/sessions/session-state-event-kinds.ts index aa7caa4a3215..d440361f8229 100644 --- a/src/sessions/session-state-event-kinds.ts +++ b/src/sessions/session-state-event-kinds.ts @@ -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" diff --git a/src/sessions/session-state-events.test.ts b/src/sessions/session-state-events.test.ts index 16685bb4da57..08b926d89423 100644 --- a/src/sessions/session-state-events.test.ts +++ b/src/sessions/session-state-events.test.ts @@ -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: { diff --git a/src/sessions/session-state-events.ts b/src/sessions/session-state-events.ts index d1f5ce6fb3f0..fcc47c80cf1c 100644 --- a/src/sessions/session-state-events.ts +++ b/src/sessions/session-state-events.ts @@ -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. */