mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
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:
parent
9a57a35aaf
commit
22da0ed41e
31 changed files with 625 additions and 219 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"core": 2446,
|
||||
"core": 2447,
|
||||
"channel": 3740,
|
||||
"plugin": 4215
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
51
src/config/schema.labels.session.ts
Normal file
51
src/config/schema.labels.session.ts
Normal 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",
|
||||
};
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
135
src/gateway/server.sessions.create-notices.test.ts
Normal file
135
src/gateway/server.sessions.create-notices.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
118
src/infra/heartbeat-runner.session-created.test.ts
Normal file
118
src/infra/heartbeat-runner.session-created.test.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
157
src/sessions/session-created.test.ts
Normal file
157
src/sessions/session-created.test.ts
Normal 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("</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([]);
|
||||
});
|
||||
});
|
||||
96
src/sessions/session-created.ts
Normal file
96
src/sessions/session-created.ts
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue