mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
refactor(sessions): canonical lineage model — creation provenance, fork ancestry, generation chain, typed row contract (#111861)
* fix(sessions): preserve spawn/fork lineage across implicit daily/idle rollover * refactor(sessions): canonical creation model with forkSource ancestry and generation chain * feat(sessions): stamp creation provenance across all creation paths and emit created events * refactor(gateway): lock lineage patching, split control from navigation, add typed session-row contract * docs(gateway): document creation provenance stamping and lineage patch tightening * fix(sessions): keep provenance proof-only on wire fallbacks and strip node-local lineage from cron continuations * fix(gateway): never journal a created event for session adoption * fix(gateway): keep post-create work on adoption while gating the created event * fix(sessions): restore trusted ensure-main provenance and stamp navigation parent at spawn * fix(sessions): allow parentSessionKey through the direct child spawn patch * fix(ci): break type cycles, satisfy export scan, regenerate Swift protocol models * refactor(sessions): replace createdBy with createdActor * fix(protocol): export session row type * fix(sessions): preserve proven creation provenance * fix(sessions): close lineage creation gaps * test(sessions): align atomic spawn lineage coverage * test(sessions): widen transcript search reconcile wait * fix(sessions): stamp reset-created rows * test(sessions): keep reset provenance coverage focused * fix(sessions): journal chat-created rows * test(ci): anchor release skill reads to repo * test(ci): avoid cached module paths
This commit is contained in:
committed by
GitHub
parent
7497147519
commit
099d6351b3
@@ -4120,7 +4120,7 @@ public struct SessionCatalogSession: Codable, Sendable {
|
||||
public let pullrequest: SessionCatalogPullRequestSummary?
|
||||
public let archived: Bool
|
||||
public let sessionkey: String?
|
||||
public let createdby: [String: AnyCodable]?
|
||||
public let createdactor: [String: AnyCodable]?
|
||||
public let cancontinue: Bool
|
||||
public let canarchive: Bool
|
||||
public let canopenterminal: Bool?
|
||||
@@ -4141,7 +4141,7 @@ public struct SessionCatalogSession: Codable, Sendable {
|
||||
pullrequest: SessionCatalogPullRequestSummary? = nil,
|
||||
archived: Bool,
|
||||
sessionkey: String? = nil,
|
||||
createdby: [String: AnyCodable]? = nil,
|
||||
createdactor: [String: AnyCodable]? = nil,
|
||||
cancontinue: Bool,
|
||||
canarchive: Bool,
|
||||
canopenterminal: Bool? = nil)
|
||||
@@ -4161,7 +4161,7 @@ public struct SessionCatalogSession: Codable, Sendable {
|
||||
self.pullrequest = pullrequest
|
||||
self.archived = archived
|
||||
self.sessionkey = sessionkey
|
||||
self.createdby = createdby
|
||||
self.createdactor = createdactor
|
||||
self.cancontinue = cancontinue
|
||||
self.canarchive = canarchive
|
||||
self.canopenterminal = canopenterminal
|
||||
@@ -4183,7 +4183,7 @@ public struct SessionCatalogSession: Codable, Sendable {
|
||||
case pullrequest = "pullRequest"
|
||||
case archived
|
||||
case sessionkey = "sessionKey"
|
||||
case createdby = "createdBy"
|
||||
case createdactor = "createdActor"
|
||||
case cancontinue = "canContinue"
|
||||
case canarchive = "canArchive"
|
||||
case canopenterminal = "canOpenTerminal"
|
||||
@@ -6624,13 +6624,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
public let execask: AnyCodable?
|
||||
public let execnode: AnyCodable?
|
||||
public let model: AnyCodable?
|
||||
public let spawnedby: AnyCodable?
|
||||
public let completionownersessionkey: AnyCodable?
|
||||
public let spawnedworkspacedir: AnyCodable?
|
||||
public let spawnedcwd: AnyCodable?
|
||||
public let spawndepth: AnyCodable?
|
||||
public let subagentrole: AnyCodable?
|
||||
public let subagentcontrolscope: AnyCodable?
|
||||
public let inheritedtoolpolicyversion: AnyCodable?
|
||||
public let inheritedtoolallow: AnyCodable?
|
||||
public let inheritedtooldeny: AnyCodable?
|
||||
@@ -6661,13 +6655,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
execask: AnyCodable? = nil,
|
||||
execnode: AnyCodable? = nil,
|
||||
model: AnyCodable? = nil,
|
||||
spawnedby: AnyCodable? = nil,
|
||||
completionownersessionkey: AnyCodable? = nil,
|
||||
spawnedworkspacedir: AnyCodable? = nil,
|
||||
spawnedcwd: AnyCodable? = nil,
|
||||
spawndepth: AnyCodable? = nil,
|
||||
subagentrole: AnyCodable? = nil,
|
||||
subagentcontrolscope: AnyCodable? = nil,
|
||||
inheritedtoolpolicyversion: AnyCodable? = nil,
|
||||
inheritedtoolallow: AnyCodable? = nil,
|
||||
inheritedtooldeny: AnyCodable? = nil,
|
||||
@@ -6697,13 +6685,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
self.execask = execask
|
||||
self.execnode = execnode
|
||||
self.model = model
|
||||
self.spawnedby = spawnedby
|
||||
self.completionownersessionkey = completionownersessionkey
|
||||
self.spawnedworkspacedir = spawnedworkspacedir
|
||||
self.spawnedcwd = spawnedcwd
|
||||
self.spawndepth = spawndepth
|
||||
self.subagentrole = subagentrole
|
||||
self.subagentcontrolscope = subagentcontrolscope
|
||||
self.inheritedtoolpolicyversion = inheritedtoolpolicyversion
|
||||
self.inheritedtoolallow = inheritedtoolallow
|
||||
self.inheritedtooldeny = inheritedtooldeny
|
||||
@@ -6735,13 +6717,7 @@ public struct SessionsPatchParams: Codable, Sendable {
|
||||
case execask = "execAsk"
|
||||
case execnode = "execNode"
|
||||
case model
|
||||
case spawnedby = "spawnedBy"
|
||||
case completionownersessionkey = "completionOwnerSessionKey"
|
||||
case spawnedworkspacedir = "spawnedWorkspaceDir"
|
||||
case spawnedcwd = "spawnedCwd"
|
||||
case spawndepth = "spawnDepth"
|
||||
case subagentrole = "subagentRole"
|
||||
case subagentcontrolscope = "subagentControlScope"
|
||||
case inheritedtoolpolicyversion = "inheritedToolPolicyVersion"
|
||||
case inheritedtoolallow = "inheritedToolAllow"
|
||||
case inheritedtooldeny = "inheritedToolDeny"
|
||||
|
||||
@@ -867,7 +867,6 @@ src/gateway/session-utils.test.ts
|
||||
src/gateway/session-utils.ts
|
||||
src/gateway/sessions-history-http.test.ts
|
||||
src/gateway/sessions-patch.test.ts
|
||||
src/gateway/sessions-patch.ts
|
||||
src/gateway/talk-realtime-relay.test.ts
|
||||
src/gateway/talk-realtime-relay.ts
|
||||
src/gateway/test-helpers.server.ts
|
||||
|
||||
@@ -1 +1 @@
|
||||
0852c1b681df33646f60d239afcac6de53fbc498bd5daa58077376331205ccdf sqlite-session-transcript-schema-baseline.sql
|
||||
011b9ec0e0fa64b5a4036648fcd61a50674b813a92280e8e3fe8746f6acdb62d sqlite-session-transcript-schema-baseline.sql
|
||||
|
||||
@@ -17,7 +17,9 @@ If people must not access each other's sessions, tools, credentials, or files, g
|
||||
|
||||
## Ownership and presence
|
||||
|
||||
New sessions record their creator when the Gateway has a trusted identity available. Trusted-proxy identity takes priority; otherwise OpenClaw uses the paired device's operator label or display name. Older sessions and sessions created without either identity have no owner stamp.
|
||||
New sessions record a write-once `createdActor` when the creation path can prove who caused it. Authenticated people use their durable Gateway profile id; requesting agents and system paths use the same actor field. Sessions created without a proven actor remain unattributed.
|
||||
|
||||
Human display names are resolved from the current Gateway profile when session rows are returned. OpenClaw does not store labels on session entries, so changing a profile name updates the ownership UI without rewriting session history.
|
||||
|
||||
The web app keeps ownership and presence visually distinct:
|
||||
|
||||
|
||||
@@ -572,13 +572,13 @@ methods. Treat this as feature discovery, not a full enumeration of
|
||||
- `sessions.preview` returns bounded transcript previews for specific session keys.
|
||||
- `sessions.describe` returns one gateway session row for an exact session key.
|
||||
- `sessions.resolve` resolves or canonicalizes a session target.
|
||||
- `sessions.create` creates a new session entry. Optional `model` and `thinkingLevel` values persist the initial model and reasoning overrides atomically. `worktree: true` provisions a managed worktree; optional `worktreeBaseRef`/`worktreeName` select the base ref and branch name, and `execNode` (`operator.admin`) binds session exec to a node host. The created worktree is echoed in the result and persisted on the session row (`worktree: { id, branch, repoRoot }`). When the entry is created but its nested initial `chat.send` is rejected, the successful result includes `runStarted: false` and `runError`; clients can preserve the prompt and retry against the returned session key. A caller that passes `parentSessionKey` with `emitCommandHooks: true` should also declare the lifecycle disposition of a distinct child: `succeedsParent: true` ends the parent with `session_end`, while `false` keeps the parent active and emits only the child's `session_start`. Omitting `succeedsParent` preserves the legacy parent-rollover behavior for existing clients. The disposition requires both parent linkage and command hooks; a fork cannot succeed its parent. Main-session reset-in-place behavior is unchanged because no distinct child is created.
|
||||
- `sessions.create` creates a new session entry. Optional `model` and `thinkingLevel` values persist the initial model and reasoning overrides atomically. `worktree: true` provisions a managed worktree; optional `worktreeBaseRef`/`worktreeName` select the base ref and branch name, and `execNode` (`operator.admin`) binds session exec to a node host. The created worktree is echoed in the result and persisted on the session row (`worktree: { id, branch, repoRoot }`). When the entry is created but its nested initial `chat.send` is rejected, the successful result includes `runStarted: false` and `runError`; clients can preserve the prompt and retry against the returned session key. A caller that passes `parentSessionKey` with `emitCommandHooks: true` should also declare the lifecycle disposition of a distinct child: `succeedsParent: true` ends the parent with `session_end`, while `false` keeps the parent active and emits only the child's `session_start`. Omitting `succeedsParent` preserves the legacy parent-rollover behavior for existing clients. The disposition requires both parent linkage and command hooks; a fork cannot succeed its parent. Main-session reset-in-place behavior is unchanged because no distinct child is created. New rows are stamped with write-once creation provenance (`createdVia`, `createdActor`, `createdAt`) from the trusted creation seam; adopting an existing key never restamps it. For human profile actors, `createdActor.label` is resolved from the current user profile when the row is projected and is never stored on the session entry, so profile renames do not drift. Session rows also carry `parentSessionKey` (navigation parent, persisted), `controlOwnerSessionKey` (runtime controller when live), `forkSource` (exact source key + transcript generation for forks), and `previousSessionId` (prior transcript generation under the same key).
|
||||
- `sessions.dispatch` (`operator.admin`) moves an existing local OpenClaw session with a session-owned managed worktree to a configured cloud-worker profile. Pass `{ key, profileId, agentId? }`. The method is absent when no worker profile is configured, closes local turn admission before draining active work, and returns only after placement reaches `active` worker ownership. Dispatch is one-way; worker-to-local pull-back is not part of this RPC.
|
||||
- `sessions.groups.list`, `sessions.groups.put`, `sessions.groups.rename`, and `sessions.groups.delete` manage the gateway-owned custom session group catalog (names + display order). Membership stays on each session's `category` field; rename and delete update member sessions server-side.
|
||||
- `sessions.send` sends a message into an existing session.
|
||||
- `sessions.steer` is the interrupt-and-steer variant for an active session.
|
||||
- `sessions.abort` aborts active work for a session. Pass `key` plus optional `runId`, or `runId` alone for active runs the gateway can resolve to a session.
|
||||
- `sessions.patch` updates session metadata/overrides and reports the resolved canonical model plus effective `agentRuntime`.
|
||||
- `sessions.patch` updates session metadata/overrides and reports the resolved canonical model plus effective `agentRuntime`. Spawn lineage (`spawnedBy`, `spawnedWorkspaceDir`, `spawnedCwd`, `spawnDepth`, `subagentRole`, `subagentControlScope`) is no longer publicly patchable; those facts are written once by trusted creation paths, and requests that still send them are rejected.
|
||||
- `sessions.reset`, `sessions.delete`, and `sessions.compact` perform session maintenance.
|
||||
- `sessions.get` returns the full stored session row.
|
||||
- Chat execution still uses `chat.history`, `chat.send`, `chat.abort`, and `chat.inject`. `chat.history` is display-normalized for UI clients: inline directive tags are stripped from visible text, plain-text tool-call XML payloads (`<tool_call>...</tool_call>`, `<function_call>...</function_call>`, `<tool_calls>...</tool_calls>`, `<function_calls>...</function_calls>`, and truncated tool-call blocks) and leaked ASCII/full-width model control tokens are stripped, pure silent-token assistant rows (exact `NO_REPLY` / `no_reply`) are omitted, and oversized rows can be replaced with placeholders.
|
||||
|
||||
@@ -26,7 +26,11 @@ export type {
|
||||
WizardNotFoundErrorDetails,
|
||||
} from "./schema/error-codes.js";
|
||||
export * from "./schema/board.js";
|
||||
export { SessionCreatorIdentitySchema, type SessionCreatorIdentity } from "./schema/sessions.js";
|
||||
export {
|
||||
SessionCreatedActorSchema,
|
||||
type SessionCreatedActor,
|
||||
type SessionRow,
|
||||
} from "./schema/sessions-row.js";
|
||||
export * from "./migration-api.js";
|
||||
export type * from "./public-session-catalog.js";
|
||||
import {
|
||||
@@ -413,6 +417,7 @@ import {
|
||||
SecretsResolveParamsSchema,
|
||||
SecretsResolveResultSchema,
|
||||
SessionBranchSchema,
|
||||
SessionRowSchema,
|
||||
SessionsAbortParamsSchema,
|
||||
SessionsCompactParamsSchema,
|
||||
SessionsCleanupParamsSchema,
|
||||
@@ -1156,6 +1161,7 @@ export {
|
||||
SessionsCompactionBranchParamsSchema,
|
||||
SessionsCompactionRestoreParamsSchema,
|
||||
SessionBranchSchema,
|
||||
SessionRowSchema,
|
||||
SessionsBranchesListParamsSchema,
|
||||
SessionsBranchesListResultSchema,
|
||||
SessionsBranchesSwitchParamsSchema,
|
||||
|
||||
@@ -31,7 +31,7 @@ describe("SessionsCatalogListResultSchema", () => {
|
||||
threadId: "thread-1",
|
||||
status: "idle",
|
||||
archived: false,
|
||||
createdBy: { id: "profile-ada", label: "Ada" },
|
||||
createdActor: { type: "human", id: "profile-ada", label: "Ada" },
|
||||
canContinue: true,
|
||||
canArchive: false,
|
||||
canOpenTerminal: true,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Type } from "typebox";
|
||||
import { closedObject } from "./closed-object.js";
|
||||
import { PluginJsonValueSchema } from "./plugins.js";
|
||||
import { NonEmptyString } from "./primitives.js";
|
||||
import { SessionCreatorIdentitySchema } from "./sessions.js";
|
||||
import { SessionCreatedActorSchema } from "./sessions-row.js";
|
||||
|
||||
const SessionCatalogErrorSchema = closedObject({ code: NonEmptyString, message: NonEmptyString });
|
||||
|
||||
@@ -56,7 +56,7 @@ export const SessionCatalogSessionSchema = closedObject({
|
||||
pullRequest: Type.Optional(SessionCatalogPullRequestSummarySchema),
|
||||
archived: Type.Boolean(),
|
||||
sessionKey: Type.Optional(NonEmptyString),
|
||||
createdBy: Type.Optional(SessionCreatorIdentitySchema),
|
||||
createdActor: Type.Optional(SessionCreatedActorSchema),
|
||||
canContinue: Type.Boolean(),
|
||||
canArchive: Type.Boolean(),
|
||||
canOpenTerminal: Type.Optional(Type.Boolean()),
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Static } from "typebox";
|
||||
import { Type } from "typebox";
|
||||
import { closedObject } from "./closed-object.js";
|
||||
import { NonEmptyString } from "./primitives.js";
|
||||
|
||||
/** Projected actor that caused a session node to be created. */
|
||||
export const SessionCreatedActorSchema = closedObject({
|
||||
type: Type.Union([Type.Literal("human"), Type.Literal("agent"), Type.Literal("system")]),
|
||||
id: Type.Optional(NonEmptyString),
|
||||
label: Type.Optional(NonEmptyString),
|
||||
});
|
||||
|
||||
/** Stable Gateway session row fields; mutation envelopes may add null tombstones. */
|
||||
export const SessionRowSchema = Type.Object(
|
||||
{
|
||||
key: Type.String(),
|
||||
sessionId: Type.Optional(Type.String()),
|
||||
kind: Type.Union([
|
||||
Type.Literal("direct"),
|
||||
Type.Literal("group"),
|
||||
Type.Literal("global"),
|
||||
Type.Literal("unknown"),
|
||||
]),
|
||||
label: Type.Optional(Type.String()),
|
||||
displayName: Type.Optional(Type.String()),
|
||||
derivedTitle: Type.Optional(Type.String()),
|
||||
lastMessagePreview: Type.Optional(Type.String()),
|
||||
channel: Type.Optional(Type.String()),
|
||||
chatType: Type.Optional(
|
||||
Type.Union([Type.Literal("direct"), Type.Literal("group"), Type.Literal("channel")]),
|
||||
),
|
||||
updatedAt: Type.Optional(Type.Union([Type.Number(), Type.Null()])),
|
||||
archived: Type.Optional(Type.Boolean()),
|
||||
archivedAt: Type.Optional(Type.Number()),
|
||||
pinned: Type.Optional(Type.Boolean()),
|
||||
pinnedAt: Type.Optional(Type.Number()),
|
||||
icon: Type.Optional(Type.String()),
|
||||
unread: Type.Optional(Type.Boolean()),
|
||||
lastReadAt: Type.Optional(Type.Number()),
|
||||
lastActivityAt: Type.Optional(Type.Number()),
|
||||
lastInteractionAt: Type.Optional(Type.Number()),
|
||||
status: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("running"),
|
||||
Type.Literal("done"),
|
||||
Type.Literal("failed"),
|
||||
Type.Literal("killed"),
|
||||
Type.Literal("timeout"),
|
||||
]),
|
||||
),
|
||||
lastRunError: Type.Optional(Type.String()),
|
||||
spawnedBy: Type.Optional(Type.String()),
|
||||
parentSessionKey: Type.Optional(Type.String()),
|
||||
controlOwnerSessionKey: Type.Optional(Type.String()),
|
||||
childSessions: Type.Optional(Type.Array(Type.String())),
|
||||
forkedFromParent: Type.Optional(Type.Boolean()),
|
||||
spawnDepth: Type.Optional(Type.Number()),
|
||||
subagentRole: Type.Optional(Type.Union([Type.Literal("orchestrator"), Type.Literal("leaf")])),
|
||||
subagentControlScope: Type.Optional(
|
||||
Type.Union([Type.Literal("children"), Type.Literal("none")]),
|
||||
),
|
||||
swarmGroupId: Type.Optional(Type.String()),
|
||||
worktree: Type.Optional(
|
||||
Type.Object({
|
||||
id: Type.String(),
|
||||
branch: Type.String(),
|
||||
repoRoot: Type.String(),
|
||||
}),
|
||||
),
|
||||
execNode: Type.Optional(Type.String()),
|
||||
execCwd: Type.Optional(Type.String()),
|
||||
spawnedWorkspaceDir: Type.Optional(Type.String()),
|
||||
spawnedCwd: Type.Optional(Type.String()),
|
||||
createdVia: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("operator"),
|
||||
Type.Literal("spawn"),
|
||||
Type.Literal("channel"),
|
||||
Type.Literal("cron"),
|
||||
Type.Literal("talk"),
|
||||
Type.Literal("run"),
|
||||
Type.Literal("plugin"),
|
||||
Type.Literal("internal"),
|
||||
]),
|
||||
),
|
||||
createdActor: Type.Optional(SessionCreatedActorSchema),
|
||||
createdAt: Type.Optional(Type.Number()),
|
||||
forkSource: Type.Optional(
|
||||
Type.Object({
|
||||
sessionKey: Type.String(),
|
||||
sessionId: Type.String(),
|
||||
entryId: Type.Optional(Type.String()),
|
||||
}),
|
||||
),
|
||||
previousSessionId: Type.Optional(Type.String()),
|
||||
inputTokens: Type.Optional(Type.Number()),
|
||||
outputTokens: Type.Optional(Type.Number()),
|
||||
totalTokens: Type.Optional(Type.Number()),
|
||||
totalTokensFresh: Type.Optional(Type.Boolean()),
|
||||
contextTokens: Type.Optional(Type.Number()),
|
||||
estimatedCostUsd: Type.Optional(Type.Number()),
|
||||
model: Type.Optional(Type.String()),
|
||||
modelProvider: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: true },
|
||||
);
|
||||
|
||||
export type SessionCreatedActor = Static<typeof SessionCreatedActorSchema>;
|
||||
export type SessionRow = Static<typeof SessionRowSchema>;
|
||||
@@ -9,6 +9,12 @@ import { NonEmptyString, SessionLabelString } from "./primitives.js";
|
||||
import { SessionsCreateParamsSchema } from "./sessions-create.js";
|
||||
|
||||
export { SessionsCreateParamsSchema };
|
||||
export {
|
||||
SessionCreatedActorSchema,
|
||||
SessionRowSchema,
|
||||
type SessionCreatedActor,
|
||||
type SessionRow,
|
||||
} from "./sessions-row.js";
|
||||
|
||||
export const SESSION_OBSERVER_HEALTH_VALUES = [
|
||||
"on-track",
|
||||
@@ -20,13 +26,6 @@ export const SESSION_OBSERVER_HEALTH_VALUES = [
|
||||
"failed",
|
||||
] as const;
|
||||
|
||||
/** Stable identity stamped on a session when an operator creates it. */
|
||||
export const SessionCreatorIdentitySchema = closedObject({
|
||||
id: NonEmptyString,
|
||||
label: Type.Optional(NonEmptyString),
|
||||
});
|
||||
export type SessionCreatorIdentity = Static<typeof SessionCreatorIdentitySchema>;
|
||||
|
||||
/** Trajectory judgment produced for one observed agent session. */
|
||||
export const SessionObserverHealthSchema = Type.Union([
|
||||
Type.Literal("on-track"),
|
||||
@@ -478,17 +477,7 @@ export const SessionsPatchParamsSchema = closedObject({
|
||||
execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
completionOwnerSessionKey: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
spawnedWorkspaceDir: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
spawnedCwd: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
|
||||
spawnDepth: Type.Optional(Type.Union([Type.Integer({ minimum: 0 }), Type.Null()])),
|
||||
subagentRole: Type.Optional(
|
||||
Type.Union([Type.Literal("orchestrator"), Type.Literal("leaf"), Type.Null()]),
|
||||
),
|
||||
subagentControlScope: Type.Optional(
|
||||
Type.Union([Type.Literal("children"), Type.Literal("none"), Type.Null()]),
|
||||
),
|
||||
inheritedToolPolicyVersion: Type.Optional(Type.Union([Type.Literal(1), Type.Null()])),
|
||||
inheritedToolAllow: Type.Optional(Type.Union([Type.Array(NonEmptyString), Type.Null()])),
|
||||
inheritedToolDeny: Type.Optional(Type.Union([Type.Array(NonEmptyString), Type.Null()])),
|
||||
|
||||
@@ -70,6 +70,7 @@ const hoisted = vi.hoisted(() => {
|
||||
const countActiveRunsForSessionMock = vi.fn();
|
||||
const getSubagentRunByChildSessionKeyMock = vi.fn();
|
||||
const listTasksForOwnerKeyMock = vi.fn();
|
||||
const upsertSessionEntryMock = vi.fn();
|
||||
const createSessionAccessorMock = () => {
|
||||
const resolveMockStorePath = (scope: {
|
||||
agentId?: string;
|
||||
@@ -111,6 +112,8 @@ const hoisted = vi.hoisted(() => {
|
||||
listSessionEntriesReadOnly: listMockEntries,
|
||||
loadSessionEntry: loadMockEntry,
|
||||
loadSessionEntryReadOnly: loadMockEntry,
|
||||
upsertSessionEntry: async (scope: unknown, patch: SessionEntry) =>
|
||||
await upsertSessionEntryMock(scope, patch),
|
||||
resolveSessionTranscriptRuntimeTarget: async (scope: {
|
||||
agentId: string;
|
||||
sessionId: string;
|
||||
@@ -161,6 +164,7 @@ const hoisted = vi.hoisted(() => {
|
||||
countActiveRunsForSessionMock,
|
||||
getSubagentRunByChildSessionKeyMock,
|
||||
listTasksForOwnerKeyMock,
|
||||
upsertSessionEntryMock,
|
||||
createSessionAccessorMock,
|
||||
state,
|
||||
};
|
||||
@@ -455,8 +459,8 @@ function expectGatewayMethodNotCalled(method: string): void {
|
||||
expect(gatewayRequests().some((request) => request.method === method)).toBe(false);
|
||||
}
|
||||
|
||||
function expectSessionPatchFields(expected: Record<string, unknown>): void {
|
||||
expectRecordFields(gatewayRequest("sessions.patch").params, expected);
|
||||
function expectCreatedSessionFields(expected: Record<string, unknown>): void {
|
||||
expectRecordFields(firstMockCall(hoisted.upsertSessionEntryMock, "session create")[1], expected);
|
||||
}
|
||||
|
||||
function expectInitializeSessionFields(expected: Record<string, unknown>): Record<string, unknown> {
|
||||
@@ -703,6 +707,13 @@ describe("spawnAcpDirect", () => {
|
||||
hoisted.countActiveRunsForSessionMock.mockReset().mockReturnValue(0);
|
||||
hoisted.getSubagentRunByChildSessionKeyMock.mockReset().mockReturnValue(null);
|
||||
hoisted.listTasksForOwnerKeyMock.mockReset().mockReturnValue([]);
|
||||
hoisted.upsertSessionEntryMock
|
||||
.mockReset()
|
||||
.mockImplementation(async (_scope: unknown, patch: Partial<SessionEntry>) => ({
|
||||
...patch,
|
||||
sessionId: patch.sessionId ?? "sess-123",
|
||||
updatedAt: patch.updatedAt ?? Date.now(),
|
||||
}));
|
||||
|
||||
hoisted.callGatewayMock.mockReset();
|
||||
hoisted.callGatewayMock.mockImplementation(async (argsUnknown: unknown) => {
|
||||
@@ -861,25 +872,25 @@ describe("spawnAcpDirect", () => {
|
||||
expect(accepted.runId).toBe("run-1");
|
||||
expect(accepted.mode).toBe("session");
|
||||
expect(accepted.inlineDelivery).toBe(true);
|
||||
expectSessionPatchFields({
|
||||
key: accepted.childSessionKey,
|
||||
expectCreatedSessionFields({
|
||||
spawnedBy: "agent:main:main",
|
||||
completionOwnerSessionKey: "agent:main:main",
|
||||
inheritedToolPolicyVersion: 1,
|
||||
parentSessionKey: "agent:main:main",
|
||||
createdVia: "spawn",
|
||||
createdActor: { type: "agent", id: "agent:main:main" },
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
expectBindingCallFields({
|
||||
targetKind: "session",
|
||||
placement: "child",
|
||||
});
|
||||
const patchCallIndex = hoisted.callGatewayMock.mock.calls.findIndex(
|
||||
(call: unknown[]) => (call[0] as { method?: string }).method === "sessions.patch",
|
||||
);
|
||||
const agentCallIndex = hoisted.callGatewayMock.mock.calls.findIndex(
|
||||
(call: unknown[]) => (call[0] as { method?: string }).method === "agent",
|
||||
);
|
||||
const patchCallOrder = expectDefined(
|
||||
hoisted.callGatewayMock.mock.invocationCallOrder[patchCallIndex],
|
||||
"hoisted.callGatewayMock.mock.invocationCallOrder[patchCallIndex] test invariant",
|
||||
const createCallOrder = expectDefined(
|
||||
hoisted.upsertSessionEntryMock.mock.invocationCallOrder[0],
|
||||
"hoisted.upsertSessionEntryMock.mock.invocationCallOrder[0] test invariant",
|
||||
);
|
||||
const initializeCallOrder = expectDefined(
|
||||
hoisted.initializeSessionMock.mock.invocationCallOrder[0],
|
||||
@@ -889,10 +900,10 @@ describe("spawnAcpDirect", () => {
|
||||
hoisted.callGatewayMock.mock.invocationCallOrder[agentCallIndex],
|
||||
"hoisted.callGatewayMock.mock.invocationCallOrder[agentCallIndex] test invariant",
|
||||
);
|
||||
expect(typeof patchCallOrder).toBe("number");
|
||||
expect(typeof createCallOrder).toBe("number");
|
||||
expect(typeof initializeCallOrder).toBe("number");
|
||||
expect(typeof agentCallOrder).toBe("number");
|
||||
expect(patchCallOrder < initializeCallOrder).toBe(true);
|
||||
expect(createCallOrder < initializeCallOrder).toBe(true);
|
||||
expect(initializeCallOrder < agentCallOrder).toBe(true);
|
||||
expectResolvedIntroTextInBindMetadata();
|
||||
|
||||
@@ -1419,9 +1430,8 @@ describe("spawnAcpDirect", () => {
|
||||
agentSessionKey: "agent:main:subagent:parent",
|
||||
});
|
||||
|
||||
const accepted = expectAcceptedSpawn(result);
|
||||
expectSessionPatchFields({
|
||||
key: accepted.childSessionKey,
|
||||
expectAcceptedSpawn(result);
|
||||
expectCreatedSessionFields({
|
||||
spawnedBy: "agent:main:subagent:parent",
|
||||
spawnDepth: 2,
|
||||
subagentRole: "leaf",
|
||||
|
||||
+41
-14
@@ -38,7 +38,9 @@ import {
|
||||
loadSessionEntry,
|
||||
loadSessionEntryReadOnly,
|
||||
resolveSessionTranscriptRuntimeTarget,
|
||||
upsertSessionEntry,
|
||||
} from "../config/sessions/session-accessor.js";
|
||||
import { buildSessionCreationStamp } from "../config/sessions/session-entry-provenance.js";
|
||||
import type { SessionAcpMeta, SessionEntry } from "../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { callGateway } from "../gateway/call.js";
|
||||
@@ -59,7 +61,7 @@ import {
|
||||
parseAgentSessionKey,
|
||||
resolveAgentIdFromSessionKey,
|
||||
} from "../routing/session-key.js";
|
||||
import { recordSubagentSpawned } from "../sessions/session-state-events.js";
|
||||
import { recordSessionCreated, recordSubagentSpawned } from "../sessions/session-state-events.js";
|
||||
import { listTasksForOwnerKey } from "../tasks/runtime-internal.js";
|
||||
import { deliveryContextFromSession, normalizeDeliveryContext } from "../utils/delivery-context.js";
|
||||
import {
|
||||
@@ -1240,6 +1242,7 @@ export async function spawnAcpDirect(
|
||||
}
|
||||
|
||||
let sessionCreated = false;
|
||||
let childCreationEntry: SessionEntry | undefined;
|
||||
let initializedRuntime: AcpSpawnRuntimeCloseHandle | undefined;
|
||||
const childIdem = crypto.randomUUID();
|
||||
const parentAgentId = parentSessionKey
|
||||
@@ -1285,20 +1288,37 @@ export async function spawnAcpDirect(
|
||||
};
|
||||
const adapter: SpawnBackendAdapter<AcpBackendState> = {
|
||||
async initialize() {
|
||||
await callGateway({
|
||||
method: "sessions.patch",
|
||||
params: {
|
||||
key: sessionKey,
|
||||
spawnedBy: requesterInternalKey,
|
||||
completionOwnerSessionKey: ownership.completionRequesterSessionKey,
|
||||
...admission.childSessionPatch,
|
||||
inheritedToolPolicyVersion: 1,
|
||||
...inheritedToolAllowPatch(ctx.inheritedToolAllowlist),
|
||||
...inheritedToolDenyPatch(ctx.inheritedToolDenylist),
|
||||
...(params.label ? { label: params.label } : {}),
|
||||
},
|
||||
timeoutMs: 10_000,
|
||||
const creationStamp = buildSessionCreationStamp({
|
||||
via: "spawn",
|
||||
actor: { type: "agent", id: requesterInternalKey },
|
||||
});
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId: targetAgentId });
|
||||
const childSessionPatch = admission.childSessionPatch
|
||||
? {
|
||||
spawnDepth: admission.childSessionPatch.spawnDepth,
|
||||
...(admission.childSessionPatch.subagentRole
|
||||
? { subagentRole: admission.childSessionPatch.subagentRole }
|
||||
: {}),
|
||||
subagentControlScope: admission.childSessionPatch.subagentControlScope,
|
||||
}
|
||||
: {};
|
||||
childCreationEntry =
|
||||
(await upsertSessionEntry(
|
||||
{ storePath, sessionKey },
|
||||
{
|
||||
...creationStamp,
|
||||
spawnedBy: requesterInternalKey,
|
||||
completionOwnerSessionKey: ownership.completionRequesterSessionKey,
|
||||
// Navigation parent is stamped at creation so the durable tree edge
|
||||
// does not depend on the control-lineage field.
|
||||
parentSessionKey: requesterInternalKey,
|
||||
...childSessionPatch,
|
||||
inheritedToolPolicyVersion: 1,
|
||||
...inheritedToolAllowPatch(ctx.inheritedToolAllowlist),
|
||||
...inheritedToolDenyPatch(ctx.inheritedToolDenylist),
|
||||
...(params.label ? { label: params.label } : {}),
|
||||
},
|
||||
)) ?? undefined;
|
||||
sessionCreated = true;
|
||||
const initializedSession = await initializeAcpSpawnRuntime({
|
||||
cfg,
|
||||
@@ -1335,6 +1355,13 @@ export async function spawnAcpDirect(
|
||||
binding: state.binding,
|
||||
});
|
||||
// ACP bypasses the native adapter, so seed the same child lineage before dispatch.
|
||||
if (childCreationEntry) {
|
||||
recordSessionCreated({
|
||||
sessionKey,
|
||||
agentId: targetAgentId,
|
||||
entry: childCreationEntry,
|
||||
});
|
||||
}
|
||||
recordSubagentSpawned({
|
||||
childSessionKey: sessionKey,
|
||||
childRunId: childIdem,
|
||||
|
||||
@@ -26,7 +26,12 @@ describe("internal session effects", () => {
|
||||
|
||||
expect(target.sessionKey).toMatch(/^agent:main:internal-session-effects:run_with_space-/);
|
||||
expect(target.sessionId).toMatch(/^internal-session-effects-run_with_space-/);
|
||||
expect(loadExactSessionEntry(target)?.entry.sessionId).toBe(target.sessionId);
|
||||
expect(loadExactSessionEntry(target)?.entry).toMatchObject({
|
||||
sessionId: target.sessionId,
|
||||
createdVia: "internal",
|
||||
createdActor: { type: "system" },
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
expect(listSessionEntries({ storePath })).toEqual([]);
|
||||
await expect(loadTranscriptEvents(target)).resolves.toEqual([
|
||||
expect.objectContaining({ id: target.sessionId, type: "session" }),
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
replaceTranscriptEvents,
|
||||
upsertSessionEntry,
|
||||
} from "../config/sessions/session-accessor.js";
|
||||
import { buildSessionCreationStamp } from "../config/sessions/session-entry-provenance.js";
|
||||
import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js";
|
||||
import { createSessionTranscriptHeader } from "../config/sessions/transcript-header.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
@@ -93,6 +94,7 @@ export async function prepareInternalSessionEffectsSession(params: {
|
||||
}
|
||||
const now = Date.now();
|
||||
const entry = await upsertSessionEntry(scope, {
|
||||
...buildSessionCreationStamp({ via: "internal", actor: { type: "system" } }),
|
||||
sessionId: scope.sessionId,
|
||||
sessionStartedAt: now,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -645,12 +645,15 @@ export function createOpenClawTools(
|
||||
config: resolvedConfig,
|
||||
senderIsOwner: options?.senderIsOwner,
|
||||
}),
|
||||
// No explicit callGateway: the tool defaults to the same in-process
|
||||
// caller, and an injected override would disable the trusted creation
|
||||
// stamp for materialized agent roots (opts.callGateway === undefined
|
||||
// is the gate in ensureConfiguredAgentMainSession).
|
||||
createSessionsSendTool({
|
||||
agentSessionKey: options?.agentSessionKey,
|
||||
agentChannel: options?.agentChannel,
|
||||
sandboxed: options?.sandboxed,
|
||||
config: resolvedConfig,
|
||||
callGateway,
|
||||
}),
|
||||
]),
|
||||
...(includeSubagentSpawnTool
|
||||
|
||||
@@ -591,57 +591,4 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
|
||||
"delete params",
|
||||
);
|
||||
});
|
||||
|
||||
it("cleans up the provisional session when lineage patching fails after thread binding", async () => {
|
||||
const store: Record<string, Record<string, unknown>> = {};
|
||||
hoisted.updateSessionStoreMock.mockImplementation(
|
||||
async (_storePath: unknown, mutator: unknown) => {
|
||||
if (typeof mutator !== "function") {
|
||||
throw new Error("missing session store mutator");
|
||||
}
|
||||
await mutator(store);
|
||||
if (Object.values(store).some((entry) => typeof entry.spawnedBy === "string")) {
|
||||
throw new Error("lineage patch failed");
|
||||
}
|
||||
return store;
|
||||
},
|
||||
);
|
||||
hoisted.callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string; params?: Record<string, unknown> };
|
||||
if (request.method === "sessions.delete") {
|
||||
return { ok: true };
|
||||
}
|
||||
if (request.method === "agent") {
|
||||
return { runId: "run-1", status: "accepted", acceptedAt: 1_001 };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const result = await spawn({
|
||||
thread: true,
|
||||
mode: "session",
|
||||
agentAccountId: "work",
|
||||
agentTo: "channel:123",
|
||||
agentThreadId: "456",
|
||||
context: "isolated",
|
||||
});
|
||||
|
||||
expect(result.status).toBe("error");
|
||||
expect(result.error).toContain("lineage patch failed");
|
||||
expect(hookRunnerMocks.runSubagentSpawned).not.toHaveBeenCalled();
|
||||
expect(hookRunnerMocks.runSubagentEnded).not.toHaveBeenCalled();
|
||||
const methods = getGatewayMethods();
|
||||
expect(methods).toContain("sessions.delete");
|
||||
expect(methods).not.toContain("agent");
|
||||
const deleteCall = findGatewayRequest("sessions.delete");
|
||||
expectFields(
|
||||
deleteCall?.params,
|
||||
{
|
||||
key: result.childSessionKey,
|
||||
deleteTranscript: true,
|
||||
emitLifecycleHooks: true,
|
||||
},
|
||||
"delete params",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -218,57 +218,4 @@ describe("spawnSubagentDirect filename validation", () => {
|
||||
fs.rmSync(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("removes materialized attachments when lineage patching fails", async () => {
|
||||
// Attachments are created before the child session lineage patch; failures
|
||||
// must delete both the child session and materialized files.
|
||||
const calls: Array<{ method?: string; params?: Record<string, unknown> }> = [];
|
||||
const store: Record<string, Record<string, unknown>> = {};
|
||||
updateSessionStoreMock.mockImplementation(async (_storePath: unknown, mutator: unknown) => {
|
||||
if (typeof mutator !== "function") {
|
||||
throw new Error("missing session store mutator");
|
||||
}
|
||||
await mutator(store);
|
||||
if (Object.values(store).some((entry) => typeof entry.spawnedBy === "string")) {
|
||||
throw new Error("lineage patch failed");
|
||||
}
|
||||
return store;
|
||||
});
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string; params?: Record<string, unknown> };
|
||||
calls.push(request);
|
||||
if (request.method === "sessions.delete") {
|
||||
return { ok: true };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const { spawnSubagentDirect } = subagentSpawnModule;
|
||||
const result = await spawnSubagentDirect(
|
||||
{
|
||||
task: "test",
|
||||
attachments: [{ name: "file.txt", content: validContent, encoding: "base64" }],
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(result.status).toBe("error");
|
||||
expect(result.error).toContain("lineage patch failed");
|
||||
const attachmentsRoot = path.join(workspaceDirOverride, ".openclaw", "attachments");
|
||||
const retainedDirs = fs.existsSync(attachmentsRoot)
|
||||
? fs.readdirSync(attachmentsRoot).filter((entry) => !entry.startsWith("."))
|
||||
: [];
|
||||
expect(retainedDirs).toHaveLength(0);
|
||||
const deleteCall = calls.find((entry) => entry.method === "sessions.delete");
|
||||
const deleteParams = deleteCall?.params as
|
||||
| {
|
||||
key?: string;
|
||||
deleteTranscript?: boolean;
|
||||
emitLifecycleHooks?: boolean;
|
||||
}
|
||||
| undefined;
|
||||
expect(deleteParams?.key).toMatch(/^agent:main:subagent:/);
|
||||
expect(deleteParams?.deleteTranscript).toBe(true);
|
||||
expect(deleteParams?.emitLifecycleHooks).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -84,7 +84,7 @@ describe("spawnSubagentDirect runtime model persistence", () => {
|
||||
expect(result.modelApplied).toBe(true);
|
||||
expect(result.resolvedModel).toBe("openai/gpt-5.4");
|
||||
expect(result.resolvedProvider).toBe("openai");
|
||||
expect(updateSessionStoreMock).toHaveBeenCalledTimes(3);
|
||||
expect(updateSessionStoreMock).toHaveBeenCalledTimes(2);
|
||||
expectPersistedRuntimeModel({
|
||||
persistedStore,
|
||||
sessionKey: /^agent:main:subagent:/,
|
||||
|
||||
@@ -884,7 +884,15 @@ describe("spawnSubagentDirect seam flow", () => {
|
||||
expect(result.childSessionKey).toMatch(/^agent:main:subagent:/);
|
||||
|
||||
const childSessionKey = result.childSessionKey as string;
|
||||
expect(hoisted.updateSessionStoreMock).toHaveBeenCalledTimes(3);
|
||||
expect(hoisted.updateSessionStoreMock).toHaveBeenCalledTimes(2);
|
||||
expect(persistedStore?.[childSessionKey]).toMatchObject({
|
||||
spawnedBy: "agent:main:main",
|
||||
completionOwnerSessionKey: "agent:main:main",
|
||||
parentSessionKey: "agent:main:main",
|
||||
createdVia: "spawn",
|
||||
createdActor: { type: "agent", id: "agent:main:main" },
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
const registerInput = firstRegisteredSubagentRun();
|
||||
const requesterOrigin = requireRecord(registerInput.requesterOrigin);
|
||||
expect(registerInput.runId).toBe("run-1");
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
resolveThreadBindingMaxAgeMsForChannel,
|
||||
resolveThreadBindingSpawnPolicy,
|
||||
} from "../channels/thread-bindings-policy.js";
|
||||
import { buildSessionCreationStamp } from "../config/sessions/session-entry-provenance.js";
|
||||
import type { SessionEntry } from "../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { SubagentSpawnPreparation } from "../context-engine/types.js";
|
||||
@@ -26,7 +27,7 @@ import { stringifyRouteThreadId } from "../plugin-sdk/channel-route.js";
|
||||
import { listRegisteredPluginAgentPromptGuidance } from "../plugins/command-registry-state.js";
|
||||
import type { SubagentLifecycleHookRunner } from "../plugins/hooks.js";
|
||||
import { isValidAgentId, normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
|
||||
import { recordSubagentSpawned } from "../sessions/session-state-events.js";
|
||||
import { recordSessionCreated, recordSubagentSpawned } from "../sessions/session-state-events.js";
|
||||
import type { FastMode } from "../shared/fast-mode.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import type { DeliveryContext } from "../utils/delivery-context.types.js";
|
||||
@@ -243,15 +244,15 @@ async function callSubagentGateway(
|
||||
authorization?: SubagentLaunchAuthorization,
|
||||
): Promise<Awaited<ReturnType<typeof callGateway>>> {
|
||||
// Subagent lifecycle requires methods spanning multiple scope tiers
|
||||
// (sessions.patch / sessions.delete → admin, agent → write). When each call
|
||||
// (sessions.delete → admin, agent → write). When each call
|
||||
// independently negotiates least-privilege scopes the first connection pairs
|
||||
// at a lower tier and every subsequent higher-tier call triggers a
|
||||
// scope-upgrade handshake that headless gateway-client connections cannot
|
||||
// complete interactively, causing close(1008) "pairing required" (#59428).
|
||||
//
|
||||
// Only admin-requiring calls are pinned to ADMIN_SCOPE; other methods (e.g.
|
||||
// "agent" -> write) keep their least-privilege scope. The params-aware
|
||||
// resolver keeps spawn-metadata sessions.patch calls on the admin tier.
|
||||
// "agent" -> write) keep their least-privilege scope. Apply the trusted
|
||||
// launch authorization before resolving the request's required scope.
|
||||
const authorizedParams =
|
||||
params.params != null && typeof params.params === "object" && !Array.isArray(params.params)
|
||||
? applySubagentLaunchAuthorization(params.params as Record<string, unknown>, authorization)
|
||||
@@ -390,6 +391,9 @@ function buildDirectChildSessionPatch(patch: Record<string, unknown>): Partial<S
|
||||
) {
|
||||
entry.completionOwnerSessionKey = patch.completionOwnerSessionKey.trim();
|
||||
}
|
||||
if (typeof patch.parentSessionKey === "string" && patch.parentSessionKey.trim()) {
|
||||
entry.parentSessionKey = patch.parentSessionKey.trim();
|
||||
}
|
||||
if (typeof patch.spawnedWorkspaceDir === "string" && patch.spawnedWorkspaceDir.trim()) {
|
||||
entry.spawnedWorkspaceDir = patch.spawnedWorkspaceDir.trim();
|
||||
}
|
||||
@@ -1340,21 +1344,24 @@ export async function spawnSubagentDirect(
|
||||
}
|
||||
}
|
||||
const resolvedModelMetadata = buildResolvedSubagentModelMetadata(resolvedModel);
|
||||
let childCreationEntry: SessionEntry | undefined;
|
||||
const patchChildSession = async (
|
||||
patch: Record<string, unknown>,
|
||||
creationStamp?: ReturnType<typeof buildSessionCreationStamp>,
|
||||
): Promise<string | undefined> => {
|
||||
try {
|
||||
const target = resolveGatewaySessionStoreTarget({
|
||||
cfg,
|
||||
key: childSessionKey,
|
||||
});
|
||||
await upsertSessionEntry(
|
||||
const updatedEntry = await upsertSessionEntry(
|
||||
{
|
||||
storePath: target.storePath,
|
||||
sessionKey: target.canonicalKey,
|
||||
},
|
||||
buildDirectChildSessionPatch(patch),
|
||||
{ ...buildDirectChildSessionPatch(patch), ...creationStamp },
|
||||
);
|
||||
childCreationEntry ??= updatedEntry ?? undefined;
|
||||
return undefined;
|
||||
} catch (err) {
|
||||
const message =
|
||||
@@ -1364,6 +1371,13 @@ export async function spawnSubagentDirect(
|
||||
};
|
||||
|
||||
const initialChildSessionPatch: Record<string, unknown> = {
|
||||
spawnedBy: spawnedByKey,
|
||||
completionOwnerSessionKey: ownership.completionRequesterSessionKey,
|
||||
// Navigation and control lineage commit with the creation stamp so a
|
||||
// launch failure cannot leave a durable but parentless child row.
|
||||
parentSessionKey: spawnedByKey,
|
||||
...(spawnedWorkspaceDir ? { spawnedWorkspaceDir } : {}),
|
||||
...(spawnedCwd ? { spawnedCwd } : {}),
|
||||
...admission.childSessionPatch,
|
||||
inheritedToolPolicyVersion: 1,
|
||||
...inheritedToolAllowPatch(ctx.inheritedToolAllowlist),
|
||||
@@ -1374,7 +1388,13 @@ export async function spawnSubagentDirect(
|
||||
...(params.outputSchema ? { swarmOutputSchema: params.outputSchema } : {}),
|
||||
};
|
||||
|
||||
const initialPatchError = await patchChildSession(initialChildSessionPatch);
|
||||
const initialPatchError = await patchChildSession(
|
||||
initialChildSessionPatch,
|
||||
buildSessionCreationStamp({
|
||||
via: "spawn",
|
||||
actor: { type: "agent", id: requesterInternalKey },
|
||||
}),
|
||||
);
|
||||
if (initialPatchError) {
|
||||
return {
|
||||
status: "error",
|
||||
@@ -1530,32 +1550,18 @@ export async function spawnSubagentDirect(
|
||||
persistentSession: spawnMode === "session",
|
||||
task,
|
||||
});
|
||||
|
||||
const spawnedMetadata = normalizeSpawnedRunMetadata({
|
||||
spawnedBy: spawnedByKey,
|
||||
...toolSpawnMetadata,
|
||||
workspaceDir: spawnedWorkspaceDir,
|
||||
});
|
||||
const spawnLineagePatchError = await patchChildSession({
|
||||
spawnedBy: spawnedByKey,
|
||||
completionOwnerSessionKey: ownership.completionRequesterSessionKey,
|
||||
...(spawnedMetadata.workspaceDir
|
||||
? { spawnedWorkspaceDir: spawnedMetadata.workspaceDir }
|
||||
: {}),
|
||||
...(spawnedCwd ? { spawnedCwd } : {}),
|
||||
});
|
||||
if (spawnLineagePatchError) {
|
||||
await cleanupFailedSpawnBeforeAgentStart({
|
||||
childSessionKey,
|
||||
attachmentAbsDir,
|
||||
emitLifecycleHooks: threadBindingReady,
|
||||
deleteTranscript: true,
|
||||
|
||||
if (childCreationEntry) {
|
||||
recordSessionCreated({
|
||||
sessionKey: childSessionKey,
|
||||
agentId: targetAgentId,
|
||||
entry: childCreationEntry,
|
||||
});
|
||||
return {
|
||||
status: "error",
|
||||
error: spawnLineagePatchError,
|
||||
childSessionKey,
|
||||
};
|
||||
}
|
||||
recordSubagentSpawned({
|
||||
childSessionKey,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
hasContext: true,
|
||||
dispatch: vi.fn(),
|
||||
callGatewayTool: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../gateway/method-scopes.js", () => ({
|
||||
resolveLeastPrivilegeOperatorScopesForMethod: () => ["operator.write"],
|
||||
}));
|
||||
|
||||
vi.mock("../../gateway/server-plugins.js", () => ({
|
||||
dispatchGatewayMethodInProcess: mocks.dispatch,
|
||||
getInProcessGatewayRequestContext: vi.fn(),
|
||||
hasInProcessGatewayContext: () => mocks.hasContext,
|
||||
}));
|
||||
|
||||
vi.mock("./gateway.js", () => ({ callGatewayTool: mocks.callGatewayTool }));
|
||||
|
||||
import { callInProcessGatewayToolWithCreation } from "./in-process-gateway.js";
|
||||
|
||||
describe("trusted in-process Gateway session creation", () => {
|
||||
beforeEach(() => {
|
||||
mocks.hasContext = true;
|
||||
mocks.dispatch.mockReset().mockResolvedValue({ key: "agent:main:dashboard:child" });
|
||||
mocks.callGatewayTool.mockReset().mockResolvedValue({ key: "agent:main:dashboard:child" });
|
||||
});
|
||||
|
||||
it("surfaces creation provenance only on in-process dispatch", async () => {
|
||||
const creation = {
|
||||
via: "spawn" as const,
|
||||
actor: { type: "agent" as const, id: "agent:main:main" },
|
||||
};
|
||||
await callInProcessGatewayToolWithCreation("sessions.create", { agentId: "main" }, creation);
|
||||
|
||||
expect(mocks.dispatch).toHaveBeenCalledWith(
|
||||
"sessions.create",
|
||||
{ agentId: "main" },
|
||||
{
|
||||
forceSyntheticClient: true,
|
||||
sessionCreation: creation,
|
||||
syntheticScopes: ["operator.write"],
|
||||
},
|
||||
);
|
||||
expect(mocks.callGatewayTool).not.toHaveBeenCalled();
|
||||
|
||||
mocks.hasContext = false;
|
||||
await callInProcessGatewayToolWithCreation("sessions.create", { agentId: "main" }, creation);
|
||||
|
||||
expect(mocks.callGatewayTool).toHaveBeenCalledWith(
|
||||
"sessions.create",
|
||||
{},
|
||||
{ agentId: "main" },
|
||||
{ scopes: ["operator.write"] },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
/** In-process Gateway calls for built-in agent tools. */
|
||||
import { resolveLeastPrivilegeOperatorScopesForMethod } from "../../gateway/method-scopes.js";
|
||||
import type { TrustedSessionCreation } from "../../gateway/server-methods/session-creation-provenance.js";
|
||||
import type { GatewayRequestContext } from "../../gateway/server-methods/types.js";
|
||||
import {
|
||||
dispatchGatewayMethodInProcess,
|
||||
@@ -34,3 +35,20 @@ export const callInProcessGatewayTool: InProcessGatewayCaller = async <T>(
|
||||
}
|
||||
return await callGatewayTool<T>(method, {}, params, { scopes });
|
||||
};
|
||||
|
||||
export async function callInProcessGatewayToolWithCreation<T = Record<string, unknown>>(
|
||||
method: string,
|
||||
params: Record<string, unknown>,
|
||||
creation: TrustedSessionCreation,
|
||||
): Promise<T> {
|
||||
const scopes = resolveLeastPrivilegeOperatorScopesForMethod(method, params);
|
||||
if (hasInProcessGatewayContext()) {
|
||||
return await dispatchGatewayMethodInProcess<T>(method, params, {
|
||||
forceSyntheticClient: true,
|
||||
sessionCreation: creation,
|
||||
syntheticScopes: scopes,
|
||||
});
|
||||
}
|
||||
// The fallback is a real Gateway request; trusted creation metadata never crosses the wire.
|
||||
return await callGatewayTool<T>(method, {}, params, { scopes });
|
||||
}
|
||||
|
||||
@@ -50,6 +50,10 @@ import {
|
||||
} from "../tool-description-presets.js";
|
||||
import type { AnyAgentTool } from "./common.js";
|
||||
import { jsonResult, readNonNegativeIntegerParam, readStringParam } from "./common.js";
|
||||
import {
|
||||
callInProcessGatewayToolWithCreation,
|
||||
hasInProcessGatewayToolContext,
|
||||
} from "./in-process-gateway.js";
|
||||
import { runWithScopedSessionAccess } from "./scoped-session-access.js";
|
||||
import {
|
||||
createSessionVisibilityGuard,
|
||||
@@ -189,6 +193,8 @@ async function ensureConfiguredAgentMainSession(params: {
|
||||
callGateway: GatewayCaller;
|
||||
sessionKey: string;
|
||||
mainKey: string;
|
||||
requesterSessionKey?: string;
|
||||
useTrustedInProcessCreation: boolean;
|
||||
}): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
if (
|
||||
!isConfiguredAgentMainSessionKey({
|
||||
@@ -209,14 +215,26 @@ async function ensureConfiguredAgentMainSession(params: {
|
||||
return { ok: true };
|
||||
} catch {
|
||||
try {
|
||||
await params.callGateway({
|
||||
method: "sessions.create",
|
||||
params: {
|
||||
key: params.sessionKey,
|
||||
agentId: resolveAgentIdFromSessionKey(params.sessionKey),
|
||||
},
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
const createParams = {
|
||||
key: params.sessionKey,
|
||||
agentId: resolveAgentIdFromSessionKey(params.sessionKey),
|
||||
};
|
||||
if (
|
||||
params.useTrustedInProcessCreation &&
|
||||
params.requesterSessionKey &&
|
||||
hasInProcessGatewayToolContext()
|
||||
) {
|
||||
await callInProcessGatewayToolWithCreation("sessions.create", createParams, {
|
||||
via: "internal",
|
||||
actor: { type: "agent", id: params.requesterSessionKey },
|
||||
});
|
||||
} else {
|
||||
await params.callGateway({
|
||||
method: "sessions.create",
|
||||
params: createParams,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
}
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: formatErrorMessage(err) };
|
||||
@@ -621,6 +639,8 @@ export function createSessionsSendTool(opts?: {
|
||||
callGateway: gatewayCall,
|
||||
sessionKey: resolvedKey,
|
||||
mainKey,
|
||||
requesterSessionKey,
|
||||
useTrustedInProcessCreation: opts?.callGateway === undefined,
|
||||
});
|
||||
if (!ensuredSession.ok) {
|
||||
return jsonResult({
|
||||
|
||||
@@ -24,7 +24,11 @@ import { resolveSubagentSpawnOwnership } from "../subagent-spawn-ownership.js";
|
||||
import { resolveConfiguredSubagentRunTimeoutSeconds } from "../subagent-spawn-plan.js";
|
||||
import { resolveSubagentTargetPolicy } from "../subagent-target-policy.js";
|
||||
import { normalizeToolModelOverride, readStringParam, ToolInputError } from "./common.js";
|
||||
import { callInProcessGatewayTool, type InProcessGatewayCaller } from "./in-process-gateway.js";
|
||||
import {
|
||||
callInProcessGatewayTool,
|
||||
callInProcessGatewayToolWithCreation,
|
||||
type InProcessGatewayCaller,
|
||||
} from "./in-process-gateway.js";
|
||||
import { reserveVisibleChildSlot } from "./sessions-spawn-visible-admission.js";
|
||||
|
||||
export const VISIBLE_SESSIONS_SPAWN_SCHEMA = {
|
||||
@@ -274,7 +278,14 @@ export async function maybeSpawnVisibleSession(params: {
|
||||
}
|
||||
try {
|
||||
const gatewayCall = params.options?.callGateway ?? callInProcessGatewayTool;
|
||||
const response = await gatewayCall<{
|
||||
const createGatewayCall: InProcessGatewayCaller =
|
||||
params.options?.callGateway ??
|
||||
((method, requestParams) =>
|
||||
callInProcessGatewayToolWithCreation(method, requestParams, {
|
||||
via: "spawn",
|
||||
actor: { type: "agent", id: requesterKey },
|
||||
}));
|
||||
const response = await createGatewayCall<{
|
||||
key?: string;
|
||||
runStarted?: boolean;
|
||||
runId?: string;
|
||||
|
||||
@@ -10,6 +10,12 @@ import { createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import { extractAssistantText, sanitizeTextContent } from "./chat-history-text.js";
|
||||
|
||||
const callGatewayMock = vi.fn();
|
||||
const inProcessCreationMock = vi.fn(
|
||||
async (..._args: [unknown, unknown, unknown]): Promise<unknown> => ({}),
|
||||
);
|
||||
// Default false mirrors running outside a gateway process; the trusted-creation
|
||||
// regression test flips it on and restores it.
|
||||
let inProcessGatewayContextAvailable = false;
|
||||
const facadeRuntimeMock = vi.hoisted(() => ({
|
||||
sessionKeyResolvers: new Map<
|
||||
string,
|
||||
@@ -25,6 +31,11 @@ const facadeRuntimeMock = vi.hoisted(() => ({
|
||||
vi.mock("../../gateway/call.js", () => ({
|
||||
callGateway: (opts: unknown) => callGatewayMock(opts),
|
||||
}));
|
||||
vi.mock("./in-process-gateway.js", () => ({
|
||||
callInProcessGatewayToolWithCreation: (method: unknown, params: unknown, creation: unknown) =>
|
||||
inProcessCreationMock(method, params, creation),
|
||||
hasInProcessGatewayToolContext: () => inProcessGatewayContextAvailable,
|
||||
}));
|
||||
vi.mock("../../plugin-sdk/facade-runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../../plugin-sdk/facade-runtime.js")>(
|
||||
"../../plugin-sdk/facade-runtime.js",
|
||||
@@ -1243,4 +1254,63 @@ describe("sessions_send gating", () => {
|
||||
expect(waitTimeouts).toEqual([MAX_TIMER_TIMEOUT_MS]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sessions_send agent-main materialization provenance", () => {
|
||||
it("uses the trusted in-process creation stamp in the production assembly (no injected caller)", async () => {
|
||||
inProcessGatewayContextAvailable = true;
|
||||
inProcessCreationMock.mockClear();
|
||||
loadConfigMock.mockReturnValue({
|
||||
session: { scope: "per-sender", mainKey: "main" },
|
||||
tools: {
|
||||
agentToAgent: { enabled: false },
|
||||
sessions: { visibility: "all" },
|
||||
},
|
||||
});
|
||||
callGatewayMock.mockImplementation(async (opts: unknown) => {
|
||||
const request = opts as { method?: string };
|
||||
if (request.method === "sessions.resolve") {
|
||||
// Unmaterialized agent main: the probe fails, forcing creation.
|
||||
throw new Error("unknown session: agent:main:main");
|
||||
}
|
||||
if (request.method === "sessions.create") {
|
||||
throw new Error("plain sessions.create must not be used for trusted materialization");
|
||||
}
|
||||
if (request.method === "chat.history") {
|
||||
return { messages: [] };
|
||||
}
|
||||
if (request.method === "agent") {
|
||||
return { runId: "run-ensure-main", acceptedAt: 1 };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
// Mirror production assembly (openclaw-tools.ts): no callGateway override, so
|
||||
// ensureConfiguredAgentMainSession takes the trusted in-process branch.
|
||||
const tool = createSessionsSendTool({
|
||||
agentSessionKey: "agent:main:dashboard:req-provenance",
|
||||
agentChannel: MAIN_AGENT_CHANNEL,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await tool.execute("call-ensure-main-provenance", {
|
||||
sessionKey: "agent:main:main",
|
||||
message: "wake up",
|
||||
timeoutSeconds: 0,
|
||||
});
|
||||
|
||||
expect(requireDetails(result).status).toBe("accepted");
|
||||
expect(inProcessCreationMock).toHaveBeenCalledTimes(1);
|
||||
expect(inProcessCreationMock).toHaveBeenCalledWith(
|
||||
"sessions.create",
|
||||
{ key: "agent:main:main", agentId: "main" },
|
||||
{
|
||||
via: "internal",
|
||||
actor: { type: "agent", id: "agent:main:dashboard:req-provenance" },
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
inProcessGatewayContextAvailable = false;
|
||||
inProcessCreationMock.mockClear();
|
||||
}
|
||||
});
|
||||
});
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -9,6 +9,7 @@ import { normalizeAnyChannelId } from "../../channels/registry.js";
|
||||
import { applyMergePatch } from "../../config/merge-patch.js";
|
||||
import { resolveStorePath } from "../../config/sessions/paths.js";
|
||||
import { loadSessionEntry, listSessionEntries } from "../../config/sessions/session-accessor.js";
|
||||
import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js";
|
||||
import { resolveSessionKey } from "../../config/sessions/session-key.js";
|
||||
import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js";
|
||||
import type { SessionEntry, SessionScope } from "../../config/sessions/types.js";
|
||||
@@ -214,8 +215,25 @@ export function initFastReplySessionState(params: {
|
||||
const sessionEntry: SessionEntry = {
|
||||
...(!resetTriggered ? existingEntry : undefined),
|
||||
sessionId,
|
||||
...((resetTriggered || !existingEntry) && ctx.SessionCreator
|
||||
? { createdBy: { ...ctx.SessionCreator } }
|
||||
...(!existingEntry && ctx.SessionCreation
|
||||
? buildSessionCreationStamp(ctx.SessionCreation)
|
||||
: {}),
|
||||
...(resetTriggered && existingEntry
|
||||
? {
|
||||
previousSessionId: existingEntry.sessionId,
|
||||
spawnedBy: existingEntry.spawnedBy,
|
||||
spawnedWorkspaceDir: existingEntry.spawnedWorkspaceDir,
|
||||
spawnedCwd: existingEntry.spawnedCwd,
|
||||
parentSessionKey: existingEntry.parentSessionKey,
|
||||
forkedFromParent: existingEntry.forkedFromParent,
|
||||
forkSource: existingEntry.forkSource,
|
||||
createdVia: existingEntry.createdVia,
|
||||
createdActor: existingEntry.createdActor,
|
||||
createdAt: existingEntry.createdAt,
|
||||
spawnDepth: existingEntry.spawnDepth,
|
||||
subagentRole: existingEntry.subagentRole,
|
||||
subagentControlScope: existingEntry.subagentControlScope,
|
||||
}
|
||||
: {}),
|
||||
sessionFile,
|
||||
updatedAt: now,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from "../../agents/model-selection.js";
|
||||
import { loadPreparedModelCatalog } from "../../agents/prepared-model-catalog.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { recordSessionCreated } from "../../sessions/session-state-events.js";
|
||||
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
|
||||
import type { SkillCommandSpec } from "../../skills/types.js";
|
||||
import { isInternalMessageChannel } from "../../utils/message-channel.js";
|
||||
@@ -169,6 +170,13 @@ export async function maybeResolveNativeSlashCommandFastReply(params: {
|
||||
};
|
||||
}
|
||||
const persistedInitialEntry = persistence.entry;
|
||||
if (creatingSession) {
|
||||
recordSessionCreated({
|
||||
sessionKey: sessionState.sessionKey,
|
||||
agentId: params.agentId,
|
||||
entry: persistedInitialEntry,
|
||||
});
|
||||
}
|
||||
// Commit the synthesized activity/channel touch before commands or directives
|
||||
// capture their own mutation baseline.
|
||||
sessionState.sessionEntry = persistedInitialEntry;
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
MODEL_SELECTION_LOCKED_RESET_MESSAGE,
|
||||
ModelSelectionLockedError,
|
||||
} from "../../sessions/model-overrides.js";
|
||||
import { listSessionStateEventsSince } from "../../sessions/session-state-events.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
|
||||
import { getReplyPayloadMetadata } from "../reply-payload.js";
|
||||
import { handleGoalCommand } from "./commands-goal.js";
|
||||
import { buildFastReplyCommandContext, initFastReplySessionState } from "./get-reply-fast-path.js";
|
||||
@@ -200,6 +202,7 @@ describe("getReplyFromConfig fast test bootstrap", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
cliBackendsTesting.resetDepsForTest();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
@@ -604,6 +607,7 @@ describe("getReplyFromConfig fast test bootstrap", () => {
|
||||
it("handles native slash directives before workspace bootstrap", async () => {
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-slash-fast-"));
|
||||
const targetSessionKey = "agent:main:telegram:123";
|
||||
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(home, "state"));
|
||||
const cfg = markCompleteReplyConfig({
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -628,6 +632,10 @@ describe("getReplyFromConfig fast test bootstrap", () => {
|
||||
CommandAuthorized: true,
|
||||
SessionKey: "telegram:slash:123",
|
||||
CommandTargetSessionKey: targetSessionKey,
|
||||
SessionCreation: {
|
||||
via: "operator",
|
||||
actor: { type: "human", id: "profile-native-slash" },
|
||||
},
|
||||
}),
|
||||
undefined,
|
||||
cfg,
|
||||
@@ -644,6 +652,13 @@ describe("getReplyFromConfig fast test bootstrap", () => {
|
||||
expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled();
|
||||
expect(mocks.handleCommands).toHaveBeenCalledOnce();
|
||||
expect(mocks.resolveReplyDirectives).toHaveBeenCalledOnce();
|
||||
expect(listSessionStateEventsSince(targetSessionKey, "main", 0, 20).events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "created",
|
||||
actorType: "human",
|
||||
actorId: "profile-native-slash",
|
||||
}),
|
||||
);
|
||||
const directiveParams = requireDirectiveParams();
|
||||
expect(directiveParams.sessionKey).toBe(targetSessionKey);
|
||||
expect(directiveParams.workspaceDir).toBe("/tmp/workspace");
|
||||
@@ -759,6 +774,28 @@ describe("getReplyFromConfig fast test bootstrap", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("stamps trusted creation provenance during fast bootstrap", () => {
|
||||
const result = initFastReplySessionState({
|
||||
ctx: buildGetReplyCtx({
|
||||
SessionKey: "agent:main:dashboard:created",
|
||||
SessionCreation: {
|
||||
via: "operator",
|
||||
actor: { type: "human", id: "profile-ada" },
|
||||
},
|
||||
}),
|
||||
cfg: { session: { store: "/tmp/sessions.json" } } as OpenClawConfig,
|
||||
agentId: "main",
|
||||
commandAuthorized: true,
|
||||
workspaceDir: "/tmp/workspace",
|
||||
});
|
||||
|
||||
expect(result.sessionEntry).toMatchObject({
|
||||
createdVia: "operator",
|
||||
createdActor: { type: "human", id: "profile-ada" },
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves usage footer mode during fast reset bootstrap", async () => {
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fast-reset-usage-"));
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
@@ -788,6 +825,57 @@ describe("getReplyFromConfig fast test bootstrap", () => {
|
||||
expect(result.sessionEntry.responseUsage).toBe("full");
|
||||
});
|
||||
|
||||
it("preserves node provenance and lineage during fast reset bootstrap", async () => {
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fast-reset-lineage-"));
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
const sessionKey = "agent:main:telegram:lineage";
|
||||
await seedFastPathSessionStore(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: "existing-fast-reset-lineage",
|
||||
updatedAt: Date.now(),
|
||||
spawnedBy: "agent:main:main",
|
||||
parentSessionKey: "agent:main:dashboard:parent",
|
||||
spawnedWorkspaceDir: "/tmp/workspace",
|
||||
spawnedCwd: "/tmp/repo",
|
||||
forkSource: { sessionKey: "agent:main:main", sessionId: "source-generation" },
|
||||
createdVia: "spawn",
|
||||
createdActor: { type: "agent", id: "agent:main:main" },
|
||||
createdAt: 1_234,
|
||||
spawnDepth: 2,
|
||||
subagentRole: "orchestrator",
|
||||
subagentControlScope: "children",
|
||||
},
|
||||
});
|
||||
|
||||
const result = initFastReplySessionState({
|
||||
ctx: buildGetReplyCtx({
|
||||
Body: "/reset",
|
||||
RawBody: "/reset",
|
||||
CommandBody: "/reset",
|
||||
SessionKey: sessionKey,
|
||||
}),
|
||||
cfg: { session: { store: storePath } } as OpenClawConfig,
|
||||
agentId: "main",
|
||||
commandAuthorized: true,
|
||||
workspaceDir: home,
|
||||
});
|
||||
|
||||
expect(result.sessionEntry).toMatchObject({
|
||||
previousSessionId: "existing-fast-reset-lineage",
|
||||
spawnedBy: "agent:main:main",
|
||||
parentSessionKey: "agent:main:dashboard:parent",
|
||||
spawnedWorkspaceDir: "/tmp/workspace",
|
||||
spawnedCwd: "/tmp/repo",
|
||||
forkSource: { sessionKey: "agent:main:main", sessionId: "source-generation" },
|
||||
createdVia: "spawn",
|
||||
createdActor: { type: "agent", id: "agent:main:main" },
|
||||
createdAt: 1_234,
|
||||
spawnDepth: 2,
|
||||
subagentRole: "orchestrator",
|
||||
subagentControlScope: "children",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a fast reset bootstrap for a model-locked session", async () => {
|
||||
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fast-reset-locked-"));
|
||||
const storePath = path.join(home, "sessions.json");
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { upsertSessionEntry } from "../../config/sessions/session-accessor.js";
|
||||
import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js";
|
||||
import { initSessionState } from "./session.js";
|
||||
|
||||
let tempDir: string | undefined;
|
||||
|
||||
afterEach(async () => {
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { force: true, recursive: true });
|
||||
tempDir = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it("clears the previous creator when an ownerless turn starts a new generation", async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-creator-"));
|
||||
const storePath = path.join(tempDir, "sessions.json");
|
||||
const sessionKey = "agent:main:telegram:chat:creator";
|
||||
await upsertSessionEntry(
|
||||
{ sessionKey, storePath },
|
||||
{
|
||||
createdBy: { id: "alice@example.com", label: "Alice" },
|
||||
sessionId: "owned-session",
|
||||
updatedAt: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: { Body: "/new", CommandBody: "/new", SessionKey: sessionKey },
|
||||
cfg: { session: { store: storePath } } as OpenClawConfig,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(result.sessionEntry).not.toHaveProperty("createdBy");
|
||||
});
|
||||
@@ -188,6 +188,10 @@ describe("forkSessionEntryFromParent", () => {
|
||||
const stored = loadSessionEntry({ agentId: "main", sessionKey, storePath });
|
||||
expect(stored).toMatchObject({
|
||||
forkedFromParent: true,
|
||||
forkSource: {
|
||||
sessionKey: parentSessionKey,
|
||||
sessionId: "parent-session",
|
||||
},
|
||||
label: "forked child",
|
||||
sessionFile: result.fork.sessionFile,
|
||||
sessionId: result.fork.sessionId,
|
||||
@@ -269,6 +273,9 @@ describe("forkSessionEntryFromParent", () => {
|
||||
sessionId: "",
|
||||
updatedAt: expect.any(Number),
|
||||
});
|
||||
expect(
|
||||
loadSessionEntry({ agentId: "main", sessionKey, storePath })?.forkSource,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips stale-token SQLite parents using transcript usage estimates", async () => {
|
||||
|
||||
@@ -60,6 +60,10 @@ export async function prepareReplySessionParentFork(params: {
|
||||
...buildMainSessionRecoveryClearPatch(params.sessionEntry),
|
||||
sessionId: fork.sessionId,
|
||||
sessionFile: fork.sessionFile,
|
||||
forkSource: {
|
||||
sessionKey: params.parentSessionKey,
|
||||
sessionId: parentEntry.sessionId,
|
||||
},
|
||||
forkedFromParent: true,
|
||||
totalTokens: undefined,
|
||||
totalTokensFresh: false,
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
isSessionLifecycleMutationActive,
|
||||
runExclusiveSessionLifecycleMutation,
|
||||
} from "../../sessions/session-lifecycle-admission.js";
|
||||
import { listSessionStateEventsSince } from "../../sessions/session-state-events.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
|
||||
import {
|
||||
createChannelTestPluginBase,
|
||||
createTestRegistry,
|
||||
@@ -579,6 +581,7 @@ beforeEach(() => {
|
||||
});
|
||||
});
|
||||
afterEach(async () => {
|
||||
closeOpenClawStateDatabaseForTest();
|
||||
resetSystemEventsForTest();
|
||||
await sessionMcpTesting.resetSessionMcpRuntimeManager();
|
||||
});
|
||||
@@ -720,6 +723,10 @@ describe("initSessionState thread forking", () => {
|
||||
expect(result.sessionEntry.totalTokensFresh).toBe(false);
|
||||
|
||||
expect(result.sessionEntry.forkedFromParent).toBe(true);
|
||||
expect(result.sessionEntry.forkSource).toEqual({
|
||||
sessionKey: parentSessionKey,
|
||||
sessionId: parentSessionId,
|
||||
});
|
||||
expect(result.sessionEntry.sessionFile).toBe(
|
||||
formatSqliteSessionFileMarker({
|
||||
agentId: "main",
|
||||
@@ -811,6 +818,10 @@ describe("initSessionState thread forking", () => {
|
||||
|
||||
expect(first.sessionEntry.sessionId).not.toBe("preseed-thread-session");
|
||||
expect(first.sessionEntry.forkedFromParent).toBe(true);
|
||||
expect(first.sessionEntry.forkSource).toEqual({
|
||||
sessionKey: parentSessionKey,
|
||||
sessionId: parentSessionId,
|
||||
});
|
||||
expect(first.sessionEntry.totalTokens).toBeUndefined();
|
||||
expect(first.sessionEntry.totalTokensFresh).toBe(false);
|
||||
expect(first.sessionEntry.abortedLastRun).toBe(false);
|
||||
@@ -899,6 +910,7 @@ describe("initSessionState thread forking", () => {
|
||||
|
||||
// Should be marked as forked (to prevent re-attempts) but NOT actually forked from parent
|
||||
expect(result.sessionEntry.forkedFromParent).toBe(true);
|
||||
expect(result.sessionEntry.forkSource).toBeUndefined();
|
||||
// Session ID should NOT match the parent — it should be a fresh UUID
|
||||
expect(result.sessionEntry.sessionId).not.toBe(parentSessionId);
|
||||
// Session file should NOT be the parent's file (it was not forked)
|
||||
@@ -1308,6 +1320,97 @@ describe("initSessionState RawBody", () => {
|
||||
expect(store[sessionKey]?.modelOverrideSource).toBe("user");
|
||||
});
|
||||
|
||||
it("stamps trusted creation provenance when initializing a missing session", async () => {
|
||||
const root = await makeCaseDir("openclaw-session-creation-provenance-");
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const sessionKey = "agent:main:dashboard:created";
|
||||
|
||||
const result = await withEnvAsync(
|
||||
{ OPENCLAW_STATE_DIR: path.join(root, "state") },
|
||||
async () => {
|
||||
const initialized = await initSessionState({
|
||||
ctx: {
|
||||
RawBody: "hello",
|
||||
ChatType: "direct",
|
||||
SessionKey: sessionKey,
|
||||
SessionCreation: {
|
||||
via: "operator",
|
||||
actor: { type: "human", id: "profile-ada" },
|
||||
},
|
||||
},
|
||||
cfg: { session: { store: storePath } } as OpenClawConfig,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
expect(listSessionStateEventsSince(sessionKey, "main", 0, 20).events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "created",
|
||||
actorType: "human",
|
||||
actorId: "profile-ada",
|
||||
}),
|
||||
);
|
||||
return initialized;
|
||||
},
|
||||
);
|
||||
expect(result.sessionEntry).toMatchObject({
|
||||
createdVia: "operator",
|
||||
createdActor: { type: "human", id: "profile-ada" },
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves session lineage across an implicit daily stale rollover (#90119)", async () => {
|
||||
const root = await makeCaseDir("openclaw-daily-rollover-lineage-");
|
||||
const storePath = path.join(root, "sessions.json");
|
||||
const sessionKey = "agent:main:subagent:daily-rollover-lineage";
|
||||
const existingSessionId = "session-before-daily-reset-lineage";
|
||||
const staleStartedAt = Date.now() - 48 * 60 * 60 * 1000;
|
||||
const lineage = {
|
||||
spawnedBy: "agent:main:main",
|
||||
spawnedWorkspaceDir: "/tmp/child-workspace",
|
||||
spawnedCwd: "/tmp/task-repo",
|
||||
parentSessionKey: "agent:main:main",
|
||||
forkedFromParent: true,
|
||||
forkSource: {
|
||||
sessionKey: "agent:main:root",
|
||||
sessionId: "root-transcript-generation",
|
||||
},
|
||||
createdVia: "spawn",
|
||||
createdActor: { type: "agent", id: "agent:main:main" },
|
||||
createdAt: staleStartedAt - 1_000,
|
||||
spawnDepth: 1,
|
||||
subagentRole: "leaf",
|
||||
subagentControlScope: "none",
|
||||
} as const;
|
||||
|
||||
await writeSessionStoreFast(storePath, {
|
||||
[sessionKey]: {
|
||||
sessionId: existingSessionId,
|
||||
updatedAt: staleStartedAt,
|
||||
sessionStartedAt: staleStartedAt,
|
||||
lastInteractionAt: staleStartedAt,
|
||||
...lineage,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await initSessionState({
|
||||
ctx: {
|
||||
RawBody: "continue child work",
|
||||
ChatType: "direct",
|
||||
SessionKey: sessionKey,
|
||||
},
|
||||
cfg: {
|
||||
session: { store: storePath, reset: { mode: "daily", atHour: 4 } },
|
||||
} as OpenClawConfig,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
|
||||
expect(result.isNewSession).toBe(true);
|
||||
expect(result.resetTriggered).toBe(false);
|
||||
expect(result.sessionId).not.toBe(existingSessionId);
|
||||
expect(result.sessionEntry.previousSessionId).toBe(existingSessionId);
|
||||
expectEntryFields(result.sessionEntry, lineage);
|
||||
});
|
||||
|
||||
it("preserves user-set behavior and pinned state across an implicit daily stale rollover (#92562)", async () => {
|
||||
// Regression: session-level behavior overrides (/think, /verbose, /reasoning,
|
||||
// /trace, ttsAuto) survive an explicit /new but were dropped after the
|
||||
@@ -3979,6 +4082,7 @@ describe("initSessionState preserves behavior overrides across /new and /reset",
|
||||
const overrides = {
|
||||
spawnedBy: "agent:main:main",
|
||||
spawnedWorkspaceDir: "/tmp/child-workspace",
|
||||
spawnedCwd: "/tmp/task-repo",
|
||||
parentSessionKey: "agent:main:main",
|
||||
forkedFromParent: true,
|
||||
spawnDepth: 2,
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
commitReplySessionInitialization,
|
||||
loadReplySessionInitializationSnapshot,
|
||||
} from "../../config/sessions/session-accessor.js";
|
||||
import { sessionEntryForkedFromParent } from "../../config/sessions/session-entry-lineage.js";
|
||||
import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js";
|
||||
import { resolveSessionKey } from "../../config/sessions/session-key.js";
|
||||
import { resolveMaintenanceConfigFromInput } from "../../config/sessions/store-maintenance.js";
|
||||
import { runExclusiveSessionStoreWrite } from "../../config/sessions/store-writer.js";
|
||||
@@ -77,6 +79,7 @@ import {
|
||||
interruptSessionWorkAdmissions,
|
||||
runExclusiveSessionLifecycleMutation,
|
||||
} from "../../sessions/session-lifecycle-admission.js";
|
||||
import { recordSessionCreated } from "../../sessions/session-state-events.js";
|
||||
import {
|
||||
classifySessionStateActor,
|
||||
registerMainSessionGroupWatch,
|
||||
@@ -477,6 +480,10 @@ async function initSessionStateAttemptLocked(
|
||||
let persistedSpawnedCwd: SessionEntry["spawnedCwd"];
|
||||
let persistedParentSessionKey: SessionEntry["parentSessionKey"];
|
||||
let persistedForkedFromParent: SessionEntry["forkedFromParent"];
|
||||
let persistedForkSource: SessionEntry["forkSource"];
|
||||
let persistedCreatedVia: SessionEntry["createdVia"];
|
||||
let persistedCreatedActor: SessionEntry["createdActor"];
|
||||
let persistedCreatedAt: SessionEntry["createdAt"];
|
||||
let persistedSpawnDepth: SessionEntry["spawnDepth"];
|
||||
let persistedSubagentRole: SessionEntry["subagentRole"];
|
||||
let persistedSubagentControlScope: SessionEntry["subagentControlScope"];
|
||||
@@ -584,6 +591,7 @@ async function initSessionStateAttemptLocked(
|
||||
ctx,
|
||||
});
|
||||
const entry = initializationSnapshot.currentEntry;
|
||||
const createdNewEntry = entry === undefined;
|
||||
const archivedSessionError = resolveSessionWorkStartError(sessionKey, entry);
|
||||
if (archivedSessionError) {
|
||||
throw new Error(archivedSessionError);
|
||||
@@ -804,17 +812,18 @@ async function initSessionStateAttemptLocked(
|
||||
persistedResponseUsage = entry.responseUsage;
|
||||
persistedLabel = entry.label;
|
||||
persistedDisplayName = entry.displayName;
|
||||
}
|
||||
// When a reset trigger (/new, /reset) starts a new session, also rotate the
|
||||
// underlying CLI conversation and carry forward spawn lineage.
|
||||
if (resetTriggered && entry) {
|
||||
// Explicit /new and /reset should rotate the underlying CLI conversation too.
|
||||
// Keep the model/auth choice, but force the next turn to mint a fresh CLI binding.
|
||||
// Explicit /new and /reset rotate CLI conversation bindings elsewhere.
|
||||
// Lineage/control facts belong to the session node and survive ANY rollover
|
||||
// from an existing entry, following the #90119 carry pattern.
|
||||
persistedSpawnedBy = entry.spawnedBy;
|
||||
persistedSpawnedWorkspaceDir = entry.spawnedWorkspaceDir;
|
||||
persistedSpawnedCwd = entry.spawnedCwd;
|
||||
persistedParentSessionKey = entry.parentSessionKey;
|
||||
persistedForkedFromParent = entry.forkedFromParent;
|
||||
persistedForkSource = entry.forkSource;
|
||||
persistedCreatedVia = entry.createdVia;
|
||||
persistedCreatedActor = entry.createdActor;
|
||||
persistedCreatedAt = entry.createdAt;
|
||||
persistedSpawnDepth = entry.spawnDepth;
|
||||
persistedSubagentRole = entry.subagentRole;
|
||||
persistedSubagentControlScope = entry.subagentControlScope;
|
||||
@@ -902,16 +911,11 @@ async function initSessionStateAttemptLocked(
|
||||
const lastTo = deliveryFields.lastTo ?? lastToRaw;
|
||||
const lastAccountId = deliveryFields.lastAccountId ?? lastAccountIdRaw;
|
||||
const lastThreadId = deliveryFields.lastThreadId ?? lastThreadIdRaw;
|
||||
const creationStamp =
|
||||
!entry && ctx.SessionCreation ? buildSessionCreationStamp(ctx.SessionCreation) : undefined;
|
||||
sessionEntry = {
|
||||
...baseEntry,
|
||||
sessionId,
|
||||
...(isNewSession
|
||||
? ctx.SessionCreator
|
||||
? { createdBy: { ...ctx.SessionCreator } }
|
||||
: {}
|
||||
: baseEntry?.createdBy
|
||||
? { createdBy: baseEntry.createdBy }
|
||||
: {}),
|
||||
updatedAt: Date.now(),
|
||||
sessionStartedAt: isNewSession
|
||||
? now
|
||||
@@ -930,6 +934,7 @@ async function initSessionStateAttemptLocked(
|
||||
pinnedAt: entry?.pinnedAt,
|
||||
usageFamilyKey,
|
||||
usageFamilySessionIds,
|
||||
previousSessionId: previousSessionEntry?.sessionId ?? baseEntry?.previousSessionId,
|
||||
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
|
||||
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
|
||||
modelOverrideSource: persistedModelOverrideSource ?? baseEntry?.modelOverrideSource,
|
||||
@@ -947,6 +952,10 @@ async function initSessionStateAttemptLocked(
|
||||
spawnedCwd: persistedSpawnedCwd ?? baseEntry?.spawnedCwd,
|
||||
parentSessionKey: persistedParentSessionKey ?? baseEntry?.parentSessionKey,
|
||||
forkedFromParent: persistedForkedFromParent ?? baseEntry?.forkedFromParent,
|
||||
forkSource: persistedForkSource ?? baseEntry?.forkSource,
|
||||
createdVia: persistedCreatedVia ?? baseEntry?.createdVia ?? creationStamp?.createdVia,
|
||||
createdActor: persistedCreatedActor ?? baseEntry?.createdActor ?? creationStamp?.createdActor,
|
||||
createdAt: persistedCreatedAt ?? baseEntry?.createdAt ?? creationStamp?.createdAt,
|
||||
spawnDepth: persistedSpawnDepth ?? baseEntry?.spawnDepth,
|
||||
subagentRole: persistedSubagentRole ?? baseEntry?.subagentRole,
|
||||
subagentControlScope: persistedSubagentControlScope ?? baseEntry?.subagentControlScope,
|
||||
@@ -999,7 +1008,7 @@ async function initSessionStateAttemptLocked(
|
||||
sessionEntry.displayName = threadLabel;
|
||||
}
|
||||
const parentSessionKey = normalizeOptionalString(ctx.ParentSessionKey);
|
||||
const alreadyForked = sessionEntry.forkedFromParent === true;
|
||||
const alreadyForked = sessionEntryForkedFromParent(sessionEntry);
|
||||
if (params.signal?.aborted === true) {
|
||||
throw new Error("reply session initialization aborted");
|
||||
}
|
||||
@@ -1093,6 +1102,9 @@ async function initSessionStateAttemptLocked(
|
||||
}
|
||||
sessionEntry = committed.sessionEntry;
|
||||
sessionId = sessionEntry.sessionId;
|
||||
if (createdNewEntry) {
|
||||
recordSessionCreated({ sessionKey, agentId, entry: sessionEntry });
|
||||
}
|
||||
if (
|
||||
!isSystemEvent &&
|
||||
classifySessionStateActor({ inputProvenance: ctx.InputProvenance }).actorType === "human"
|
||||
|
||||
@@ -276,8 +276,11 @@ export type MsgContext = {
|
||||
OwnerAllowFrom?: Array<string | number>;
|
||||
SenderName?: string;
|
||||
SenderId?: string;
|
||||
/** Trusted Gateway operator identity used only when creating a session. */
|
||||
SessionCreator?: import("../../packages/gateway-protocol/src/schema/sessions.js").SessionCreatorIdentity;
|
||||
/** Trusted in-process creation provenance; never populated from channel payloads. */
|
||||
SessionCreation?: {
|
||||
via: import("../config/sessions/session-entry-provenance.js").SessionCreatedVia;
|
||||
actor?: import("../config/sessions/session-entry-provenance.js").SessionCreatedActor;
|
||||
};
|
||||
SenderUsername?: string;
|
||||
SenderTag?: string;
|
||||
SenderE164?: string;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
|
||||
import { theme } from "../../packages/terminal-core/src/theme.js";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js";
|
||||
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
|
||||
|
||||
/** Display row derived from a persisted session entry. */
|
||||
@@ -66,7 +67,7 @@ export function toSessionDisplayRow(key: string, entry: SessionEntry): SessionDi
|
||||
spawnedWorkspaceDir: entry?.spawnedWorkspaceDir,
|
||||
spawnedCwd: entry?.spawnedCwd,
|
||||
parentSessionKey: entry?.parentSessionKey,
|
||||
forkedFromParent: entry?.forkedFromParent,
|
||||
forkedFromParent: sessionEntryForkedFromParent(entry) ? true : undefined,
|
||||
spawnDepth: entry?.spawnDepth,
|
||||
subagentRole: entry?.subagentRole,
|
||||
subagentControlScope: entry?.subagentControlScope,
|
||||
|
||||
@@ -1258,7 +1258,11 @@ describe("sqlite session normalization", () => {
|
||||
await upsertSqliteSessionEntry(scope, {
|
||||
channel: "telegram",
|
||||
chatType: "group",
|
||||
createdVia: "channel",
|
||||
createdActor: { type: "human", id: "telegram-sender" },
|
||||
createdAt: 1_782_973_390_000,
|
||||
displayName: "telegram:g-bucephalus-+-topics",
|
||||
forkSource: { sessionKey: "agent:main:main", sessionId: "root-session" },
|
||||
sessionId: oldSessionId,
|
||||
status: "killed",
|
||||
updatedAt: 1_782_973_392_492,
|
||||
@@ -1298,6 +1302,11 @@ describe("sqlite session normalization", () => {
|
||||
expect(loadSqliteSessionEntry(scope)).toEqual(
|
||||
expect.objectContaining({
|
||||
sessionId: newSessionId,
|
||||
createdVia: "channel",
|
||||
createdActor: { type: "human", id: "telegram-sender" },
|
||||
createdAt: 1_782_973_390_000,
|
||||
forkSource: { sessionKey: "agent:main:main", sessionId: "root-session" },
|
||||
previousSessionId: oldSessionId,
|
||||
usageFamilyKey: sessionKey,
|
||||
usageFamilySessionIds: [oldSessionId, newSessionId],
|
||||
}),
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
import {
|
||||
normalizeSqliteStatus,
|
||||
parseSqliteSessionEntryJson as parseSessionEntryRow,
|
||||
serializeSqliteSessionCreatorIdentity,
|
||||
} from "./session-accessor.sqlite-status.js";
|
||||
import {
|
||||
readTranscriptMutationStateInTransaction,
|
||||
@@ -488,7 +487,6 @@ export function writeSessionEntry(
|
||||
entry_json: JSON.stringify(normalizedEntry),
|
||||
updated_at: updatedAt,
|
||||
status: normalizeSqliteStatus(normalizedEntry.status),
|
||||
created_by_json: serializeSqliteSessionCreatorIdentity(normalizedEntry.createdBy),
|
||||
})
|
||||
.onConflict((conflict) =>
|
||||
conflict.column("session_key").doUpdateSet({
|
||||
@@ -496,7 +494,6 @@ export function writeSessionEntry(
|
||||
entry_json: JSON.stringify(normalizedEntry),
|
||||
updated_at: updatedAt,
|
||||
status: normalizeSqliteStatus(normalizedEntry.status),
|
||||
created_by_json: serializeSqliteSessionCreatorIdentity(normalizedEntry.createdBy),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -64,6 +64,7 @@ import {
|
||||
readSqliteSessionEntriesByStatus,
|
||||
} from "./session-accessor.sqlite-status.js";
|
||||
import { preserveSqliteSameKeySessionRolloverLineage } from "./session-entry-lineage.js";
|
||||
import { buildSessionCreationStamp } from "./session-entry-provenance.js";
|
||||
import { kickSessionHistoryDiskBudgetMaintenance } from "./session-history-eviction.js";
|
||||
import { resolveSessionStorePathForScope } from "./session-store-path.js";
|
||||
import type { GroupKeyResolution, SessionEntry } from "./types.js";
|
||||
@@ -419,13 +420,27 @@ export async function recordSqliteInboundSessionMeta(params: {
|
||||
const createIfMissing = params.createIfMissing ?? true;
|
||||
return await patchSqliteSessionEntry(
|
||||
{ sessionKey: params.sessionKey, storePath: params.storePath },
|
||||
(_entry, context) =>
|
||||
deriveSessionMetaPatch({
|
||||
(_entry, context) => {
|
||||
const metadataPatch = deriveSessionMetaPatch({
|
||||
ctx: params.ctx,
|
||||
sessionKey: params.sessionKey,
|
||||
existing: context.existingEntry,
|
||||
groupResolution: params.groupResolution,
|
||||
}),
|
||||
});
|
||||
if (context.existingEntry) {
|
||||
return metadataPatch;
|
||||
}
|
||||
const senderId = params.ctx.From?.trim();
|
||||
return {
|
||||
...buildSessionCreationStamp(
|
||||
params.ctx.SessionCreation ?? {
|
||||
via: "channel",
|
||||
actor: { type: "human", ...(senderId ? { id: senderId } : {}) },
|
||||
},
|
||||
),
|
||||
...metadataPatch,
|
||||
};
|
||||
},
|
||||
{
|
||||
// Inbound metadata must not refresh activity timestamps; idle reset
|
||||
// evaluation relies on updatedAt from actual session turns.
|
||||
@@ -452,8 +467,8 @@ export async function updateSqliteSessionLastRoute(params: {
|
||||
const createIfMissing = params.createIfMissing ?? true;
|
||||
return await patchSqliteSessionEntry(
|
||||
{ sessionKey: params.sessionKey, storePath: params.storePath },
|
||||
(_entry, context) =>
|
||||
deriveLastRoutePatch({
|
||||
(_entry, context) => {
|
||||
const routePatch = deriveLastRoutePatch({
|
||||
channel: params.channel,
|
||||
to: params.to,
|
||||
accountId: params.accountId,
|
||||
@@ -464,7 +479,23 @@ export async function updateSqliteSessionLastRoute(params: {
|
||||
groupResolution: params.groupResolution,
|
||||
existing: context.existingEntry,
|
||||
sessionKey: params.sessionKey,
|
||||
}),
|
||||
});
|
||||
if (context.existingEntry) {
|
||||
return routePatch;
|
||||
}
|
||||
const senderId = params.ctx?.From?.trim();
|
||||
return {
|
||||
...buildSessionCreationStamp(
|
||||
params.ctx?.SessionCreation ?? {
|
||||
via: "channel",
|
||||
...(params.ctx
|
||||
? { actor: { type: "human" as const, ...(senderId ? { id: senderId } : {}) } }
|
||||
: {}),
|
||||
},
|
||||
),
|
||||
...routePatch,
|
||||
};
|
||||
},
|
||||
{
|
||||
// Route updates must not refresh activity timestamps (#49515).
|
||||
preserveActivity: true,
|
||||
|
||||
@@ -37,7 +37,11 @@ async function createSession(options: { activeLeafTarget?: string } = {}) {
|
||||
cliSessionIds: { "claude-cli": "claude-conversation" },
|
||||
compactionCount: 2,
|
||||
contextTokens: 100_000,
|
||||
createdVia: "operator",
|
||||
createdActor: { type: "human", id: "profile-1" },
|
||||
createdAt: 1_000,
|
||||
deliveryContext: { channel: "telegram", to: "chat-123" },
|
||||
forkSource: { sessionKey: "agent:main:root", sessionId: "root-session" },
|
||||
lastChannel: "telegram",
|
||||
lastTo: "chat-123",
|
||||
lifecycleRevision: "source-lifecycle-revision",
|
||||
@@ -210,6 +214,11 @@ describe("SQLite session message cuts", () => {
|
||||
cliSessionIds: undefined,
|
||||
compactionCount: undefined,
|
||||
contextTokens: undefined,
|
||||
createdVia: "operator",
|
||||
createdActor: { type: "human", id: "profile-1" },
|
||||
createdAt: 1_000,
|
||||
forkSource: { sessionKey: "agent:main:root", sessionId: "root-session" },
|
||||
previousSessionId: "message-cut-source",
|
||||
});
|
||||
expect(result.entry.deliveryContext).toEqual({ channel: "telegram", to: "chat-123" });
|
||||
});
|
||||
@@ -274,6 +283,16 @@ describe("SQLite session message cuts", () => {
|
||||
expect(result.entry.lastChannel).toBeUndefined();
|
||||
expect(result.entry.lastTo).toBeUndefined();
|
||||
expect(result.entry.parentSessionKey).toBe(canonicalSourceKey);
|
||||
expect(result.entry.previousSessionId).toBeUndefined();
|
||||
expect(result.entry.forkedFromParent).toBeUndefined();
|
||||
expect(result.entry.createdVia).toBeUndefined();
|
||||
expect(result.entry.createdActor).toBeUndefined();
|
||||
expect(result.entry.createdAt).toBeUndefined();
|
||||
expect(result.entry.forkSource).toEqual({
|
||||
sessionKey: canonicalSourceKey,
|
||||
sessionId: "message-cut-source",
|
||||
entryId: "user-2",
|
||||
});
|
||||
expect(result.entry).toMatchObject({
|
||||
modelOverride: "gpt-5",
|
||||
modelOverrideSource: "user",
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
SessionMessageCutMutationParams,
|
||||
SessionMessageCutMutationResult,
|
||||
} from "./session-accessor.types.js";
|
||||
import { buildSessionCreationStamp } from "./session-entry-provenance.js";
|
||||
import { inheritSessionSelection } from "./session-entry-selection.js";
|
||||
import { reconcileSessionTranscriptIndexInTransaction } from "./session-transcript-index.js";
|
||||
import { parseSqliteSessionFileMarker } from "./sqlite-marker.js";
|
||||
@@ -142,6 +143,7 @@ async function mutateSqliteSessionAtMessage(
|
||||
result = mutateSqliteSessionAtMessageInTransaction(database, resolved, {
|
||||
entryId: params.entryId,
|
||||
canonicalSourceKey,
|
||||
creation: params.creation,
|
||||
mode,
|
||||
sourceKey,
|
||||
targetKey,
|
||||
@@ -158,6 +160,7 @@ function mutateSqliteSessionAtMessageInTransaction(
|
||||
resolved: ResolvedSqliteScope,
|
||||
params: {
|
||||
canonicalSourceKey: string;
|
||||
creation?: SessionMessageCutMutationParams["creation"];
|
||||
entryId: string;
|
||||
mode: SessionTranscriptMutationMode;
|
||||
sourceKey: string;
|
||||
@@ -215,13 +218,25 @@ function mutateSqliteSessionAtMessageInTransaction(
|
||||
|
||||
// Rotating transcript identity fences stale live managers: later snapshot-replace writes
|
||||
// target the old session and cannot erase this leaf repoint from the active session.
|
||||
const nextEntry = cloneMessageCutSessionEntry({
|
||||
currentEntry,
|
||||
forked: params.mode === "fork",
|
||||
nextSessionFile,
|
||||
nextSessionId,
|
||||
parentSessionKey: params.mode === "fork" ? params.canonicalSourceKey : undefined,
|
||||
});
|
||||
const nextEntry = {
|
||||
...cloneMessageCutSessionEntry({
|
||||
currentEntry,
|
||||
forked: params.mode === "fork",
|
||||
forkSource:
|
||||
params.mode === "fork"
|
||||
? {
|
||||
sessionKey: params.canonicalSourceKey,
|
||||
sessionId: currentEntry.sessionId,
|
||||
entryId: params.entryId,
|
||||
}
|
||||
: undefined,
|
||||
nextSessionFile,
|
||||
nextSessionId,
|
||||
}),
|
||||
...(params.mode === "fork" && params.creation
|
||||
? buildSessionCreationStamp(params.creation)
|
||||
: {}),
|
||||
};
|
||||
writeSessionEntry(database, params.targetKey, nextEntry);
|
||||
return {
|
||||
status: "created",
|
||||
@@ -356,9 +371,9 @@ function resolveMessageCut(
|
||||
function cloneMessageCutSessionEntry(params: {
|
||||
currentEntry: SessionEntry;
|
||||
forked: boolean;
|
||||
forkSource?: NonNullable<SessionEntry["forkSource"]>;
|
||||
nextSessionFile: string;
|
||||
nextSessionId: string;
|
||||
parentSessionKey?: string;
|
||||
}): SessionEntry {
|
||||
const baseEntry = params.forked
|
||||
? inheritSessionSelection(params.currentEntry)
|
||||
@@ -407,7 +422,10 @@ function cloneMessageCutSessionEntry(params: {
|
||||
abortCutoffTimestamp: undefined,
|
||||
usageFamilyKey: params.forked ? undefined : params.currentEntry.usageFamilyKey,
|
||||
usageFamilySessionIds: params.forked ? undefined : params.currentEntry.usageFamilySessionIds,
|
||||
...(params.parentSessionKey ? { parentSessionKey: params.parentSessionKey } : {}),
|
||||
previousSessionId: params.forked ? undefined : params.currentEntry.sessionId,
|
||||
...(params.forkSource
|
||||
? { forkSource: params.forkSource, parentSessionKey: params.forkSource.sessionKey }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -218,6 +218,10 @@ export async function forkSqliteSessionEntryFromParentTarget(
|
||||
});
|
||||
const next = mergeSessionEntry(freshBase, {
|
||||
...patch,
|
||||
forkSource: {
|
||||
sessionKey: parentTarget.canonicalKey,
|
||||
sessionId: freshParent.sessionId,
|
||||
},
|
||||
forkedFromParent: true,
|
||||
sessionFile: fork.transcript.sessionFile,
|
||||
sessionId: fork.transcript.sessionId,
|
||||
|
||||
@@ -19,42 +19,12 @@ export function normalizeSqliteStatus(value: unknown): SessionEntryStatus | null
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeSessionCreatorIdentity(value: unknown): SessionEntry["createdBy"] {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = value as { id?: unknown; label?: unknown };
|
||||
const id = typeof candidate.id === "string" ? candidate.id.trim() : "";
|
||||
if (!id) {
|
||||
return undefined;
|
||||
}
|
||||
const label = typeof candidate.label === "string" ? candidate.label.trim() : "";
|
||||
return { id, ...(label ? { label } : {}) };
|
||||
}
|
||||
|
||||
export function serializeSqliteSessionCreatorIdentity(
|
||||
createdBy: SessionEntry["createdBy"],
|
||||
): string | null {
|
||||
const normalized = normalizeSessionCreatorIdentity(createdBy);
|
||||
return normalized ? JSON.stringify(normalized) : null;
|
||||
}
|
||||
|
||||
export function parseSqliteSessionEntryJson(row: { entry_json: string }): SessionEntry | null {
|
||||
try {
|
||||
const parsed = JSON.parse(row.entry_json) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const entry = parsed as SessionEntry;
|
||||
// entry_json stays authoritative across downgrade/upgrade cycles: an older
|
||||
// binary can rewrite it without knowing about the additive projection column.
|
||||
const createdBy = normalizeSessionCreatorIdentity(entry.createdBy);
|
||||
if (createdBy) {
|
||||
entry.createdBy = createdBy;
|
||||
} else {
|
||||
delete entry.createdBy;
|
||||
}
|
||||
return entry;
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as SessionEntry)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -105,7 +105,6 @@ describe("session accessor seam", () => {
|
||||
};
|
||||
|
||||
await upsertSessionEntry(scope, {
|
||||
createdBy: { id: "profile-ada", label: "Ada Lovelace" },
|
||||
model: "gpt-5.5",
|
||||
sessionId: "session-1",
|
||||
updatedAt: 10,
|
||||
@@ -113,20 +112,10 @@ describe("session accessor seam", () => {
|
||||
|
||||
expect(loadSessionEntry(scope)).toMatchObject({
|
||||
model: "gpt-5.5",
|
||||
createdBy: { id: "profile-ada", label: "Ada Lovelace" },
|
||||
sessionId: "session-1",
|
||||
updatedAt: expect.any(Number),
|
||||
});
|
||||
expect(readSessionUpdatedAt(scope)).toEqual(expect.any(Number));
|
||||
const databasePath = resolveSqliteTargetFromSessionStorePath(storePath, {
|
||||
agentId: "main",
|
||||
}).path;
|
||||
const database = openOpenClawAgentDatabase({ agentId: "main", path: databasePath });
|
||||
expect(
|
||||
database.db
|
||||
.prepare("SELECT created_by_json FROM session_entries WHERE session_key = ?")
|
||||
.get(scope.sessionKey),
|
||||
).toEqual({ created_by_json: '{"id":"profile-ada","label":"Ada Lovelace"}' });
|
||||
expect(listSessionEntries({ storePath })).toEqual([
|
||||
{
|
||||
sessionKey: "agent:main:main",
|
||||
@@ -138,17 +127,6 @@ describe("session accessor seam", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
// A downgraded writer knows only entry_json and can leave the additive
|
||||
// projection untouched. Re-upgrade must not resurrect that stale creator.
|
||||
database.db
|
||||
.prepare("UPDATE session_entries SET entry_json = ?, updated_at = ? WHERE session_key = ?")
|
||||
.run(
|
||||
JSON.stringify({ model: "legacy-reset", sessionId: "session-1", updatedAt: 15 }),
|
||||
15,
|
||||
scope.sessionKey,
|
||||
);
|
||||
expect(loadSessionEntry(scope)).not.toHaveProperty("createdBy");
|
||||
|
||||
await upsertSessionEntry(scope, { model: "sonnet-4.6", updatedAt: 20 });
|
||||
|
||||
expect(loadSessionEntry(scope)).toMatchObject({
|
||||
@@ -156,7 +134,6 @@ describe("session accessor seam", () => {
|
||||
sessionId: "session-1",
|
||||
updatedAt: expect.any(Number),
|
||||
});
|
||||
expect(loadSessionEntry(scope)).not.toHaveProperty("createdBy");
|
||||
});
|
||||
|
||||
it("lists retained transcript instances across same-key session rotation", async () => {
|
||||
@@ -496,12 +473,48 @@ describe("session accessor seam", () => {
|
||||
|
||||
const recorded = await recordInboundSessionMeta({ storePath, sessionKey, ctx });
|
||||
expect(recorded?.origin?.provider).toBe("webchat");
|
||||
expect(recorded).toMatchObject({
|
||||
createdVia: "channel",
|
||||
createdActor: { type: "human", id: "webchat:user-1" },
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
const creationStamp = {
|
||||
createdVia: recorded?.createdVia,
|
||||
createdActor: recorded?.createdActor,
|
||||
createdAt: recorded?.createdAt,
|
||||
};
|
||||
|
||||
await recordInboundSessionMeta({
|
||||
storePath,
|
||||
sessionKey,
|
||||
ctx: { ...ctx, From: "webchat:different-sender" },
|
||||
});
|
||||
expect(loadSessionEntry({ sessionKey, storePath })).toMatchObject(creationStamp);
|
||||
|
||||
// Detached result: caller mutations must never leak into cached store state.
|
||||
if (recorded) {
|
||||
recorded.origin = { provider: "mutated" };
|
||||
}
|
||||
expect(loadSessionEntry({ sessionKey, storePath })?.origin?.provider).toBe("webchat");
|
||||
|
||||
const operatorKey = "agent:main:dashboard:operator-created";
|
||||
const operator = await recordInboundSessionMeta({
|
||||
storePath,
|
||||
sessionKey: operatorKey,
|
||||
ctx: {
|
||||
...ctx,
|
||||
SessionKey: operatorKey,
|
||||
SessionCreation: {
|
||||
via: "operator",
|
||||
actor: { type: "human", id: "profile-ada" },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(operator).toMatchObject({
|
||||
createdVia: "operator",
|
||||
createdActor: { type: "human", id: "profile-ada" },
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create sessions when inbound meta recording opts out of upsert", async () => {
|
||||
|
||||
@@ -665,6 +665,10 @@ export type SessionMessageCutMutationResult =
|
||||
|
||||
export type SessionMessageCutMutationParams = {
|
||||
agentId?: string;
|
||||
creation?: {
|
||||
via: import("./session-entry-provenance.js").SessionCreatedVia;
|
||||
actor?: import("./session-entry-provenance.js").SessionCreatedActor;
|
||||
};
|
||||
entryId: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
sessionKey: string;
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
|
||||
/** True when this entry's transcript began as a copy of a parent (actual forkSource ancestry or the legacy/thread-settled marker). */
|
||||
export function sessionEntryForkedFromParent(
|
||||
entry: Pick<SessionEntry, "forkedFromParent" | "forkSource"> | undefined,
|
||||
): boolean {
|
||||
return entry?.forkSource !== undefined || entry?.forkedFromParent === true;
|
||||
}
|
||||
|
||||
export function preserveSqliteSameKeySessionRolloverLineage(params: {
|
||||
next: SessionEntry;
|
||||
previous: SessionEntry;
|
||||
@@ -13,6 +20,7 @@ export function preserveSqliteSameKeySessionRolloverLineage(params: {
|
||||
}
|
||||
return {
|
||||
...params.next,
|
||||
previousSessionId,
|
||||
usageFamilyKey:
|
||||
params.next.usageFamilyKey ?? params.previous.usageFamilyKey ?? params.sessionKey,
|
||||
usageFamilySessionIds: uniqueStrings([
|
||||
|
||||
@@ -1,5 +1,31 @@
|
||||
import type { HookExternalContentSource } from "../../security/external-content.js";
|
||||
|
||||
/** Kept aligned with SessionStateActorType (src/sessions/session-state-event-kinds.ts); not imported to avoid layering config/sessions onto src/sessions. */
|
||||
export type SessionCreatedActor = { type: "human" | "agent" | "system"; id?: string };
|
||||
export type SessionCreatedVia =
|
||||
| "operator" // gateway sessions.create (Control UI / operator clients)
|
||||
| "spawn" // sessions_spawn native or ACP subagent spawn
|
||||
| "channel" // inbound channel conversation materialization
|
||||
| "cron"
|
||||
| "talk"
|
||||
| "run" // create-on-run materialization (agent-session-persist)
|
||||
| "plugin" // trusted plugin runtime creation
|
||||
| "internal"; // internal/hidden sessions (internal-session-effects, voice bare rows)
|
||||
|
||||
// Return shape mirrors the SessionEntry creation fields as a leaf contract;
|
||||
// types.ts imports from here, never the reverse (madge cycle guard).
|
||||
export function buildSessionCreationStamp(params: {
|
||||
via: SessionCreatedVia;
|
||||
actor?: SessionCreatedActor;
|
||||
now?: number;
|
||||
}): { createdVia: SessionCreatedVia; createdActor?: SessionCreatedActor; createdAt: number } {
|
||||
return {
|
||||
createdVia: params.via,
|
||||
...(params.actor ? { createdActor: params.actor } : {}),
|
||||
createdAt: params.now ?? Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export type SessionEntryProvenance = {
|
||||
/** Plugin id that owns this session through a trusted runtime creation seam. */
|
||||
pluginOwnerId?: string;
|
||||
|
||||
@@ -74,7 +74,8 @@ function search(query: string, options: { limit?: number; sessionKeys?: string[]
|
||||
async function waitForSearchReconcile(query: string): Promise<void> {
|
||||
await vi.waitFor(() => expect(search(query).indexing).toBe(false), {
|
||||
interval: 10,
|
||||
timeout: 5_000,
|
||||
// Compact CI shards can delay the asynchronous index worker beyond five seconds.
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { evaluateSessionFreshness, resolveSessionResetPolicy } from "./reset.js";
|
||||
import { mergeRestartRecoveryTerminalRunIds } from "./restart-recovery-state.js";
|
||||
import { loadSessionEntry } from "./session-accessor.js";
|
||||
import { buildSessionCreationStamp } from "./session-entry-provenance.js";
|
||||
import { resolveAndPersistSessionFile } from "./session-file.js";
|
||||
import { formatSqliteSessionFileMarker } from "./sqlite-marker.js";
|
||||
import { readSessionStoreCache, writeSessionStoreCache } from "./store-cache.js";
|
||||
@@ -453,6 +454,28 @@ describe("session store writer queue", () => {
|
||||
expect(store["agent:main:array"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("round-trips durable session creation and lineage fields", async () => {
|
||||
const key = "agent:main:lineage-roundtrip";
|
||||
const entry: SessionEntry = {
|
||||
sessionId: "lineage-roundtrip-session",
|
||||
updatedAt: 200,
|
||||
createdVia: "operator",
|
||||
createdActor: { type: "human", id: "profile-1" },
|
||||
createdAt: 100,
|
||||
forkSource: {
|
||||
sessionKey: "agent:main:source",
|
||||
sessionId: "source-session",
|
||||
entryId: "source-entry",
|
||||
},
|
||||
previousSessionId: "previous-generation",
|
||||
};
|
||||
const { storePath } = await makeTmpStore();
|
||||
|
||||
await saveSessionStore(storePath, { [key]: entry }, { skipMaintenance: true });
|
||||
|
||||
expect(loadSessionStore(storePath, { skipCache: true })[key]).toEqual(entry);
|
||||
});
|
||||
|
||||
it("strips malformed pending final-delivery fields on load", async () => {
|
||||
const { storePath } = await makeTmpStore({
|
||||
"agent:main:bad-pending": {
|
||||
@@ -1117,6 +1140,64 @@ describe("session store writer queue", () => {
|
||||
expect(merged.modelProvider).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds a deterministic session creation stamp", () => {
|
||||
expect(
|
||||
buildSessionCreationStamp({
|
||||
via: "spawn",
|
||||
actor: { type: "agent", id: "agent:main:requester" },
|
||||
now: 123,
|
||||
}),
|
||||
).toEqual({
|
||||
createdVia: "spawn",
|
||||
createdActor: { type: "agent", id: "agent:main:requester" },
|
||||
createdAt: 123,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps session creation and fork ancestry fields write-once", () => {
|
||||
const existing: SessionEntry = {
|
||||
sessionId: "session-write-once",
|
||||
updatedAt: 100,
|
||||
createdVia: "channel",
|
||||
createdActor: { type: "human", id: "sender-1" },
|
||||
createdAt: 50,
|
||||
forkSource: { sessionKey: "agent:main:parent", sessionId: "parent-session" },
|
||||
};
|
||||
|
||||
expect(
|
||||
mergeSessionEntry(existing, {
|
||||
createdVia: undefined,
|
||||
createdActor: { type: "system", id: "replacement" },
|
||||
createdAt: 200,
|
||||
forkSource: undefined,
|
||||
}),
|
||||
).toMatchObject({
|
||||
createdVia: "channel",
|
||||
createdActor: { type: "human", id: "sender-1" },
|
||||
createdAt: 50,
|
||||
forkSource: { sessionKey: "agent:main:parent", sessionId: "parent-session" },
|
||||
});
|
||||
});
|
||||
|
||||
it("fills absent session creation and fork ancestry fields", () => {
|
||||
expect(
|
||||
mergeSessionEntry(
|
||||
{ sessionId: "session-fill", updatedAt: 100 },
|
||||
{
|
||||
createdVia: "internal",
|
||||
createdActor: { type: "system" },
|
||||
createdAt: 75,
|
||||
forkSource: { sessionKey: "agent:main:source", sessionId: "source-session" },
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
createdVia: "internal",
|
||||
createdActor: { type: "system" },
|
||||
createdAt: 75,
|
||||
forkSource: { sessionKey: "agent:main:source", sessionId: "source-session" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rewrites generated sessionFile paths when session id changes", () => {
|
||||
const previousSessionId = "11111111-1111-4111-8111-111111111111";
|
||||
const nextSessionId = "22222222-2222-4222-8222-222222222222";
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
} from "@openclaw/acp-core/types";
|
||||
import { normalizeOptionalString, type FastMode } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type { SessionCreatorIdentity } from "../../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/session-icon.js";
|
||||
import type { ChatType } from "../../channels/chat-type.js";
|
||||
import type { ChannelId } from "../../channels/plugins/channel-id.types.js";
|
||||
@@ -17,7 +16,11 @@ import type { DeliveryContext } from "../../utils/delivery-context.types.js";
|
||||
import type { TtsAutoMode } from "../types.tts.js";
|
||||
import type { MainRestartRecoveryState } from "./main-session-recovery.types.js";
|
||||
import type { SessionRestartRecoveryState } from "./restart-recovery-types.js";
|
||||
import type { SessionEntryProvenance } from "./session-entry-provenance.js";
|
||||
import type {
|
||||
SessionCreatedActor,
|
||||
SessionCreatedVia,
|
||||
SessionEntryProvenance,
|
||||
} from "./session-entry-provenance.js";
|
||||
import { rewriteSessionFileForNewSessionId } from "./session-file-rotation.js";
|
||||
import type { AgentPatchedSessionModelFallback } from "./session-model-fallback.js";
|
||||
|
||||
@@ -252,8 +255,6 @@ export type SessionEntry = SessionRestartRecoveryState &
|
||||
/** Durable one-shot prompt additions drained before the next agent turn. */
|
||||
pluginNextTurnInjections?: Record<string, SessionPluginNextTurnInjection[]>;
|
||||
sessionId: string;
|
||||
/** Operator identity captured once for this session generation. */
|
||||
createdBy?: SessionCreatorIdentity;
|
||||
updatedAt: number;
|
||||
/** Opaque owner revision used to reject stale lifecycle mutations. */
|
||||
lifecycleRevision?: string;
|
||||
@@ -294,7 +295,17 @@ export type SessionEntry = SessionRestartRecoveryState &
|
||||
worktree?: { id: string; branch: string; repoRoot: string };
|
||||
/** Explicit parent session linkage for dashboard-created child sessions. */
|
||||
parentSessionKey?: string;
|
||||
/** True after a thread/topic session has been forked from its parent transcript once. */
|
||||
/** How this session node came to exist; written once and retained across sessionId rotations. */
|
||||
createdVia?: SessionCreatedVia;
|
||||
/** Actor that caused node creation, with an optional profile, session, or sender id; written once. */
|
||||
createdActor?: SessionCreatedActor;
|
||||
/** Node creation time (ms); unlike sessionStartedAt, survives sessionId rotations. */
|
||||
createdAt?: number;
|
||||
/** Exact source generation and optional cut entry for an actual transcript-copy fork. */
|
||||
forkSource?: { sessionKey: string; sessionId: string; entryId?: string };
|
||||
/** Session id of the prior transcript generation under this same session key. */
|
||||
previousSessionId?: string;
|
||||
/** Thread parent-seeding settled marker; also set when seeding is deliberately skipped. */
|
||||
forkedFromParent?: boolean;
|
||||
/** Subagent spawn depth (0 = main, 1 = sub-agent, 2 = sub-sub-agent). */
|
||||
spawnDepth?: number;
|
||||
@@ -671,6 +682,20 @@ function mergeSessionEntryWithPolicy(
|
||||
(existing.sessionId === sessionId ? existing.sessionStartedAt : updatedAt),
|
||||
};
|
||||
|
||||
// Node creation and exact fork ancestry are write-once; patches may only fill absent values.
|
||||
if (existing.createdVia !== undefined) {
|
||||
next.createdVia = existing.createdVia;
|
||||
}
|
||||
if (existing.createdActor !== undefined) {
|
||||
next.createdActor = existing.createdActor;
|
||||
}
|
||||
if (existing.createdAt !== undefined) {
|
||||
next.createdAt = existing.createdAt;
|
||||
}
|
||||
if (existing.forkSource !== undefined) {
|
||||
next.forkSource = existing.forkSource;
|
||||
}
|
||||
|
||||
if (existing.sessionId !== sessionId) {
|
||||
// Session id rotations should move transcript paths when they match known reset/fork shapes.
|
||||
const patchHasSessionFile = Object.hasOwn(patch, "sessionFile");
|
||||
|
||||
@@ -65,6 +65,9 @@ describe("createPersistCronSessionEntry", () => {
|
||||
lifecycleRevision,
|
||||
modelProvider: "claude-cli",
|
||||
model: "claude-opus-4-8",
|
||||
// Node-local lineage on the base row must not leak onto the :run: node.
|
||||
previousSessionId: "base-prior-generation",
|
||||
forkSource: { sessionKey: "agent:main:other", sessionId: "other-generation" },
|
||||
}),
|
||||
),
|
||||
lifecycleRevision,
|
||||
@@ -87,7 +90,12 @@ describe("createPersistCronSessionEntry", () => {
|
||||
storePath: cronSession.storePath,
|
||||
update: expect.any(Function),
|
||||
});
|
||||
expect(store[runSessionKey]?.previousSessionId).toBeUndefined();
|
||||
expect(store[runSessionKey]?.forkSource).toBeUndefined();
|
||||
expect(store[runSessionKey]).toMatchObject({
|
||||
createdVia: "cron",
|
||||
createdActor: { type: "system" },
|
||||
createdAt: expect.any(Number),
|
||||
sessionId: "run-session-id",
|
||||
modelProvider: "claude-cli",
|
||||
model: "claude-opus-4-8",
|
||||
@@ -165,7 +173,8 @@ describe("createPersistCronSessionEntry", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
const persistSessionEntry = vi.fn(async () => {});
|
||||
const persistedStore: Record<string, SessionEntry> = {};
|
||||
const persistSessionEntry = makeGuardedPersistSessionEntry(persistedStore);
|
||||
|
||||
const persist = createPersistCronSessionEntry({
|
||||
cronSession,
|
||||
@@ -175,12 +184,21 @@ describe("createPersistCronSessionEntry", () => {
|
||||
|
||||
await persist();
|
||||
|
||||
expect(cronSession.store["agent:main:cron:job"]).toBe(cronSession.sessionEntry);
|
||||
expect(cronSession.store["agent:main:cron:job"]).toMatchObject({
|
||||
createdVia: "cron",
|
||||
createdActor: { type: "system" },
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
expect(persistedStore["agent:main:cron:job"]).toMatchObject({
|
||||
createdVia: "cron",
|
||||
createdActor: { type: "system" },
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
expect(cronSession.store["agent:main:cron:job:run:run-session-id"]).toBeUndefined();
|
||||
expect(persistSessionEntry).toHaveBeenCalledWith({
|
||||
storePath: "/tmp/sessions.json",
|
||||
sessionKey: "agent:main:cron:job",
|
||||
fallbackEntry: cronSession.sessionEntry,
|
||||
fallbackEntry: expect.objectContaining({ sessionId: "run-session-id" }),
|
||||
update: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import type { LiveSessionModelSelection } from "../../agents/live-model-switch.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js";
|
||||
import { mergeSessionSnapshotChanges } from "../../config/sessions/session-snapshot-merge.js";
|
||||
import { parseSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js";
|
||||
import { isCronSessionKey } from "../../sessions/session-key-utils.js";
|
||||
@@ -112,6 +113,14 @@ export function createPersistCronSessionEntry(params: {
|
||||
sessionKey: params.agentSessionKey,
|
||||
fallbackEntry: persistedEntry,
|
||||
update: (currentEntry) => {
|
||||
if (!currentEntry) {
|
||||
const creationStamp = buildSessionCreationStamp({
|
||||
via: "cron",
|
||||
actor: { type: "system" },
|
||||
});
|
||||
committedEntry = { ...persistedEntry, ...creationStamp };
|
||||
mergedLiveEntry = { ...liveEntry, ...creationStamp };
|
||||
}
|
||||
const ownsCurrentRevision =
|
||||
currentEntry?.lifecycleRevision === params.cronSession.lifecycleRevision;
|
||||
const currentRevisionActive = Boolean(
|
||||
@@ -189,6 +198,13 @@ export function createCronRunContinuationSession(params: {
|
||||
entry?.cronRunContinuation?.lifecycleRevision === continuation.lifecycleRevision;
|
||||
const persist = async (create: boolean, phase: "running" | "ready", basePersisted = false) => {
|
||||
const source = structuredClone(params.cronSession.sessionEntry);
|
||||
delete source.createdVia;
|
||||
delete source.createdActor;
|
||||
delete source.createdAt;
|
||||
// Node-local lineage must not leak across keys: the base row's generation
|
||||
// chain and fork ancestry describe the cron root, not this :run: node.
|
||||
delete source.previousSessionId;
|
||||
delete source.forkSource;
|
||||
let persisted = false;
|
||||
let alreadySealed = false;
|
||||
await params.persistSessionEntry({
|
||||
@@ -212,6 +228,9 @@ export function createCronRunContinuationSession(params: {
|
||||
return {
|
||||
...current,
|
||||
...source,
|
||||
...(!current
|
||||
? buildSessionCreationStamp({ via: "cron", actor: { type: "system" } })
|
||||
: {}),
|
||||
...(params.thinkingLevel ? { thinkingLevel: params.thinkingLevel } : {}),
|
||||
cronRunContinuation: {
|
||||
...continuation,
|
||||
|
||||
@@ -423,7 +423,10 @@ describe("method scope resolution", () => {
|
||||
["sendPolicy", { key: "agent:main:ios-1", sendPolicy: "deny" }],
|
||||
["inheritedToolAllow", { key: "agent:main:ios-1", inheritedToolAllow: ["exec"] }],
|
||||
["inheritedToolPolicyVersion", { key: "agent:main:ios-1", inheritedToolPolicyVersion: 1 }],
|
||||
["spawnedBy", { key: "agent:main:ios-1", spawnedBy: "agent:main:main" }],
|
||||
[
|
||||
"completionOwnerSessionKey",
|
||||
{ key: "agent:main:ios-1", completionOwnerSessionKey: "agent:main:main" },
|
||||
],
|
||||
["mixed with safe fields", { key: "agent:main:ios-1", label: "x", execHost: "node-1" }],
|
||||
["unknown fields", { key: "agent:main:ios-1", futureField: true }],
|
||||
])("keeps sessions.patch admin-only when params include %s", (_name, params) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "../auto-re
|
||||
import { normalizeVerboseLevel } from "../auto-reply/thinking.js";
|
||||
import { normalizeAgentPlanSteps } from "../channels/streaming.js";
|
||||
import { getRuntimeConfig } from "../config/io.js";
|
||||
import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js";
|
||||
import {
|
||||
type AgentEventPayload,
|
||||
type AgentEventRuntimePayload,
|
||||
@@ -550,7 +551,7 @@ export function createAgentEventHandler({
|
||||
spawnedBy: row?.spawnedBy,
|
||||
spawnedWorkspaceDir: row?.spawnedWorkspaceDir,
|
||||
spawnedCwd: row?.spawnedCwd,
|
||||
forkedFromParent: row?.forkedFromParent,
|
||||
forkedFromParent: sessionEntryForkedFromParent(row ?? undefined) ? true : undefined,
|
||||
spawnDepth: row?.spawnDepth,
|
||||
subagentRole: row?.subagentRole,
|
||||
subagentControlScope: row?.subagentControlScope,
|
||||
|
||||
@@ -21,8 +21,8 @@ import {
|
||||
resolveBareSessionResetResult,
|
||||
runSessionResetFromAgent,
|
||||
} from "./agent-session-reset.js";
|
||||
import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
|
||||
import { emitSessionsChanged } from "./session-change-event.js";
|
||||
import { resolveAgentRunSessionCreation } from "./session-creation-provenance.js";
|
||||
import type { GatewayRequestHandlerOptions } from "./types.js";
|
||||
|
||||
export type CommittedResetCompletion = {
|
||||
@@ -97,7 +97,7 @@ export async function runAgentResetPhase(params: {
|
||||
? { agentId: params.agentId }
|
||||
: {}),
|
||||
reason: resetReason,
|
||||
createdBy: gatewayClientSessionCreator(params.client),
|
||||
creation: resolveAgentRunSessionCreation(params.client),
|
||||
assertCurrent: () => assertAgentRunLifecycleGenerationCurrent(params.lifecycleGeneration),
|
||||
onCommitted: (commit) => {
|
||||
params.setCommittedResetCompletion({
|
||||
|
||||
@@ -23,7 +23,7 @@ import { startAgentRunExecution } from "./agent-run-execution-phase.js";
|
||||
import { buildAgentSessionPatch } from "./agent-session-patch.js";
|
||||
import { persistAgentSessionPhase } from "./agent-session-persist.js";
|
||||
import { prepareAgentSession } from "./agent-session-prepare.js";
|
||||
import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
|
||||
import { resolveAgentRunSessionCreation } from "./session-creation-provenance.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
export const agentRunHandler: GatewayRequestHandlers["agent"] = async ({
|
||||
@@ -298,7 +298,6 @@ export const agentRunHandler: GatewayRequestHandlers["agent"] = async ({
|
||||
freshEntry === undefined
|
||||
? normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId)
|
||||
: undefined,
|
||||
createdBy: gatewayClientSessionCreator(client),
|
||||
expectedExistingSessionId,
|
||||
hasRestoredCronContinuation: restoredCronContinuationIdentity !== undefined,
|
||||
resetPolicy,
|
||||
@@ -338,6 +337,7 @@ export const agentRunHandler: GatewayRequestHandlers["agent"] = async ({
|
||||
canonicalSessionKey,
|
||||
sessionAgentId,
|
||||
mainSessionKey,
|
||||
creation: resolveAgentRunSessionCreation(client),
|
||||
lifecycleGeneration,
|
||||
isRestartRecoveryResumeRun,
|
||||
runId,
|
||||
|
||||
@@ -31,59 +31,6 @@ function buildPatch(touchInteraction: boolean) {
|
||||
}
|
||||
|
||||
describe("agent session patch", () => {
|
||||
it("stamps a creator only when minting a new session", () => {
|
||||
const patch = buildAgentSessionPatch({
|
||||
freshEntry: undefined,
|
||||
initialEntry: undefined,
|
||||
cfg: {},
|
||||
sessionAgentId: "main",
|
||||
canonicalSessionKey: "agent:main:new",
|
||||
storePath: "/tmp/openclaw-agent-creator-test.json",
|
||||
normalizedSpawned: {},
|
||||
requestDeliveryHint: undefined,
|
||||
createdBy: { id: "profile-ada", label: "Ada" },
|
||||
hasRestoredCronContinuation: false,
|
||||
resetPolicy: resolveSessionResetPolicy({ resetType: "direct" }),
|
||||
now: 1_000,
|
||||
isSystemGatewayRun: false,
|
||||
visibleRequest: true,
|
||||
fallbackSessionId: "new-session",
|
||||
touchInteraction: true,
|
||||
failedSessionTranscriptMissing: () => false,
|
||||
}).patch;
|
||||
|
||||
expect(patch.createdBy).toEqual({ id: "profile-ada", label: "Ada" });
|
||||
});
|
||||
|
||||
it("clears a previous creator on an ownerless implicit rotation", () => {
|
||||
const entry: SessionEntry = {
|
||||
createdBy: { id: "profile-ada", label: "Ada" },
|
||||
sessionId: "old-session",
|
||||
updatedAt: 1,
|
||||
};
|
||||
const patch = buildAgentSessionPatch({
|
||||
freshEntry: entry,
|
||||
initialEntry: entry,
|
||||
cfg: {},
|
||||
sessionAgentId: "main",
|
||||
canonicalSessionKey: "agent:main:main",
|
||||
storePath: "/tmp/openclaw-agent-creator-rotation.json",
|
||||
normalizedSpawned: {},
|
||||
requestDeliveryHint: undefined,
|
||||
hasRestoredCronContinuation: false,
|
||||
resetPolicy: resolveSessionResetPolicy({ resetType: "direct" }),
|
||||
now: 2,
|
||||
isSystemGatewayRun: false,
|
||||
visibleRequest: true,
|
||||
fallbackSessionId: "new-session",
|
||||
touchInteraction: true,
|
||||
failedSessionTranscriptMissing: () => true,
|
||||
}).patch;
|
||||
|
||||
expect(Object.hasOwn(patch, "createdBy")).toBe(true);
|
||||
expect(patch.createdBy).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears agent status at the next human interaction boundary", () => {
|
||||
const patch = buildPatch(true);
|
||||
expect(Object.hasOwn(patch, "agentStatus")).toBe(true);
|
||||
|
||||
@@ -50,7 +50,6 @@ export function buildAgentSessionPatch(params: {
|
||||
requestLabel?: string;
|
||||
recipientChannel?: string;
|
||||
pluginOwnerId?: string;
|
||||
createdBy?: SessionEntry["createdBy"];
|
||||
expectedExistingSessionId?: string;
|
||||
hasRestoredCronContinuation: boolean;
|
||||
resetPolicy: ReturnType<typeof import("../../config/sessions.js").resolveSessionResetPolicy>;
|
||||
@@ -210,9 +209,6 @@ export function buildAgentSessionPatch(params: {
|
||||
sessionId: patchSessionId,
|
||||
updatedAt: params.now,
|
||||
...(freshIsNewSession && !freshSessionRotatedSinceLoad ? { sessionStartedAt: params.now } : {}),
|
||||
...(freshIsNewSession && !freshSessionRotatedSinceLoad
|
||||
? { createdBy: params.createdBy ? { ...params.createdBy } : undefined }
|
||||
: {}),
|
||||
...(params.touchInteraction
|
||||
? {
|
||||
lastInteractionAt: params.now,
|
||||
|
||||
@@ -18,9 +18,11 @@ import {
|
||||
patchSessionEntryTarget,
|
||||
type SessionEntryPatchOptions,
|
||||
} from "../../config/sessions/session-accessor.js";
|
||||
import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { assertAgentRunLifecycleGenerationCurrent } from "../../infra/agent-events.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
import { recordSessionCreated } from "../../sessions/session-state-events.js";
|
||||
import { getGeneratedMediaTaskIdsForSessionKey } from "../../tasks/task-status-access.js";
|
||||
import { formatForLog } from "../ws-log.js";
|
||||
import {
|
||||
@@ -35,6 +37,7 @@ import {
|
||||
} from "./agent-handler-helpers.js";
|
||||
import type { AgentRunRequest } from "./agent-request-types.js";
|
||||
import type { AgentSessionPatchBuild } from "./agent-session-patch.js";
|
||||
import type { TrustedSessionCreation } from "./session-creation-provenance.js";
|
||||
import type { GatewayRequestHandlerOptions } from "./types.js";
|
||||
|
||||
export type CronContinuationClaim = {
|
||||
@@ -75,6 +78,7 @@ export async function persistAgentSessionPhase(params: {
|
||||
canonicalSessionKey: string;
|
||||
sessionAgentId: string;
|
||||
mainSessionKey: string;
|
||||
creation: TrustedSessionCreation;
|
||||
lifecycleGeneration: string;
|
||||
isRestartRecoveryResumeRun: boolean;
|
||||
runId: string;
|
||||
@@ -117,6 +121,7 @@ export async function persistAgentSessionPhase(params: {
|
||||
let restoredCronContinuation: RestoredCronContinuation | undefined;
|
||||
let mainRestartRecoveryOwnerLease: MainSessionRecoveryOwnerLease | undefined;
|
||||
let skipAgentInitialSessionTouch = false;
|
||||
let createdNewEntry = false;
|
||||
const recoveredSessionStartedAt =
|
||||
!patchBuild.isNewSession &&
|
||||
params.entry !== undefined &&
|
||||
@@ -246,12 +251,25 @@ export async function persistAgentSessionPhase(params: {
|
||||
});
|
||||
}
|
||||
patchBuild = params.buildSessionPatch(entryForPatch);
|
||||
const effectivePatch =
|
||||
const lifecyclePatch =
|
||||
recoveredSessionStartedAt !== undefined &&
|
||||
entryForPatch?.sessionStartedAt === undefined &&
|
||||
entryForPatch?.sessionId === params.entry?.sessionId
|
||||
? { ...patchBuild.patch, sessionStartedAt: recoveredSessionStartedAt }
|
||||
: patchBuild.patch;
|
||||
const previousSessionId = normalizeOptionalString(freshEntry?.sessionId);
|
||||
const nextSessionId = normalizeOptionalString(lifecyclePatch.sessionId);
|
||||
const rotationLineage =
|
||||
previousSessionId && nextSessionId && previousSessionId !== nextSessionId
|
||||
? { previousSessionId }
|
||||
: {};
|
||||
const effectivePatch = freshEntry
|
||||
? { ...lifecyclePatch, ...rotationLineage }
|
||||
: {
|
||||
...lifecyclePatch,
|
||||
...buildSessionCreationStamp(params.creation),
|
||||
};
|
||||
createdNewEntry = freshEntry === undefined;
|
||||
const merged = withSqliteSessionFileMarker({
|
||||
agentId: params.sessionAgentId,
|
||||
entry: mergeSessionEntry(entryForPatch, effectivePatch),
|
||||
@@ -416,6 +434,13 @@ export async function persistAgentSessionPhase(params: {
|
||||
const rotatedSessionId = patchBuild.rotatedSessionId;
|
||||
const usableRequestedSessionId = patchBuild.usableRequestedSessionId;
|
||||
const freshness = patchBuild.freshness;
|
||||
if (createdNewEntry && sessionEntry) {
|
||||
recordSessionCreated({
|
||||
sessionKey: params.canonicalSessionKey,
|
||||
agentId: params.sessionAgentId,
|
||||
entry: sessionEntry,
|
||||
});
|
||||
}
|
||||
if (isNewSession && params.entry?.sessionId && resolvedSessionId !== params.entry.sessionId) {
|
||||
supersededSessionId = params.entry.sessionId;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { SessionCreatorIdentity } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import type { AgentCommandOpts } from "../../agents/command/types.js";
|
||||
import { agentCommandFromIngress } from "../../commands/agent.js";
|
||||
@@ -14,13 +13,14 @@ import { defaultRuntime } from "../../runtime.js";
|
||||
import { resolveSendPolicy } from "../../sessions/send-policy.js";
|
||||
import { performGatewaySessionReset } from "../session-reset-service.js";
|
||||
import { loadSessionEntry } from "../session-utils.js";
|
||||
import type { TrustedSessionCreation } from "./session-creation-provenance.js";
|
||||
import type { GatewayRequestHandlerOptions, GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
export async function runSessionResetFromAgent(params: {
|
||||
key: string;
|
||||
agentId?: string;
|
||||
reason: "new" | "reset";
|
||||
createdBy?: SessionCreatorIdentity;
|
||||
creation: TrustedSessionCreation;
|
||||
assertCurrent?: () => void;
|
||||
onCommitted?: (commit: { key: string; sessionId: string }) => void;
|
||||
}) {
|
||||
@@ -29,7 +29,7 @@ export async function runSessionResetFromAgent(params: {
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
reason: params.reason,
|
||||
commandSource: "gateway:agent",
|
||||
createdBy: params.createdBy,
|
||||
creation: params.creation,
|
||||
assertCurrent: params.assertCurrent,
|
||||
onCommitted: params.onCommitted,
|
||||
});
|
||||
|
||||
@@ -163,6 +163,12 @@ describe("prepareChatSendUserTurn", () => {
|
||||
}),
|
||||
client: {
|
||||
connId: "conn-1",
|
||||
authenticatedUserProfile: {
|
||||
profileId: "profile-ada",
|
||||
displayName: "Ada",
|
||||
hasAvatar: false,
|
||||
updatedAt: 1,
|
||||
},
|
||||
connect: {
|
||||
device: { id: "device-1" },
|
||||
scopes: ["operator.admin"],
|
||||
@@ -190,6 +196,10 @@ describe("prepareChatSendUserTurn", () => {
|
||||
MediaStaged: true,
|
||||
GatewayClientScopes: ["operator.admin"],
|
||||
GatewayClientCaps: ["tool-events"],
|
||||
SessionCreation: {
|
||||
via: "operator",
|
||||
actor: { type: "human", id: "profile-ada" },
|
||||
},
|
||||
});
|
||||
expect(prepared.ctx).not.toHaveProperty("SenderId");
|
||||
expect(prepared.queuedFollowupOwnerKey).toBe("device:device-1");
|
||||
|
||||
@@ -17,7 +17,7 @@ import type { prepareChatSendAttachments } from "./chat-send-attachments.js";
|
||||
import type { NormalizedChatSendRequest } from "./chat-send-request.js";
|
||||
import type { PreparedChatSendSession } from "./chat-send-session.js";
|
||||
import { normalizeOptionalChatText } from "./chat-text-normalization.js";
|
||||
import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
|
||||
import { resolveOperatorSessionCreation } from "./session-creation-provenance.js";
|
||||
import type { GatewayRequestContext, GatewayRequestHandlerOptions } from "./types.js";
|
||||
|
||||
type PreparedChatSendAttachments = Extract<
|
||||
@@ -180,9 +180,7 @@ function buildChatSendMessageContext(params: {
|
||||
body: commandBody,
|
||||
},
|
||||
MessageSid: params.clientRunId,
|
||||
...(gatewayClientSessionCreator(params.client)
|
||||
? { SessionCreator: gatewayClientSessionCreator(params.client) }
|
||||
: {}),
|
||||
SessionCreation: resolveOperatorSessionCreation(params.client),
|
||||
ApprovalReviewerDeviceId: queuedFollowupOwnerDeviceId,
|
||||
...(!isOperatorUiClient(params.clientInfo)
|
||||
? {
|
||||
|
||||
@@ -17,8 +17,3 @@ export function gatewayClientSenderFields(client: GatewayClient | null): {
|
||||
}
|
||||
return client?.authenticatedUserId ? { sender: { id: client.authenticatedUserId } } : {};
|
||||
}
|
||||
|
||||
/** Returns the trusted creator identity captured during connection admission. */
|
||||
export function gatewayClientSessionCreator(client: GatewayClient | null) {
|
||||
return client?.operatorIdentity ? { ...client.operatorIdentity } : undefined;
|
||||
}
|
||||
|
||||
@@ -4,15 +4,15 @@ import { gatewaySubagentState } from "../../plugins/runtime/gateway-bindings.js"
|
||||
import { createPluginRuntime } from "../../plugins/runtime/index.js";
|
||||
import type { SessionCatalogProvider } from "../../plugins/session-catalog.js";
|
||||
|
||||
type CatalogSessionEntryLoader = (
|
||||
type CatalogSessionRowLoader = (
|
||||
sessionKey: string,
|
||||
options?: { agentId?: string; clone?: boolean },
|
||||
) => { entry: { createdBy?: { id: string; label?: string } } | undefined };
|
||||
) => { createdActor?: { type: "human" | "agent" | "system"; id?: string; label?: string } } | null;
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
activeRegistry: { sessionCatalogs: [] as unknown[] },
|
||||
pinnedSessionExtensionRegistry: undefined as { sessionCatalogs: unknown[] } | undefined,
|
||||
loadSessionEntryReadOnly: vi.fn<CatalogSessionEntryLoader>(() => ({ entry: undefined })),
|
||||
loadGatewaySessionRow: vi.fn<CatalogSessionRowLoader>(() => null),
|
||||
recordSessionStateEvent: vi.fn(),
|
||||
upsertSessionUpstreamLink: vi.fn(),
|
||||
}));
|
||||
@@ -41,7 +41,7 @@ vi.mock("../../plugins/session-conversation-binding.js", () => ({
|
||||
}));
|
||||
vi.mock("../session-utils.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../session-utils.js")>();
|
||||
return { ...actual, loadSessionEntryReadOnly: hoisted.loadSessionEntryReadOnly };
|
||||
return { ...actual, loadGatewaySessionRow: hoisted.loadGatewaySessionRow };
|
||||
});
|
||||
|
||||
const { resolveSessionCatalogCreateTarget, sessionCatalogHandlers } =
|
||||
@@ -81,8 +81,8 @@ describe("session catalog Gateway methods", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.activeRegistry.sessionCatalogs = [];
|
||||
hoisted.pinnedSessionExtensionRegistry = undefined;
|
||||
hoisted.loadSessionEntryReadOnly.mockReset();
|
||||
hoisted.loadSessionEntryReadOnly.mockReturnValue({ entry: undefined });
|
||||
hoisted.loadGatewaySessionRow.mockReset();
|
||||
hoisted.loadGatewaySessionRow.mockReturnValue(null);
|
||||
hoisted.recordSessionStateEvent.mockClear();
|
||||
hoisted.upsertSessionUpstreamLink.mockClear();
|
||||
conversationBindingMocks.bindPluginSessionConversation.mockClear();
|
||||
@@ -169,7 +169,7 @@ describe("session catalog Gateway methods", () => {
|
||||
status: "stored",
|
||||
archived: false,
|
||||
sessionKey: "agent:main:owned",
|
||||
createdBy: { id: "provider-spoof" },
|
||||
createdActor: { type: "human" as const, id: "provider-spoof" },
|
||||
canContinue: true,
|
||||
canArchive: false,
|
||||
},
|
||||
@@ -178,7 +178,7 @@ describe("session catalog Gateway methods", () => {
|
||||
status: "stored",
|
||||
archived: false,
|
||||
sessionKey: "agent:main:missing",
|
||||
createdBy: { id: "provider-spoof" },
|
||||
createdActor: { type: "human" as const, id: "provider-spoof" },
|
||||
canContinue: true,
|
||||
canArchive: false,
|
||||
},
|
||||
@@ -186,18 +186,17 @@ describe("session catalog Gateway methods", () => {
|
||||
threadId: "external-thread",
|
||||
status: "stored",
|
||||
archived: false,
|
||||
createdBy: { id: "provider-spoof" },
|
||||
createdActor: { type: "human" as const, id: "provider-spoof" },
|
||||
canContinue: true,
|
||||
canArchive: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
hoisted.loadSessionEntryReadOnly.mockImplementation((sessionKey: string) => ({
|
||||
entry:
|
||||
sessionKey === "agent:main:owned"
|
||||
? { createdBy: { id: "profile-ada", label: "Ada" } }
|
||||
: undefined,
|
||||
}));
|
||||
hoisted.loadGatewaySessionRow.mockImplementation((sessionKey: string) =>
|
||||
sessionKey === "agent:main:owned"
|
||||
? { createdActor: { type: "human", id: "profile-ada", label: "Ada" } }
|
||||
: null,
|
||||
);
|
||||
hoisted.activeRegistry.sessionCatalogs = [
|
||||
{
|
||||
provider: provider("claude", {
|
||||
@@ -219,10 +218,10 @@ describe("session catalog Gateway methods", () => {
|
||||
const projectedSessions = [
|
||||
expect.objectContaining({
|
||||
threadId: "owned-thread",
|
||||
createdBy: { id: "profile-ada", label: "Ada" },
|
||||
createdActor: { type: "human", id: "profile-ada", label: "Ada" },
|
||||
}),
|
||||
expect.not.objectContaining({ createdBy: expect.anything() }),
|
||||
expect.not.objectContaining({ createdBy: expect.anything() }),
|
||||
expect.not.objectContaining({ createdActor: expect.anything() }),
|
||||
expect.not.objectContaining({ createdActor: expect.anything() }),
|
||||
];
|
||||
|
||||
expect(broadcastToConnIds).toHaveBeenCalledWith(
|
||||
@@ -242,10 +241,10 @@ describe("session catalog Gateway methods", () => {
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(hoisted.loadSessionEntryReadOnly).toHaveBeenCalledWith("agent:main:owned", {
|
||||
expect(hoisted.loadGatewaySessionRow).toHaveBeenCalledWith("agent:main:owned", {
|
||||
agentId: "main",
|
||||
});
|
||||
expect(hoisted.loadSessionEntryReadOnly).toHaveBeenCalledTimes(2);
|
||||
expect(hoisted.loadGatewaySessionRow).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("uses the pinned Gateway catalog runtime after active registry churn", async () => {
|
||||
|
||||
@@ -24,7 +24,7 @@ import { bindPluginSessionConversation } from "../../plugins/session-conversatio
|
||||
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||
import { recordSessionStateEvent } from "../../sessions/session-state-events.js";
|
||||
import { upsertSessionUpstreamLink } from "../../sessions/session-upstream-links.js";
|
||||
import { loadSessionEntryReadOnly } from "../session-utils.js";
|
||||
import { loadGatewaySessionRow } from "../session-utils.js";
|
||||
import { resolveAgentIdOrRespondError } from "./agent-id-shared.js";
|
||||
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
@@ -158,28 +158,28 @@ function catalogResult(
|
||||
return result;
|
||||
}
|
||||
|
||||
function projectCatalogHostCreators(
|
||||
function projectCatalogHostCreatedActors(
|
||||
host: SessionCatalogHost,
|
||||
agentId: string,
|
||||
creatorBySessionKey: Map<string, SessionCatalogSession["createdBy"]>,
|
||||
actorBySessionKey: Map<string, SessionCatalogSession["createdActor"]>,
|
||||
): SessionCatalogHost {
|
||||
return {
|
||||
...host,
|
||||
sessions: host.sessions.map(({ createdBy: _providerCreatedBy, ...session }) => {
|
||||
sessions: host.sessions.map(({ createdActor: _providerCreatedActor, ...session }) => {
|
||||
// Catalog providers do not own creator identity; the persisted session entry does.
|
||||
const sessionKey = session.sessionKey;
|
||||
let createdBy: SessionCatalogSession["createdBy"];
|
||||
if (sessionKey && creatorBySessionKey.has(sessionKey)) {
|
||||
createdBy = creatorBySessionKey.get(sessionKey);
|
||||
let createdActor: SessionCatalogSession["createdActor"];
|
||||
if (sessionKey && actorBySessionKey.has(sessionKey)) {
|
||||
createdActor = actorBySessionKey.get(sessionKey);
|
||||
} else {
|
||||
createdBy = sessionKey
|
||||
? loadSessionEntryReadOnly(sessionKey, { agentId }).entry?.createdBy
|
||||
createdActor = sessionKey
|
||||
? loadGatewaySessionRow(sessionKey, { agentId })?.createdActor
|
||||
: undefined;
|
||||
if (sessionKey) {
|
||||
creatorBySessionKey.set(sessionKey, createdBy);
|
||||
actorBySessionKey.set(sessionKey, createdActor);
|
||||
}
|
||||
}
|
||||
return createdBy ? { ...session, createdBy: { ...createdBy } } : session;
|
||||
return createdActor ? { ...session, createdActor: { ...createdActor } } : session;
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -228,7 +228,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
|
||||
const search = normalizeSessionCatalogSearch(request.search);
|
||||
const progressId = request.progressId;
|
||||
const progressConnId = progressId && client?.connId ? client.connId : undefined;
|
||||
const creatorBySessionKey = new Map<string, SessionCatalogSession["createdBy"]>();
|
||||
const actorBySessionKey = new Map<string, SessionCatalogSession["createdActor"]>();
|
||||
const catalogList = await Promise.all(
|
||||
selected.map(async (provider): Promise<SessionCatalog> => {
|
||||
const createTarget = resolveProviderCreateTarget(provider, resolvedAgent.agentId);
|
||||
@@ -244,7 +244,13 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
|
||||
agentId: resolvedAgent.agentId,
|
||||
catalog: catalogResult(
|
||||
provider,
|
||||
[projectCatalogHostCreators(host, resolvedAgent.agentId, creatorBySessionKey)],
|
||||
[
|
||||
projectCatalogHostCreatedActors(
|
||||
host,
|
||||
resolvedAgent.agentId,
|
||||
actorBySessionKey,
|
||||
),
|
||||
],
|
||||
undefined,
|
||||
createSession,
|
||||
),
|
||||
@@ -265,7 +271,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
|
||||
return catalogResult(
|
||||
provider,
|
||||
hosts.map((host) =>
|
||||
projectCatalogHostCreators(host, resolvedAgent.agentId, creatorBySessionKey),
|
||||
projectCatalogHostCreatedActors(host, resolvedAgent.agentId, actorBySessionKey),
|
||||
),
|
||||
undefined,
|
||||
createSession,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveAgentRunSessionCreation } from "./session-creation-provenance.js";
|
||||
|
||||
describe("agent run session creation provenance", () => {
|
||||
it("uses a proven Gateway profile id", () => {
|
||||
expect(
|
||||
resolveAgentRunSessionCreation({
|
||||
authenticatedUserProfile: { profileId: "profile-ada" },
|
||||
}),
|
||||
).toEqual({ via: "run", actor: { type: "human", id: "profile-ada" } });
|
||||
});
|
||||
|
||||
it("does not infer an actor for a profile-less wire client", () => {
|
||||
expect(resolveAgentRunSessionCreation({})).toEqual({ via: "run" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type {
|
||||
SessionCreatedActor,
|
||||
SessionCreatedVia,
|
||||
} from "../../config/sessions/session-entry-provenance.js";
|
||||
|
||||
export type TrustedSessionCreation = {
|
||||
via: SessionCreatedVia;
|
||||
actor?: SessionCreatedActor;
|
||||
};
|
||||
|
||||
/**
|
||||
* Structural subset of GatewayClient; a leaf contract so shared-types.ts can
|
||||
* import TrustedSessionCreation without a type cycle back through this module.
|
||||
*/
|
||||
type SessionCreationClient = {
|
||||
authenticatedUserProfile?: { profileId?: string } | null;
|
||||
internal?: { syntheticClient?: true; sessionCreation?: TrustedSessionCreation };
|
||||
};
|
||||
|
||||
export function resolveOperatorSessionCreation(
|
||||
client: SessionCreationClient | null | undefined,
|
||||
options: { allowTrustedHint?: boolean } = {},
|
||||
): TrustedSessionCreation {
|
||||
if (options.allowTrustedHint && client?.internal?.sessionCreation) {
|
||||
return client.internal.sessionCreation;
|
||||
}
|
||||
const profileId = client?.authenticatedUserProfile?.profileId;
|
||||
// Actor only when proven: a profile-less wire connection may be an agent-tool
|
||||
// client on a remote topology, so claiming a human actor would misattribute
|
||||
// agent-caused creations. Absent actor means unknown, never inferred.
|
||||
return {
|
||||
via: "operator",
|
||||
...(profileId ? { actor: { type: "human" as const, id: profileId } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAgentRunSessionCreation(
|
||||
client: SessionCreationClient | null | undefined,
|
||||
): TrustedSessionCreation {
|
||||
const actor = resolveOperatorSessionCreation(client).actor;
|
||||
return { via: "run", ...(actor ? { actor } : {}) };
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status
|
||||
import { insideGitCheckout } from "../../agents/worktrees/git.js";
|
||||
import { managedWorktrees } from "../../agents/worktrees/service.js";
|
||||
import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js";
|
||||
import { sessionEntryForkedFromParent } from "../../config/sessions/session-entry-lineage.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { isPathInside } from "../../infra/path-guards.js";
|
||||
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
|
||||
@@ -28,13 +29,13 @@ import { resolveSessionStoreAgentId } from "../session-store-key.js";
|
||||
import { readSessionMessageCountAsync } from "../session-transcript-readers.js";
|
||||
import { loadSessionEntryReadOnly, resolveGatewaySessionStoreTarget } from "../session-utils.js";
|
||||
import { chatHandlers } from "./chat.js";
|
||||
import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
|
||||
import { resolveSessionCatalogCreateTarget } from "./session-catalog.js";
|
||||
import { emitSessionsChanged } from "./session-change-event.js";
|
||||
import {
|
||||
resolveSessionCreateInitialTurn,
|
||||
shouldAttachPendingMessageSeq,
|
||||
} from "./session-create-initial-turn.js";
|
||||
import { resolveOperatorSessionCreation } from "./session-creation-provenance.js";
|
||||
import { sessionLog } from "./sessions-shared.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
@@ -329,7 +330,6 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
|
||||
).allowed;
|
||||
const created = await createGatewaySession({
|
||||
cfg,
|
||||
createdBy: gatewayClientSessionCreator(client),
|
||||
key: sessionKey,
|
||||
agentId: sessionAgentId,
|
||||
label: p.label,
|
||||
@@ -356,6 +356,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
|
||||
emitCommandHooks: p.emitCommandHooks,
|
||||
resetMainWhenUnspecified: !hasInitialTurn,
|
||||
commandSource: "webchat",
|
||||
creation: resolveOperatorSessionCreation(client, { allowTrustedHint: true }),
|
||||
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
|
||||
afterCreate: hasInitialTurn
|
||||
? async ({ key, agentId, entry, storePath }) => {
|
||||
@@ -432,6 +433,9 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
|
||||
branch: sessionWorktree.branch,
|
||||
}
|
||||
: undefined;
|
||||
const responseEntry = sessionEntryForkedFromParent(created.entry)
|
||||
? { ...created.entry, forkedFromParent: true as const }
|
||||
: created.entry;
|
||||
if (created.resetExisting) {
|
||||
respond(
|
||||
true,
|
||||
@@ -439,7 +443,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
|
||||
ok: true,
|
||||
key: created.key,
|
||||
sessionId: created.entry.sessionId,
|
||||
entry: created.entry,
|
||||
entry: responseEntry,
|
||||
resolved: created.resolved,
|
||||
runStarted: false,
|
||||
...(createdWorktree ? { worktree: createdWorktree } : {}),
|
||||
@@ -467,7 +471,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
|
||||
ok: true,
|
||||
key: created.key,
|
||||
sessionId: created.entry.sessionId,
|
||||
entry: created.entry,
|
||||
entry: responseEntry,
|
||||
runStarted,
|
||||
...(runPayload ? runPayload : {}),
|
||||
...(runStarted && typeof messageSeq === "number" ? { messageSeq } : {}),
|
||||
|
||||
@@ -34,9 +34,9 @@ import {
|
||||
type SessionsPatchResult,
|
||||
} from "../session-utils.js";
|
||||
import { projectSessionsPatchEntry } from "../sessions-patch.js";
|
||||
import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
|
||||
import { hasVisibleActiveSessionRun } from "./session-active-runs.js";
|
||||
import { emitSessionsChanged } from "./session-change-event.js";
|
||||
import { resolveOperatorSessionCreation } from "./session-creation-provenance.js";
|
||||
import {
|
||||
isAgentMainSessionKey,
|
||||
loadSessionsRuntimeModule,
|
||||
@@ -400,7 +400,7 @@ export const sessionMutationHandlers: GatewayRequestHandlers = {
|
||||
...(p.agentId ? { agentId: p.agentId } : {}),
|
||||
reason,
|
||||
commandSource: "gateway:sessions.reset",
|
||||
createdBy: gatewayClientSessionCreator(client),
|
||||
creation: resolveOperatorSessionCreation(client),
|
||||
});
|
||||
if (!result.ok) {
|
||||
respond(false, undefined, result.error);
|
||||
|
||||
@@ -58,9 +58,12 @@ import {
|
||||
appendTranscriptEvent,
|
||||
appendTranscriptMessage,
|
||||
listSessionEntries,
|
||||
loadSessionEntry,
|
||||
upsertSessionEntry,
|
||||
} from "../../config/sessions/session-accessor.js";
|
||||
import { listSessionStateEventsSince } from "../../sessions/session-state-events.js";
|
||||
import { sessionsHandlers } from "./sessions.js";
|
||||
import type { GatewayClient } from "./types.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
const sessionKey = "agent:main:rewind-handler";
|
||||
@@ -140,8 +143,12 @@ type MessageCutMethod =
|
||||
| "sessions.fork"
|
||||
| "sessions.rewind";
|
||||
|
||||
async function invoke(method: MessageCutMethod, entryId?: string) {
|
||||
const respond = vi.fn() as unknown as RespondFn;
|
||||
async function invoke(
|
||||
method: MessageCutMethod,
|
||||
entryId?: string,
|
||||
client: GatewayClient | null = null,
|
||||
) {
|
||||
const respond = vi.fn();
|
||||
await expectDefined(
|
||||
sessionsHandlers[method],
|
||||
`${method} handler`,
|
||||
@@ -155,9 +162,9 @@ async function invoke(method: MessageCutMethod, entryId?: string) {
|
||||
? {}
|
||||
: { entryId }),
|
||||
},
|
||||
respond,
|
||||
respond: respond as unknown as RespondFn,
|
||||
context: context(),
|
||||
client: null,
|
||||
client,
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
return respond;
|
||||
@@ -209,12 +216,36 @@ describe("session message-cut methods", () => {
|
||||
});
|
||||
|
||||
it("returns editor text for rewind and a new key for fork", async () => {
|
||||
const fork = await invoke("sessions.fork", "user-entry");
|
||||
const profileId = "profile-fork-creator";
|
||||
const fork = await invoke("sessions.fork", "user-entry", {
|
||||
connect: { scopes: ["operator.write"] },
|
||||
authenticatedUserProfile: {
|
||||
profileId,
|
||||
displayName: "Fork Operator",
|
||||
hasAvatar: false,
|
||||
updatedAt: 1,
|
||||
},
|
||||
} as GatewayClient);
|
||||
expect(fork).toHaveBeenCalledWith(
|
||||
true,
|
||||
expect.objectContaining({ editorText: "edit me", sessionKey: expect.any(String) }),
|
||||
undefined,
|
||||
);
|
||||
const forkKey = (fork.mock.calls[0]?.[1] as { sessionKey?: string } | undefined)?.sessionKey;
|
||||
expect(forkKey).toBeTruthy();
|
||||
const forkEntry = loadSessionEntry({ agentId: "main", sessionKey: forkKey ?? "" });
|
||||
expect(forkEntry).toMatchObject({
|
||||
createdVia: "operator",
|
||||
createdActor: { type: "human", id: profileId },
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
expect(listSessionStateEventsSince(forkKey ?? "", "main", 0, 20).events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "created",
|
||||
actorType: "human",
|
||||
actorId: profileId,
|
||||
}),
|
||||
);
|
||||
|
||||
const rewind = await invoke("sessions.rewind", "user-entry");
|
||||
expect(rewind).toHaveBeenCalledWith(true, { editorText: "edit me" }, undefined);
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
isCompetingSessionWorkAdmissionActive,
|
||||
runExclusiveSessionLifecycleMutation,
|
||||
} from "../../sessions/session-lifecycle-admission.js";
|
||||
import { recordSessionCreated } from "../../sessions/session-state-events.js";
|
||||
import {
|
||||
readSessionUpstreamLink,
|
||||
type SessionUpstreamLink,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
import { asWorkerInferenceControl } from "../worker-environments/inference-control.js";
|
||||
import { hasVisibleActiveSessionRun } from "./session-active-runs.js";
|
||||
import { emitSessionsChanged } from "./session-change-event.js";
|
||||
import { resolveOperatorSessionCreation } from "./session-creation-provenance.js";
|
||||
import {
|
||||
loadAccessorSessionEntryForGatewayTarget,
|
||||
resolveSessionWorkerPlacementMutationError,
|
||||
@@ -155,7 +157,7 @@ async function mutateSessionAtMessage(
|
||||
options: GatewayRequestHandlerOptions,
|
||||
action: MessageCutAction,
|
||||
): Promise<void> {
|
||||
const { params, respond, context } = options;
|
||||
const { params, respond, context, client } = options;
|
||||
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey.trim() : "";
|
||||
const entryId =
|
||||
action === "switch"
|
||||
@@ -385,6 +387,7 @@ async function mutateSessionAtMessage(
|
||||
sessionStoreKey: current.sessionStoreKey,
|
||||
storePath: current.storePath,
|
||||
targetKey,
|
||||
creation: resolveOperatorSessionCreation(client),
|
||||
})
|
||||
: action === "rewind"
|
||||
? rewindSessionToMessage({
|
||||
@@ -415,6 +418,12 @@ async function mutateSessionAtMessage(
|
||||
}
|
||||
if (action !== "fork") {
|
||||
clearSessionQueues(lifecycleIdentities);
|
||||
} else {
|
||||
recordSessionCreated({
|
||||
sessionKey: result.key,
|
||||
agentId: current.target.agentId,
|
||||
entry: result.entry,
|
||||
});
|
||||
}
|
||||
respond(
|
||||
true,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type {
|
||||
SessionCreatorIdentity,
|
||||
SessionApprovalReplay,
|
||||
SystemAgentChatQuestion,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
@@ -58,6 +57,7 @@ import type {
|
||||
WorkerEnvironmentServiceContract,
|
||||
WorkerPlacementDispatchContract,
|
||||
} from "../worker-environments/service-contract.js";
|
||||
import type { TrustedSessionCreation } from "./session-creation-provenance.js";
|
||||
|
||||
/**
|
||||
* Shared gateway request types used by every server-method module.
|
||||
@@ -78,8 +78,6 @@ export type GatewayClient = {
|
||||
hasAvatar: boolean;
|
||||
updatedAt: number;
|
||||
};
|
||||
/** Trusted operator identity resolved once during connection admission. */
|
||||
operatorIdentity?: SessionCreatorIdentity;
|
||||
pluginSurfaceUrls?: Record<string, string>;
|
||||
pluginNodeCapabilitySurfaces?: Record<string, PluginNodeCapabilitySurface>;
|
||||
pluginNodeCapabilities?: Record<string, { capability: string; expiresAtMs: number }>;
|
||||
@@ -89,6 +87,10 @@ export type GatewayClient = {
|
||||
/** Signed shared-auth session admitted only to approve its own upgrade pairing. */
|
||||
isControlUiDeviceAuthMigration?: boolean;
|
||||
internal?: {
|
||||
/** Marks the server-constructed client used by trusted in-process dispatch. */
|
||||
syntheticClient?: true;
|
||||
/** Trusted session creation provenance; never accepted from Gateway wire params. */
|
||||
sessionCreation?: TrustedSessionCreation;
|
||||
allowModelOverride?: boolean;
|
||||
approvalRuntime?: boolean;
|
||||
cronRunContinuation?: boolean;
|
||||
|
||||
@@ -10,6 +10,7 @@ import { normalizeToolName } from "../agents/tool-policy.js";
|
||||
import { getActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import type { RuntimePluginToolGrant } from "../plugins/runtime/tool-grant.js";
|
||||
import { APPROVALS_SCOPE, WRITE_SCOPE } from "./method-scopes.js";
|
||||
import type { TrustedSessionCreation } from "./server-methods/session-creation-provenance.js";
|
||||
import type { GatewayRequestOptions } from "./server-methods/types.js";
|
||||
|
||||
export function createSyntheticPluginRuntimeClient(params?: {
|
||||
@@ -21,6 +22,7 @@ export function createSyntheticPluginRuntimeClient(params?: {
|
||||
pluginRuntimeOwnerId?: string;
|
||||
runtimePluginToolGrant?: RuntimePluginToolGrant;
|
||||
delegatedToolPolicyHandoff?: boolean;
|
||||
sessionCreation?: TrustedSessionCreation;
|
||||
scopes?: string[];
|
||||
}): NonNullable<GatewayRequestOptions["client"]> {
|
||||
const pluginRuntimeOwnerId =
|
||||
@@ -41,6 +43,8 @@ export function createSyntheticPluginRuntimeClient(params?: {
|
||||
scopes: params?.scopes ?? [WRITE_SCOPE],
|
||||
},
|
||||
internal: {
|
||||
syntheticClient: true,
|
||||
...(params?.sessionCreation ? { sessionCreation: params.sessionCreation } : {}),
|
||||
allowModelOverride: params?.allowModelOverride === true,
|
||||
...(params?.agentRunTracking ? { agentRunTracking: params.agentRunTracking } : {}),
|
||||
...(params?.cronRunContinuation === true ? { cronRunContinuation: true } : {}),
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type GatewayMethodDispatchResponse,
|
||||
unwrapGatewayMethodDispatchResponse,
|
||||
} from "./server-in-process-dispatch.js";
|
||||
import type { TrustedSessionCreation } from "./server-methods/session-creation-provenance.js";
|
||||
import type {
|
||||
GatewayRequestContext,
|
||||
GatewayRequestHandler,
|
||||
@@ -247,6 +248,7 @@ type DispatchGatewayMethodInProcessOptions = {
|
||||
pluginRuntimeOwnerId?: string;
|
||||
runtimePluginToolGrant?: RuntimePluginToolGrant;
|
||||
delegatedToolPolicyHandoff?: boolean;
|
||||
sessionCreation?: TrustedSessionCreation;
|
||||
requireScopedClient?: boolean;
|
||||
syntheticScopes?: string[];
|
||||
timeoutMs?: number;
|
||||
@@ -288,6 +290,7 @@ export async function dispatchGatewayMethodInProcessRaw(
|
||||
? { runtimePluginToolGrant: options.runtimePluginToolGrant }
|
||||
: {}),
|
||||
delegatedToolPolicyHandoff: options?.delegatedToolPolicyHandoff === true,
|
||||
...(options?.sessionCreation ? { sessionCreation: options.sessionCreation } : {}),
|
||||
scopes: options?.syntheticScopes,
|
||||
});
|
||||
const scopedClient = mergePluginRuntimeClientInternal(
|
||||
|
||||
@@ -93,7 +93,7 @@ function buildGatewaySessionSnapshot(params: {
|
||||
const session = params.includeSession
|
||||
? {
|
||||
...buildGatewaySessionEventRow(sessionRow),
|
||||
createdBy: sessionRow.createdBy ?? null,
|
||||
createdActor: sessionRow.createdActor ?? null,
|
||||
thinkingLevel: sessionRow.thinkingLevel ?? null,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
@@ -367,6 +367,53 @@ describe("gateway server agent", () => {
|
||||
expect(persisted?.spawnedBy).toBe("agent:main:main");
|
||||
});
|
||||
|
||||
test("agent stamps create-on-run session rows without guessing an actor", async () => {
|
||||
await setTestSessionStore({ entries: {} });
|
||||
const sessionKey = "agent:main:dashboard:create-on-run";
|
||||
const res = await rpcReq(gatewaySuite.ws, "agent", {
|
||||
message: "hi",
|
||||
sessionKey,
|
||||
idempotencyKey: "idem-agent-create-on-run",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
await waitForAgentCommandCall("idem-agent-create-on-run");
|
||||
|
||||
expect(
|
||||
loadSessionEntry({ sessionKey, storePath: gatewaySuite.sessionStorePath }),
|
||||
).toMatchObject({
|
||||
createdVia: "run",
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
expect(
|
||||
loadSessionEntry({ sessionKey, storePath: gatewaySuite.sessionStorePath })?.createdActor,
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test("agent links the previous generation when a missing transcript rotates the session", async () => {
|
||||
const sessionKey = "agent:main:dashboard:rotate-missing-transcript";
|
||||
await setTestSessionStore({
|
||||
entries: {
|
||||
[sessionKey]: {
|
||||
sessionId: "missing-transcript-generation",
|
||||
status: "failed",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await rpcReq(gatewaySuite.ws, "agent", {
|
||||
message: "continue",
|
||||
sessionKey,
|
||||
idempotencyKey: "idem-agent-rotate-missing-transcript",
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
await waitForAgentCommandCall("idem-agent-rotate-missing-transcript");
|
||||
|
||||
const persisted = loadSessionEntry({ sessionKey, storePath: gatewaySuite.sessionStorePath });
|
||||
expect(persisted?.sessionId).not.toBe("missing-transcript-generation");
|
||||
expect(persisted?.previousSessionId).toBe("missing-transcript-generation");
|
||||
});
|
||||
|
||||
test("agent derives sessionKey from agentId", async () => {
|
||||
testState.agentsConfig = { list: [{ id: "ops" }] };
|
||||
await setTestSessionStore({
|
||||
|
||||
@@ -224,10 +224,10 @@ describe("subagent session deliveryContext from spawn request params", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("pre-patched subagent session (via sessions.patch) inherits deliveryContext from agent request", async () => {
|
||||
// Simulates the real subagent spawn flow: spawnSubagentDirect calls sessions.patch
|
||||
// first (to set spawnDepth, spawnedBy, etc.), then calls callSubagentGateway({method: "agent"}).
|
||||
// The sessions.patch creates a partial entry without deliveryContext.
|
||||
test("pre-created subagent session inherits deliveryContext from agent request", async () => {
|
||||
// Simulates the real subagent spawn flow: the trusted session accessor persists lineage
|
||||
// before callSubagentGateway({method: "agent"}) seeds the delivery context.
|
||||
// The direct lineage write creates a partial entry without deliveryContext.
|
||||
// The agent handler must seed deliveryContext from the request params.
|
||||
await prepareSessionStore({
|
||||
"agent:main:subagent:pre-patched": {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { managedWorktrees } from "../agents/worktrees/service.js";
|
||||
import { loadSessionEntry, loadTranscriptEvents } from "../config/sessions/session-accessor.js";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { listSessionStateEventsSince } from "../sessions/session-state-events.js";
|
||||
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
|
||||
import {
|
||||
agentCommand,
|
||||
@@ -73,51 +74,6 @@ function requireNonEmptyString(value: string | undefined, label: string): string
|
||||
return value;
|
||||
}
|
||||
|
||||
test("sessions.create stamps the trusted creator and preserves it until reset", async () => {
|
||||
await createSessionStoreDir();
|
||||
const adaClient = {
|
||||
operatorIdentity: { id: "profile-ada", label: "Ada Lovelace" },
|
||||
connect: { scopes: ["operator.admin"] },
|
||||
} as never;
|
||||
const bobClient = {
|
||||
operatorIdentity: { id: "profile-bob", label: "Bob Hopper" },
|
||||
connect: { scopes: ["operator.admin"] },
|
||||
} as never;
|
||||
|
||||
const created = await directSessionReq<{
|
||||
key: string;
|
||||
entry: { createdBy?: { id: string; label?: string } };
|
||||
}>("sessions.create", { agentId: "main" }, { client: adaClient });
|
||||
expect(created.ok).toBe(true);
|
||||
expect(created.payload?.entry.createdBy).toEqual({
|
||||
id: "profile-ada",
|
||||
label: "Ada Lovelace",
|
||||
});
|
||||
const key = requireNonEmptyString(created.payload?.key, "created session key");
|
||||
|
||||
const reused = await directSessionReq<{ entry: { createdBy?: { id: string } } }>(
|
||||
"sessions.create",
|
||||
{ agentId: "main", key },
|
||||
{ client: bobClient },
|
||||
);
|
||||
expect(reused.payload?.entry.createdBy?.id).toBe("profile-ada");
|
||||
|
||||
const listed = await directSessionReq<{
|
||||
sessions: Array<{ key: string; createdBy?: { id: string; label?: string } }>;
|
||||
}>("sessions.list", { agentId: "main" });
|
||||
expect(listed.payload?.sessions.find((row) => row.key === key)?.createdBy).toEqual({
|
||||
id: "profile-ada",
|
||||
label: "Ada Lovelace",
|
||||
});
|
||||
|
||||
const reset = await directSessionReq<{ entry: { createdBy?: { id: string; label?: string } } }>(
|
||||
"sessions.reset",
|
||||
{ agentId: "main", key },
|
||||
{ client: bobClient },
|
||||
);
|
||||
expect(reset.payload?.entry.createdBy).toEqual({ id: "profile-bob", label: "Bob Hopper" });
|
||||
});
|
||||
|
||||
test("sessions.create provisions and reuses a session worktree for later runs", async () => {
|
||||
const root = await fs.mkdtemp(
|
||||
path.join(await fs.realpath(os.tmpdir()), "openclaw-session-worktree-"),
|
||||
@@ -1488,6 +1444,190 @@ test("sessions.create preserves write-scoped fresh keyed model selection but gat
|
||||
});
|
||||
});
|
||||
|
||||
test("sessions.create stamps trusted operator provenance and records created", async () => {
|
||||
await createSessionStoreDir();
|
||||
const profileId = "profile-session-creator";
|
||||
const created = await directSessionReq<{
|
||||
key?: string;
|
||||
entry?: {
|
||||
createdVia?: string;
|
||||
createdActor?: { type: string; id?: string };
|
||||
createdAt?: number;
|
||||
};
|
||||
}>(
|
||||
"sessions.create",
|
||||
{ agentId: "main" },
|
||||
{
|
||||
client: {
|
||||
connect: { scopes: ["operator.write"] },
|
||||
authenticatedUserProfile: {
|
||||
profileId,
|
||||
displayName: "Test Operator",
|
||||
hasAvatar: false,
|
||||
updatedAt: 1,
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
expect(created.ok).toBe(true);
|
||||
expect(created.payload?.entry).toMatchObject({
|
||||
createdVia: "operator",
|
||||
createdActor: { type: "human", id: profileId },
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
const key = requireNonEmptyString(created.payload?.key, "created session key");
|
||||
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();
|
||||
|
||||
const hinted = await directSessionReq<{
|
||||
entry?: { createdVia?: string; createdActor?: unknown };
|
||||
}>(
|
||||
"sessions.create",
|
||||
{ agentId: "main" },
|
||||
{
|
||||
client: {
|
||||
connect: { scopes: ["operator.write"] },
|
||||
internal: {
|
||||
syntheticClient: true,
|
||||
sessionCreation: {
|
||||
via: "spawn",
|
||||
actor: { type: "agent", id: "agent:main:main" },
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
expect(hinted.payload?.entry).toMatchObject({
|
||||
createdVia: "spawn",
|
||||
createdActor: { type: "agent", id: "agent:main:main" },
|
||||
});
|
||||
});
|
||||
|
||||
test("sessions.create reset-in-place preserves the node creation stamp", async () => {
|
||||
testState.sessionConfig = { dmScope: "main" };
|
||||
const { storePath } = await createSessionStoreDir();
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: sessionStoreEntry("existing-main", {
|
||||
createdVia: "channel",
|
||||
createdActor: { type: "human", id: "telegram:42" },
|
||||
createdAt: 1234,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const reset = await directSessionReq<{ entry?: Record<string, unknown> }>(
|
||||
"sessions.create",
|
||||
{ agentId: "main", parentSessionKey: "main", emitCommandHooks: true },
|
||||
{
|
||||
client: {
|
||||
connect: { scopes: ["operator.write"] },
|
||||
authenticatedUserProfile: {
|
||||
profileId: "profile-resetter",
|
||||
displayName: null,
|
||||
hasAvatar: false,
|
||||
updatedAt: 1,
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
expect(reset.ok).toBe(true);
|
||||
expect(reset.payload?.entry).toMatchObject({
|
||||
createdVia: "channel",
|
||||
createdActor: { type: "human", id: "telegram:42" },
|
||||
createdAt: 1234,
|
||||
});
|
||||
expect(loadSessionEntry({ sessionKey: "agent:main:main", storePath })).toMatchObject({
|
||||
createdVia: "channel",
|
||||
createdActor: { type: "human", id: "telegram:42" },
|
||||
createdAt: 1234,
|
||||
});
|
||||
});
|
||||
|
||||
test("sessions.create adopting an existing key does not restamp node provenance", async () => {
|
||||
const { storePath } = await createSessionStoreDir();
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
"agent:main:dashboard:adopted": sessionStoreEntry("existing-adopted", {
|
||||
createdVia: "spawn",
|
||||
createdActor: { type: "agent", id: "agent:main:main" },
|
||||
createdAt: 4321,
|
||||
}),
|
||||
},
|
||||
});
|
||||
const { chatHandlers } = await import("./server-methods/chat.js");
|
||||
const chatSend = vi.spyOn(chatHandlers, "chat.send").mockImplementation(async ({ respond }) => {
|
||||
respond(true, { runId: "adopted-run", status: "started" });
|
||||
});
|
||||
|
||||
try {
|
||||
const adopted = await directSessionReq<{
|
||||
entry?: Record<string, unknown>;
|
||||
runStarted?: boolean;
|
||||
}>(
|
||||
"sessions.create",
|
||||
{ key: "agent:main:dashboard:adopted", agentId: "main", message: "adopted follow-up" },
|
||||
{
|
||||
client: {
|
||||
connect: { scopes: ["operator.write"] },
|
||||
authenticatedUserProfile: {
|
||||
profileId: "profile-adopter",
|
||||
displayName: null,
|
||||
hasAvatar: false,
|
||||
updatedAt: 1,
|
||||
},
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
expect(adopted.ok).toBe(true);
|
||||
// Post-create work (the nested initial chat.send) still runs on adoption.
|
||||
expect(adopted.payload?.runStarted).toBe(true);
|
||||
expect(chatSend).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
loadSessionEntry({ sessionKey: "agent:main:dashboard:adopted", storePath }),
|
||||
).toMatchObject({
|
||||
createdVia: "spawn",
|
||||
createdActor: { type: "agent", id: "agent:main:main" },
|
||||
createdAt: 4321,
|
||||
});
|
||||
// Adoption is not a node creation: no `created` event may enter the journal.
|
||||
expect(
|
||||
listSessionStateEventsSince("agent:main:dashboard:adopted", "main", 0, 20).events.filter(
|
||||
(event) => event.kind === "created",
|
||||
),
|
||||
).toEqual([]);
|
||||
} finally {
|
||||
chatSend.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test("sessions.create scopes the main alias to the requested agent", async () => {
|
||||
const { storePath } = await createSessionStoreDir();
|
||||
|
||||
@@ -1875,6 +2015,7 @@ test("sessions.create forks the parent transcript into the new session", async (
|
||||
entry?: {
|
||||
sessionFile?: string;
|
||||
parentSessionKey?: string;
|
||||
forkSource?: { sessionKey: string; sessionId: string };
|
||||
forkedFromParent?: boolean;
|
||||
totalTokens?: number;
|
||||
totalTokensFresh?: boolean;
|
||||
@@ -1887,6 +2028,10 @@ test("sessions.create forks the parent transcript into the new session", async (
|
||||
|
||||
expect(created.ok, JSON.stringify(created.error)).toBe(true);
|
||||
expect(created.payload?.entry?.parentSessionKey).toBe("agent:main:main");
|
||||
expect(created.payload?.entry?.forkSource).toEqual({
|
||||
sessionKey: "agent:main:main",
|
||||
sessionId: parent.sessionId,
|
||||
});
|
||||
expect(created.payload?.entry?.forkedFromParent).toBe(true);
|
||||
expect(created.payload?.entry?.totalTokens).toBeUndefined();
|
||||
expect(created.payload?.entry?.totalTokensFresh).toBe(false);
|
||||
@@ -1932,8 +2077,16 @@ test("sessions.create forks the parent transcript into the new session", async (
|
||||
expect(loadSessionEntry({ sessionKey: key, storePath })).toMatchObject({
|
||||
sessionId: created.payload?.sessionId,
|
||||
sessionFile: forkedSessionFile,
|
||||
forkedFromParent: true,
|
||||
forkSource: {
|
||||
sessionKey: "agent:main:main",
|
||||
sessionId: parent.sessionId,
|
||||
},
|
||||
});
|
||||
expect(loadSessionEntry({ sessionKey: key, storePath })).not.toHaveProperty("forkedFromParent");
|
||||
const listed = await directSessionReq<{
|
||||
sessions?: Array<{ key: string; forkedFromParent?: boolean }>;
|
||||
}>("sessions.list", {});
|
||||
expect(listed.payload?.sessions?.find((row) => row.key === key)?.forkedFromParent).toBe(true);
|
||||
testState.sessionConfig = undefined;
|
||||
});
|
||||
|
||||
@@ -2159,6 +2312,7 @@ test("sessions.create resolves an agent-qualified fork from the parent store", a
|
||||
entry?: {
|
||||
parentSessionKey?: string;
|
||||
sessionFile?: string;
|
||||
forkSource?: { sessionKey: string; sessionId: string };
|
||||
forkedFromParent?: boolean;
|
||||
};
|
||||
}>("sessions.create", {
|
||||
@@ -2169,6 +2323,10 @@ test("sessions.create resolves an agent-qualified fork from the parent store", a
|
||||
expect(created.ok, JSON.stringify(created.error)).toBe(true);
|
||||
expect(created.payload?.key).toMatch(/^agent:main:dashboard:/);
|
||||
expect(created.payload?.entry?.parentSessionKey).toBe("agent:work:main");
|
||||
expect(created.payload?.entry?.forkSource).toEqual({
|
||||
sessionKey: "agent:work:main",
|
||||
sessionId: parent.sessionId,
|
||||
});
|
||||
expect(created.payload?.entry?.forkedFromParent).toBe(true);
|
||||
const forkedSessionFile = requireNonEmptyString(
|
||||
created.payload?.entry?.sessionFile,
|
||||
|
||||
@@ -1191,12 +1191,22 @@ test("sessions.changed mutation events include subagent ownership metadata", asy
|
||||
entries: {
|
||||
"subagent:child": sessionStoreEntry("sess-child", {
|
||||
spawnedBy: "agent:main:main",
|
||||
parentSessionKey: "agent:main:dashboard:navigation-parent",
|
||||
spawnedWorkspaceDir: "/tmp/subagent-workspace",
|
||||
spawnedCwd: "/tmp/task-repo",
|
||||
forkedFromParent: true,
|
||||
spawnDepth: 2,
|
||||
subagentRole: "orchestrator",
|
||||
subagentControlScope: "children",
|
||||
createdVia: "spawn",
|
||||
createdActor: { type: "agent", id: "agent:main:main" },
|
||||
createdAt: 1_000,
|
||||
forkSource: {
|
||||
sessionKey: "agent:main:main",
|
||||
sessionId: "sess-source",
|
||||
entryId: "entry-source",
|
||||
},
|
||||
previousSessionId: "sess-previous",
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -1211,12 +1221,23 @@ test("sessions.changed mutation events include subagent ownership metadata", asy
|
||||
sessionKey: "agent:main:subagent:child",
|
||||
reason: "patch",
|
||||
spawnedBy: "agent:main:main",
|
||||
controlOwnerSessionKey: "agent:main:main",
|
||||
parentSessionKey: "agent:main:dashboard:navigation-parent",
|
||||
spawnedWorkspaceDir: "/tmp/subagent-workspace",
|
||||
spawnedCwd: "/tmp/task-repo",
|
||||
forkedFromParent: true,
|
||||
spawnDepth: 2,
|
||||
subagentRole: "orchestrator",
|
||||
subagentControlScope: "children",
|
||||
createdVia: "spawn",
|
||||
createdActor: { type: "agent", id: "agent:main:main" },
|
||||
createdAt: 1_000,
|
||||
forkSource: {
|
||||
sessionKey: "agent:main:main",
|
||||
sessionId: "sess-source",
|
||||
entryId: "entry-source",
|
||||
},
|
||||
previousSessionId: "sess-previous",
|
||||
});
|
||||
});
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -6,6 +6,7 @@ import path from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import { loadSessionEntry } from "../config/sessions/session-accessor.js";
|
||||
import { MODEL_SELECTION_LOCKED_RESET_MESSAGE } from "../sessions/model-overrides.js";
|
||||
import { listSessionStateEventsSince } from "../sessions/session-state-events.js";
|
||||
import { testState, writeSessionStore } from "./test-helpers.js";
|
||||
import {
|
||||
setupGatewaySessionsTestHarness,
|
||||
@@ -28,6 +29,11 @@ type ResetSessionEntry = {
|
||||
spawnedWorkspaceDir?: string;
|
||||
spawnedCwd?: string;
|
||||
parentSessionKey?: string;
|
||||
createdVia?: string;
|
||||
createdActor?: { type: string; id?: string };
|
||||
createdAt?: number;
|
||||
forkSource?: { sessionKey: string; sessionId: string; entryId?: string };
|
||||
previousSessionId?: string;
|
||||
forkedFromParent?: boolean;
|
||||
spawnDepth?: number;
|
||||
subagentRole?: string;
|
||||
@@ -84,6 +90,35 @@ type ModelResetEntry = Pick<
|
||||
type ResolvedSessionModel = { modelProvider: string; model: string };
|
||||
type SessionEntryOverrides = NonNullable<Parameters<typeof sessionStoreEntry>[1]>;
|
||||
|
||||
test("sessions.reset stamps provenance when it materializes a missing row", async () => {
|
||||
await createSessionStoreDir();
|
||||
const reset = await directSessionReq<{ entry: ResetSessionEntry }>(
|
||||
"sessions.reset",
|
||||
{ key: "agent:main:subagent:missing" },
|
||||
{
|
||||
client: {
|
||||
authenticatedUserProfile: { profileId: "profile-reset-creator" },
|
||||
} as never,
|
||||
},
|
||||
);
|
||||
|
||||
expect(reset.ok).toBe(true);
|
||||
expect(reset.payload?.entry).toMatchObject({
|
||||
createdVia: "operator",
|
||||
createdActor: { type: "human", id: "profile-reset-creator" },
|
||||
createdAt: expect.any(Number),
|
||||
});
|
||||
expect(
|
||||
listSessionStateEventsSince("agent:main:subagent:missing", "main", 0, 20).events,
|
||||
).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "created",
|
||||
actorType: "human",
|
||||
actorId: "profile-reset-creator",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
const ownedChildMetadata = {
|
||||
chatType: "group",
|
||||
channel: "discord",
|
||||
@@ -523,6 +558,15 @@ test("sessions.reset preserves spawned session ownership metadata", async () =>
|
||||
"subagent:child": sessionStoreEntry("sess-owned-child", {
|
||||
sessionFile: customSessionFile,
|
||||
...ownedChildMetadata,
|
||||
forkedFromParent: undefined,
|
||||
createdVia: "spawn",
|
||||
createdActor: { type: "agent", id: "agent:main:main" },
|
||||
createdAt: 1_000,
|
||||
forkSource: {
|
||||
sessionKey: "agent:main:root",
|
||||
sessionId: "root-session",
|
||||
entryId: "root-entry",
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -535,9 +579,31 @@ test("sessions.reset preserves spawned session ownership metadata", async () =>
|
||||
|
||||
expect(reset.ok).toBe(true);
|
||||
expectOwnedChildMetadata(reset.payload?.entry);
|
||||
expect(reset.payload?.entry).toMatchObject({
|
||||
createdVia: "spawn",
|
||||
createdActor: { type: "agent", id: "agent:main:main" },
|
||||
createdAt: 1_000,
|
||||
forkSource: {
|
||||
sessionKey: "agent:main:root",
|
||||
sessionId: "root-session",
|
||||
entryId: "root-entry",
|
||||
},
|
||||
previousSessionId: "sess-owned-child",
|
||||
});
|
||||
|
||||
const stored = loadSessionEntry({ sessionKey: "agent:main:subagent:child", storePath }) as
|
||||
| ResetSessionEntry
|
||||
| undefined;
|
||||
expectOwnedChildMetadata(stored);
|
||||
expect(stored).toMatchObject({
|
||||
createdVia: "spawn",
|
||||
createdActor: { type: "agent", id: "agent:main:main" },
|
||||
createdAt: 1_000,
|
||||
forkSource: {
|
||||
sessionKey: "agent:main:root",
|
||||
sessionId: "root-session",
|
||||
entryId: "root-entry",
|
||||
},
|
||||
previousSessionId: "sess-owned-child",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -439,33 +439,24 @@ test("lists and patches session store via sessions.* RPC", async () => {
|
||||
expect(spawnedOnly.ok).toBe(true);
|
||||
expect(spawnedOnly.payload?.sessions.map((s) => s.key)).toEqual(["agent:main:subagent:one"]);
|
||||
|
||||
const spawnedPatched = await directSessionReq<{
|
||||
ok: true;
|
||||
entry: { spawnedBy?: string };
|
||||
}>("sessions.patch", {
|
||||
key: "agent:main:subagent:two",
|
||||
spawnedBy: "agent:main:main",
|
||||
});
|
||||
expect(spawnedPatched.ok).toBe(true);
|
||||
expect(spawnedPatched.payload?.entry.spawnedBy).toBe("agent:main:main");
|
||||
|
||||
const acpPatched = await directSessionReq<{
|
||||
ok: true;
|
||||
entry: { spawnedBy?: string; spawnDepth?: number };
|
||||
}>("sessions.patch", {
|
||||
key: "agent:main:acp:child",
|
||||
for (const [field, value] of Object.entries({
|
||||
spawnedBy: "agent:main:main",
|
||||
spawnedWorkspaceDir: "/tmp/subagent-workspace",
|
||||
spawnedCwd: "/tmp/task-repo",
|
||||
spawnDepth: 1,
|
||||
});
|
||||
expect(acpPatched.ok).toBe(true);
|
||||
expect(acpPatched.payload?.entry.spawnedBy).toBe("agent:main:main");
|
||||
expect(acpPatched.payload?.entry.spawnDepth).toBe(1);
|
||||
|
||||
const spawnedPatchedInvalidKey = await directSessionReq("sessions.patch", {
|
||||
key: "agent:main:main",
|
||||
spawnedBy: "agent:main:main",
|
||||
});
|
||||
expect(spawnedPatchedInvalidKey.ok).toBe(false);
|
||||
subagentRole: "leaf",
|
||||
subagentControlScope: "none",
|
||||
})) {
|
||||
const rejected = await directSessionReq("sessions.patch", {
|
||||
key: "agent:main:subagent:two",
|
||||
[field]: value,
|
||||
});
|
||||
expect(rejected.ok, field).toBe(false);
|
||||
expect(rejected.error, field).toMatchObject({
|
||||
code: "INVALID_REQUEST",
|
||||
message: expect.stringContaining(`unexpected property '${field}'`),
|
||||
});
|
||||
}
|
||||
|
||||
const cleaned = await directSessionReq<{
|
||||
applied: true;
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { ConnectErrorDetailCodes } from "../../../../packages/gateway-protocol/src/connect-error-details.js";
|
||||
import { ErrorCodes, PROTOCOL_VERSION } from "../../../../packages/gateway-protocol/src/index.js";
|
||||
import { getRuntimeConfig } from "../../../config/io.js";
|
||||
import { getPairedDevice } from "../../../infra/device-pairing.js";
|
||||
import {
|
||||
captureAuthenticatedNodePairingState,
|
||||
type NodePairingGeneration,
|
||||
@@ -214,33 +213,6 @@ export async function attachAuthenticatedGatewayConnect(
|
||||
);
|
||||
}
|
||||
}
|
||||
let pairedDeviceLabel: string | undefined;
|
||||
if (device?.id) {
|
||||
try {
|
||||
const pairedDevice = await getPairedDevice(device.id);
|
||||
pairedDeviceLabel =
|
||||
normalizeOptionalString(pairedDevice?.operatorLabel) ??
|
||||
normalizeOptionalString(pairedDevice?.displayName);
|
||||
} catch (error) {
|
||||
// Pairing metadata is attribution-only and must not turn into a login dependency.
|
||||
logWsControl.warn(
|
||||
`paired device label resolution failed conn=${connId}: ${formatForLog(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// SSO identity wins over device labeling so one person keeps the same creator
|
||||
// across browsers; paired-device labels cover gateways without trusted proxy auth.
|
||||
const operatorIdentity = authenticatedUserProfile
|
||||
? {
|
||||
id: authenticatedUserId,
|
||||
label: authenticatedUserProfile.displayName ?? authenticatedUserId,
|
||||
}
|
||||
: authenticatedUserId
|
||||
? { id: authenticatedUserId, label: authenticatedUserId }
|
||||
: device?.id && pairedDeviceLabel
|
||||
? { id: device.id, label: pairedDeviceLabel }
|
||||
: undefined;
|
||||
|
||||
const pluginSurfaceUrls: Record<string, string> = {};
|
||||
const pluginNodeCapabilitySurfaces = indexPluginNodeCapabilitySurfaces(pluginNodeCapabilities);
|
||||
const pendingPluginNodeCapabilities: Array<{
|
||||
@@ -342,7 +314,6 @@ export async function attachAuthenticatedGatewayConnect(
|
||||
presenceKey,
|
||||
...(authenticatedUserId ? { authenticatedUserId } : {}),
|
||||
...(authenticatedUserProfile ? { authenticatedUserProfile } : {}),
|
||||
...(operatorIdentity ? { operatorIdentity } : {}),
|
||||
clientIp: reportedClientIp,
|
||||
...(internal ? { internal } : {}),
|
||||
...(Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {}),
|
||||
|
||||
@@ -628,7 +628,6 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
|
||||
displayName: "alice",
|
||||
hasAvatar: false,
|
||||
},
|
||||
operatorIdentity: { id: "alice@example.com", label: "alice" },
|
||||
});
|
||||
|
||||
expect(setAvatar(profileId!, new Uint8Array([1, 2, 3]), "image/png").ok).toBe(true);
|
||||
@@ -665,10 +664,7 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
expect(harness.client).toMatchObject({
|
||||
authenticatedUserId: "alice@example.com",
|
||||
operatorIdentity: { id: "alice@example.com", label: "alice@example.com" },
|
||||
});
|
||||
expect(harness.client).toMatchObject({ authenticatedUserId: "alice@example.com" });
|
||||
expect(harness.client).not.toMatchObject({ authenticatedUserProfile: expect.anything() });
|
||||
expect(harness.logWsControl.warn).toHaveBeenCalledTimes(1);
|
||||
expect(harness.logWsControl.warn).toHaveBeenCalledWith(
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { buildForkedGatewaySessionEntry } from "./session-create-fork-entry.js";
|
||||
|
||||
describe("buildForkedGatewaySessionEntry", () => {
|
||||
it("preserves adopted node ancestry and links the replaced generation", () => {
|
||||
const previous: SessionEntry = {
|
||||
sessionId: "adopted-generation",
|
||||
updatedAt: 1,
|
||||
forkSource: { sessionKey: "agent:main:original", sessionId: "original-generation" },
|
||||
};
|
||||
|
||||
const forked = buildForkedGatewaySessionEntry(
|
||||
previous,
|
||||
{ sessionId: "next-generation", sessionFile: "/tmp/next-generation.jsonl" },
|
||||
{ sessionKey: "agent:main:new-parent", sessionId: "new-parent-generation" },
|
||||
previous,
|
||||
);
|
||||
|
||||
expect(forked).toMatchObject({
|
||||
sessionId: "next-generation",
|
||||
previousSessionId: "adopted-generation",
|
||||
forkSource: { sessionKey: "agent:main:original", sessionId: "original-generation" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the requested ancestry for a genuinely new node", () => {
|
||||
const entry: SessionEntry = { sessionId: "provisional", updatedAt: 1 };
|
||||
const forked = buildForkedGatewaySessionEntry(
|
||||
entry,
|
||||
{ sessionId: "forked", sessionFile: "/tmp/forked.jsonl" },
|
||||
{ sessionKey: "agent:main:parent", sessionId: "parent-generation" },
|
||||
);
|
||||
|
||||
expect(forked.forkSource).toEqual({
|
||||
sessionKey: "agent:main:parent",
|
||||
sessionId: "parent-generation",
|
||||
});
|
||||
expect(forked.previousSessionId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ import type { SessionEntry } from "../config/sessions.js";
|
||||
export function buildForkedGatewaySessionEntry(
|
||||
entry: SessionEntry,
|
||||
fork: { sessionId: string; sessionFile: string },
|
||||
forkSource: NonNullable<SessionEntry["forkSource"]>,
|
||||
previousEntry?: SessionEntry,
|
||||
): SessionEntry {
|
||||
// Replacing the transcript identity also replaces the recovery episode owned by the old row.
|
||||
return {
|
||||
@@ -11,7 +13,10 @@ export function buildForkedGatewaySessionEntry(
|
||||
...buildMainSessionRecoveryClearPatch(entry),
|
||||
sessionId: fork.sessionId,
|
||||
sessionFile: fork.sessionFile,
|
||||
forkedFromParent: true,
|
||||
forkSource: previousEntry?.forkSource ?? forkSource,
|
||||
...(previousEntry?.sessionId && previousEntry.sessionId !== fork.sessionId
|
||||
? { previousSessionId: previousEntry.sessionId }
|
||||
: {}),
|
||||
totalTokens: undefined,
|
||||
totalTokensFresh: false,
|
||||
};
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
import {
|
||||
ErrorCodes,
|
||||
type ErrorShape,
|
||||
type SessionCreatorIdentity,
|
||||
errorShape,
|
||||
missingScopeErrorShape,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
@@ -34,6 +33,11 @@ import {
|
||||
createSessionEntryWithTranscript,
|
||||
resolveSessionEntryAccessTarget,
|
||||
} from "../config/sessions/session-accessor.js";
|
||||
import {
|
||||
buildSessionCreationStamp,
|
||||
type SessionCreatedActor,
|
||||
type SessionCreatedVia,
|
||||
} from "../config/sessions/session-entry-provenance.js";
|
||||
import { inheritSessionSelection } from "../config/sessions/session-entry-selection.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
@@ -58,6 +62,7 @@ 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 { ADMIN_SCOPE } from "./operator-scopes.js";
|
||||
import { buildForkedGatewaySessionEntry } from "./session-create-fork-entry.js";
|
||||
@@ -261,7 +266,6 @@ export async function createGatewaySession(params: {
|
||||
thinkingLevel?: string;
|
||||
/** Trusted catalog-owned model/runtime pair, persisted and locked together. */
|
||||
catalogTarget?: TrustedCatalogSessionTarget;
|
||||
createdBy?: SessionCreatorIdentity;
|
||||
parentSessionKey?: string;
|
||||
/**
|
||||
* Spawn-lineage depth declared by spawn-owned creations (visible subagent
|
||||
@@ -293,6 +297,8 @@ export async function createGatewaySession(params: {
|
||||
initialEntry?: TrustedInitialSessionEntry;
|
||||
/** Public callers need admin before reconfiguring an adopted keyed session. */
|
||||
allowExistingModelSelection?: boolean;
|
||||
/** Trusted in-process creation provenance; never populated from public Gateway params. */
|
||||
creation?: { via: SessionCreatedVia; actor?: SessionCreatedActor };
|
||||
/** Exact harness namespace authorized by the scoped plugin runtime. */
|
||||
authorizedAgentHarnessId?: string;
|
||||
/** Exact plugin namespace authorized by the scoped plugin runtime. */
|
||||
@@ -537,7 +543,7 @@ export async function createGatewaySession(params: {
|
||||
: {}),
|
||||
reason: "new",
|
||||
commandSource: params.commandSource,
|
||||
createdBy: params.createdBy,
|
||||
...(params.creation ? { creation: params.creation } : {}),
|
||||
...(spawnedCwd ? { spawnedCwd } : {}),
|
||||
...(params.worktree ? { worktree: params.worktree } : {}),
|
||||
...(params.execNode ? { execNode: params.execNode } : {}),
|
||||
@@ -560,6 +566,7 @@ export async function createGatewaySession(params: {
|
||||
}
|
||||
|
||||
let createdContext: CreatedGatewaySession | undefined;
|
||||
let createdNewEntry = false;
|
||||
const createChildSession = async (): Promise<CreateGatewaySessionResult> => {
|
||||
let currentParentSessionEntry = parentSessionEntry;
|
||||
if (
|
||||
@@ -687,6 +694,9 @@ export async function createGatewaySession(params: {
|
||||
),
|
||||
};
|
||||
}
|
||||
// Adoption of an existing key must not stamp provenance or emit a
|
||||
// `created` event; only a genuinely new row is a node creation.
|
||||
createdNewEntry = existingEntry === undefined;
|
||||
const requestedModel = normalizeOptionalString(params.model);
|
||||
const requestedThinkingLevel = normalizeOptionalString(params.thinkingLevel);
|
||||
if (existingEntry?.sessionId && params.allowExistingModelSelection !== true) {
|
||||
@@ -771,9 +781,10 @@ export async function createGatewaySession(params: {
|
||||
: undefined;
|
||||
const initializedEntry: SessionEntry = {
|
||||
...patched.entry,
|
||||
...(existingEntry === undefined && params.createdBy
|
||||
? { createdBy: { ...params.createdBy } }
|
||||
: {}),
|
||||
// Stamp provenance only for genuinely new rows: adopting an existing key
|
||||
// must not restamp write-once node facts (this direct store write bypasses
|
||||
// the merge-level write-once guard), and legacy rows stay "unknown".
|
||||
...(params.creation && createdNewEntry ? buildSessionCreationStamp(params.creation) : {}),
|
||||
...(catalogResolvedModel && catalogAgentRuntime
|
||||
? {
|
||||
providerOverride: catalogResolvedModel.provider,
|
||||
@@ -880,7 +891,15 @@ export async function createGatewaySession(params: {
|
||||
}
|
||||
return {
|
||||
...initialized,
|
||||
entry: buildForkedGatewaySessionEntry(entry, fork),
|
||||
entry: buildForkedGatewaySessionEntry(
|
||||
entry,
|
||||
fork,
|
||||
{
|
||||
sessionKey: forkParentSessionKey,
|
||||
sessionId: currentParentSessionEntry.sessionId,
|
||||
},
|
||||
existingEntry,
|
||||
),
|
||||
};
|
||||
},
|
||||
params.initialEntry
|
||||
@@ -967,12 +986,28 @@ export async function createGatewaySession(params: {
|
||||
run: createChildSession,
|
||||
});
|
||||
if (result.ok && !result.resetExisting && createdContext) {
|
||||
// Adoption still runs post-create work (initial chat.send, plugin hooks);
|
||||
// only the created journal event is reserved for genuinely new rows.
|
||||
if (createdNewEntry) {
|
||||
recordSessionCreated({
|
||||
sessionKey: createdContext.key,
|
||||
agentId: createdContext.agentId,
|
||||
entry: createdContext.entry,
|
||||
});
|
||||
}
|
||||
await params.afterCreate?.(createdContext);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const result = await createChildSession();
|
||||
if (result.ok && !result.resetExisting && createdContext) {
|
||||
if (createdNewEntry) {
|
||||
recordSessionCreated({
|
||||
sessionKey: createdContext.key,
|
||||
agentId: createdContext.agentId,
|
||||
entry: createdContext.entry,
|
||||
});
|
||||
}
|
||||
await params.afterCreate?.(createdContext);
|
||||
}
|
||||
return result;
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { buildGatewaySessionEventFields } from "./session-event-payload.js";
|
||||
|
||||
it("projects creator identity and explicitly clears it for ownerless generations", () => {
|
||||
it("projects the created actor and explicitly clears it for actorless sessions", () => {
|
||||
expect(
|
||||
buildGatewaySessionEventFields({
|
||||
sessionRow: {
|
||||
key: "agent:main:owned",
|
||||
kind: "direct",
|
||||
updatedAt: 1,
|
||||
createdBy: { id: "profile-ada", label: "Ada" },
|
||||
createdActor: { type: "human", id: "profile-ada", label: "Ada" },
|
||||
},
|
||||
}).createdBy,
|
||||
).toEqual({ id: "profile-ada", label: "Ada" });
|
||||
}).createdActor,
|
||||
).toEqual({ type: "human", id: "profile-ada", label: "Ada" });
|
||||
|
||||
expect(
|
||||
buildGatewaySessionEventFields({
|
||||
sessionRow: { key: "agent:main:ownerless", kind: "direct", updatedAt: 2 },
|
||||
}).createdBy,
|
||||
}).createdActor,
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js";
|
||||
import type { GatewaySessionRow } from "./session-utils.js";
|
||||
|
||||
/**
|
||||
@@ -27,7 +28,7 @@ export function buildGatewaySessionEventFields(params: {
|
||||
return {
|
||||
updatedAt: sessionRow.updatedAt ?? undefined,
|
||||
sessionId: sessionRow.sessionId,
|
||||
createdBy: sessionRow.createdBy ?? null,
|
||||
createdActor: sessionRow.createdActor ?? null,
|
||||
kind: sessionRow.kind,
|
||||
channel: sessionRow.channel,
|
||||
subject: sessionRow.subject,
|
||||
@@ -46,13 +47,18 @@ export function buildGatewaySessionEventFields(params: {
|
||||
observerDigest: sessionRow.observerDigest ?? null,
|
||||
lastActivityAt: sessionRow.lastActivityAt,
|
||||
spawnedBy: sessionRow.spawnedBy,
|
||||
controlOwnerSessionKey: sessionRow.controlOwnerSessionKey ?? null,
|
||||
swarmGroupId: sessionRow.swarmGroupId,
|
||||
spawnedWorkspaceDir: sessionRow.spawnedWorkspaceDir,
|
||||
spawnedCwd: sessionRow.spawnedCwd,
|
||||
forkedFromParent: sessionRow.forkedFromParent,
|
||||
forkedFromParent: sessionEntryForkedFromParent(sessionRow) ? true : undefined,
|
||||
spawnDepth: sessionRow.spawnDepth,
|
||||
subagentRole: sessionRow.subagentRole,
|
||||
subagentControlScope: sessionRow.subagentControlScope,
|
||||
createdVia: sessionRow.createdVia,
|
||||
createdAt: sessionRow.createdAt,
|
||||
forkSource: sessionRow.forkSource,
|
||||
previousSessionId: sessionRow.previousSessionId,
|
||||
label: params.label ?? sessionRow.label ?? null,
|
||||
// Explicit null so subscribed clients drop a cleared category during merge-reconcile.
|
||||
category: sessionRow.category ?? null,
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
// Gateway session reset/delete service.
|
||||
// Rotates transcripts and coordinates lifecycle cleanup across runtimes/hooks.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
type SessionCreatorIdentity,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import { ErrorCodes, errorShape } from "../../packages/gateway-protocol/src/index.js";
|
||||
import { getAcpSessionManager } from "../acp/control-plane/manager.js";
|
||||
import { getAcpRuntimeBackend } from "../acp/runtime/registry.js";
|
||||
import {
|
||||
@@ -39,6 +35,12 @@ import {
|
||||
} from "../config/sessions.js";
|
||||
import { rebindCliSessionReseedReceiptsForReset } from "../config/sessions/cli-session-binding.js";
|
||||
import { resolveResetPreservedSelection } from "../config/sessions/reset-preserved-selection.js";
|
||||
import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js";
|
||||
import {
|
||||
buildSessionCreationStamp,
|
||||
type SessionCreatedActor,
|
||||
type SessionCreatedVia,
|
||||
} from "../config/sessions/session-entry-provenance.js";
|
||||
import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js";
|
||||
import type { SessionAcpMeta } from "../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
@@ -66,7 +68,10 @@ import {
|
||||
runExclusiveSessionLifecycleMutation,
|
||||
SESSION_WORK_ADMISSION_DRAIN_TIMEOUT_MS,
|
||||
} from "../sessions/session-lifecycle-admission.js";
|
||||
import { handleSessionStateSessionReset } from "../sessions/session-state-events.js";
|
||||
import {
|
||||
handleSessionStateSessionReset,
|
||||
recordSessionCreated,
|
||||
} from "../sessions/session-state-events.js";
|
||||
import {
|
||||
forgetActiveSessionForShutdown,
|
||||
listActiveSessionsForShutdown,
|
||||
@@ -912,7 +917,8 @@ export async function performGatewaySessionReset(params: {
|
||||
clearSpawnedCwd?: boolean;
|
||||
reason: "new" | "reset";
|
||||
commandSource: string;
|
||||
createdBy?: SessionCreatorIdentity;
|
||||
/** Trusted provenance for a reset that materializes a previously missing row. */
|
||||
creation?: { via: SessionCreatedVia; actor?: SessionCreatedActor };
|
||||
assertCurrent?: () => void;
|
||||
onCommitted?: (commit: { key: string; sessionId: string }) => void;
|
||||
}): Promise<
|
||||
@@ -1166,6 +1172,7 @@ export async function performGatewaySessionReset(params: {
|
||||
})
|
||||
: undefined;
|
||||
|
||||
let createdNewEntry = false;
|
||||
const lifecycle = await resetSessionEntryLifecycle({
|
||||
agentId: target.agentId,
|
||||
storePath,
|
||||
@@ -1174,6 +1181,7 @@ export async function performGatewaySessionReset(params: {
|
||||
storeKeys: target.storeKeys,
|
||||
},
|
||||
buildNextEntry: ({ currentEntry, primaryKey }) => {
|
||||
createdNewEntry = currentEntry === undefined;
|
||||
if (!isResetLifecycleCurrent() && currentEntry?.sessionId !== entry?.sessionId) {
|
||||
// A newer owner already replaced or removed the session while cleanup
|
||||
// targeted the old id. Preserve that newer state instead of resetting it.
|
||||
@@ -1193,9 +1201,17 @@ export async function performGatewaySessionReset(params: {
|
||||
sessionId: nextSessionId,
|
||||
storePath,
|
||||
});
|
||||
const creationStamp = currentEntry
|
||||
? {
|
||||
createdVia: currentEntry.createdVia,
|
||||
createdActor: currentEntry.createdActor,
|
||||
createdAt: currentEntry.createdAt,
|
||||
}
|
||||
: params.creation
|
||||
? buildSessionCreationStamp(params.creation)
|
||||
: {};
|
||||
const nextEntry: SessionEntry = {
|
||||
sessionId: nextSessionId,
|
||||
...(params.createdBy ? { createdBy: { ...params.createdBy } } : {}),
|
||||
sessionFile,
|
||||
updatedAt: now,
|
||||
systemSent: false,
|
||||
@@ -1249,7 +1265,10 @@ export async function performGatewaySessionReset(params: {
|
||||
? undefined
|
||||
: (params.worktree ?? currentEntry?.worktree),
|
||||
parentSessionKey: currentEntry?.parentSessionKey,
|
||||
forkedFromParent: currentEntry?.forkedFromParent,
|
||||
...creationStamp,
|
||||
forkSource: currentEntry?.forkSource,
|
||||
previousSessionId: currentEntry?.sessionId,
|
||||
forkedFromParent: sessionEntryForkedFromParent(currentEntry) ? true : undefined,
|
||||
spawnDepth: currentEntry?.spawnDepth,
|
||||
subagentRole: currentEntry?.subagentRole,
|
||||
subagentControlScope: currentEntry?.subagentControlScope,
|
||||
@@ -1295,6 +1314,13 @@ export async function performGatewaySessionReset(params: {
|
||||
return nextEntry;
|
||||
},
|
||||
afterEntryMutation: async (mutation) => {
|
||||
if (createdNewEntry) {
|
||||
recordSessionCreated({
|
||||
sessionKey: target.canonicalKey ?? params.key,
|
||||
agentId,
|
||||
entry: mutation.nextEntry,
|
||||
});
|
||||
}
|
||||
let committedAcpResetState: { sessionKey: string; meta: SessionAcpMeta } | undefined;
|
||||
if (deferredAcpResetState) {
|
||||
const identity = deferredAcpResetState.meta.identity;
|
||||
|
||||
@@ -1,17 +1,27 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
|
||||
const getUserProfileListItem = vi.hoisted(() =>
|
||||
vi.fn((profileId: string) => ({
|
||||
id: profileId,
|
||||
displayName: profileId === "profile-ada" ? "Ada" : "Bob",
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("../state/user-profiles.js", () => ({ getUserProfileListItem }));
|
||||
|
||||
import { listSessionsFromStore } from "./session-utils.js";
|
||||
|
||||
it("returns the complete deterministic creator facet independently of pagination", () => {
|
||||
const store: Record<string, SessionEntry> = {
|
||||
"agent:main:ada": {
|
||||
createdBy: { id: "profile-ada", label: "Ada" },
|
||||
createdActor: { type: "human", id: "profile-ada" },
|
||||
sessionId: "session-ada",
|
||||
updatedAt: 2,
|
||||
},
|
||||
"agent:main:bob": {
|
||||
createdBy: { id: "profile-bob", label: "Bob" },
|
||||
createdActor: { type: "human", id: "profile-bob" },
|
||||
sessionId: "session-bob",
|
||||
updatedAt: 1,
|
||||
},
|
||||
@@ -30,6 +40,12 @@ it("returns the complete deterministic creator facet independently of pagination
|
||||
{ id: "profile-ada", label: "Ada" },
|
||||
{ id: "profile-bob", label: "Bob" },
|
||||
]);
|
||||
expect(result.sessions[0]?.createdActor).toEqual({
|
||||
type: "human",
|
||||
id: "profile-ada",
|
||||
label: "Ada",
|
||||
});
|
||||
expect(getUserProfileListItem).toHaveBeenCalledTimes(2);
|
||||
|
||||
const filtered = listSessionsFromStore({
|
||||
cfg: {} as OpenClawConfig,
|
||||
|
||||
@@ -111,6 +111,59 @@ describe("listSessionsFromStore subagent metadata", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps persisted navigation lineage separate from live registry control", () => {
|
||||
const now = Date.now();
|
||||
const childSessionKey = "agent:main:subagent:controlled-child";
|
||||
const entry = {
|
||||
sessionId: "sess-controlled-child",
|
||||
updatedAt: now,
|
||||
spawnedBy: "agent:main:subagent:persisted-spawner",
|
||||
parentSessionKey: "agent:main:dashboard:navigation-parent",
|
||||
createdVia: "spawn",
|
||||
createdActor: { type: "agent", id: "agent:main:main" },
|
||||
createdAt: now - 10_000,
|
||||
forkSource: {
|
||||
sessionKey: "agent:main:main",
|
||||
sessionId: "sess-source",
|
||||
entryId: "entry-source",
|
||||
},
|
||||
previousSessionId: "sess-previous",
|
||||
} satisfies SessionEntry;
|
||||
|
||||
addSubagentRunForTests({
|
||||
runId: "run-controlled-child",
|
||||
childSessionKey,
|
||||
controllerSessionKey: "agent:main:subagent:runtime-controller",
|
||||
requesterSessionKey: "agent:main:main",
|
||||
requesterDisplayKey: "main",
|
||||
task: "controlled child",
|
||||
cleanup: "keep",
|
||||
createdAt: now - 5_000,
|
||||
startedAt: now - 4_000,
|
||||
});
|
||||
|
||||
const result = listSessionsFromStore({
|
||||
cfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store: { [childSessionKey]: entry },
|
||||
opts: {},
|
||||
});
|
||||
const row = expectDefined(result.sessions[0], "controlled child row");
|
||||
|
||||
expect(row.spawnedBy).toBe("agent:main:subagent:runtime-controller");
|
||||
expect(row.controlOwnerSessionKey).toBe("agent:main:subagent:runtime-controller");
|
||||
expect(row.parentSessionKey).toBe("agent:main:dashboard:navigation-parent");
|
||||
expect(row.createdVia).toBe("spawn");
|
||||
expect(row.createdActor).toEqual({ type: "agent", id: "agent:main:main" });
|
||||
expect(row.createdAt).toBe(now - 10_000);
|
||||
expect(row.forkSource).toEqual({
|
||||
sessionKey: "agent:main:main",
|
||||
sessionId: "sess-source",
|
||||
entryId: "entry-source",
|
||||
});
|
||||
expect(row.previousSessionId).toBe("sess-previous");
|
||||
});
|
||||
|
||||
test("includes subagent status timing and direct child session keys", () => {
|
||||
const now = Date.now();
|
||||
const store: Record<string, SessionEntry> = {
|
||||
@@ -576,7 +629,7 @@ describe("listSessionsFromStore subagent metadata", () => {
|
||||
expect(result.sessions[0]?.spawnedBy).toBe("agent:main:subagent:new-parent-owner");
|
||||
});
|
||||
|
||||
test("reports the newest parentSessionKey for moved child session rows", () => {
|
||||
test("keeps the persisted parentSessionKey while reporting the newest runtime controller", () => {
|
||||
const now = Date.now();
|
||||
const childSessionKey = "agent:main:subagent:shared-child-parent";
|
||||
const store: Record<string, SessionEntry> = {
|
||||
@@ -621,7 +674,11 @@ describe("listSessionsFromStore subagent metadata", () => {
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]?.key).toBe(childSessionKey);
|
||||
expect(result.sessions[0]?.parentSessionKey).toBe("agent:main:subagent:new-parent-parent");
|
||||
expect(result.sessions[0]?.parentSessionKey).toBe("agent:main:subagent:old-parent-parent");
|
||||
expect(result.sessions[0]?.spawnedBy).toBe("agent:main:subagent:new-parent-parent");
|
||||
expect(result.sessions[0]?.controlOwnerSessionKey).toBe(
|
||||
"agent:main:subagent:new-parent-parent",
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves original session timing across follow-up replacement runs", () => {
|
||||
|
||||
@@ -227,6 +227,18 @@ describe("gateway session utils", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("emits a tombstone when a session has no current control owner", () => {
|
||||
const row = buildGatewaySessionRow({
|
||||
cfg: createModelDefaultsConfig({ primary: "openai/gpt-5.4" }),
|
||||
storePath: "",
|
||||
store: {},
|
||||
key: "agent:main:child-without-owner",
|
||||
entry: {} as SessionEntry,
|
||||
});
|
||||
|
||||
expect(buildGatewaySessionEventFields({ sessionRow: row }).controlOwnerSessionKey).toBeNull();
|
||||
});
|
||||
|
||||
test("projects only unexpired agent status", () => {
|
||||
const entry = {
|
||||
sessionId: "session",
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
|
||||
import type {
|
||||
SessionCreatorIdentity,
|
||||
SessionCreatedActor,
|
||||
SessionsListParams,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
@@ -96,6 +96,7 @@ import {
|
||||
listSessionEntries as listAccessorSessionEntries,
|
||||
listSessionEntriesReadOnly as listAccessorSessionEntriesReadOnly,
|
||||
} from "../config/sessions/session-accessor.js";
|
||||
import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { projectPluginSessionExtensionsSync } from "../plugins/host-hook-state.js";
|
||||
import { withPinnedActivePluginRegistryWorkspaceDir } from "../plugins/runtime-workspace-state.js";
|
||||
@@ -107,6 +108,7 @@ import {
|
||||
import { resolveActiveSessionAgentStatus } from "../sessions/session-agent-status.js";
|
||||
import { isAcpSessionKey, isCronRunSessionKey } from "../sessions/session-key-utils.js";
|
||||
import { resolveNonNegativeNumber } from "../shared/number-coercion.js";
|
||||
import { getUserProfileListItem } from "../state/user-profiles.js";
|
||||
import { truncateUtf16Safe } from "../utils.js";
|
||||
import { normalizeSessionDeliveryFields } from "../utils/delivery-context.shared.js";
|
||||
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel-constants.js";
|
||||
@@ -408,6 +410,7 @@ type SessionListRowContext = {
|
||||
>;
|
||||
displayModelIdentityByKey: Map<string, { provider?: string; model?: string }>;
|
||||
modelCostConfigByModelRef: Map<string, ModelCostConfig | undefined>;
|
||||
userProfileLabelById: Map<string, string | undefined>;
|
||||
};
|
||||
|
||||
type SessionListRowContextProvider = () => SessionListRowContext;
|
||||
@@ -661,6 +664,7 @@ function buildSessionListRowContextFromParts(params: {
|
||||
thinkingMetadataByModelRef: new Map(),
|
||||
displayModelIdentityByKey: new Map(),
|
||||
modelCostConfigByModelRef: new Map(),
|
||||
userProfileLabelById: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1919,6 +1923,31 @@ export function resolveSessionDisplayModelIdentityRef(params: {
|
||||
};
|
||||
}
|
||||
|
||||
/** Adds the current human profile label without persisting rename-prone display data. */
|
||||
function projectSessionCreatedActor(
|
||||
actor: SessionEntry["createdActor"],
|
||||
userProfileLabelById: Map<string, string | undefined> = new Map(),
|
||||
): SessionCreatedActor | undefined {
|
||||
if (!actor) {
|
||||
return undefined;
|
||||
}
|
||||
const id = normalizeOptionalString(actor.id);
|
||||
if (actor.type !== "human" || !id) {
|
||||
return { type: actor.type, ...(id ? { id } : {}) };
|
||||
}
|
||||
let label = userProfileLabelById.get(id);
|
||||
if (!userProfileLabelById.has(id)) {
|
||||
try {
|
||||
label = normalizeOptionalString(getUserProfileListItem(id).displayName);
|
||||
} catch {
|
||||
// Human actors can also be channel sender ids; only profile ids resolve here.
|
||||
label = undefined;
|
||||
}
|
||||
userProfileLabelById.set(id, label);
|
||||
}
|
||||
return { type: actor.type, id, ...(label ? { label } : {}) };
|
||||
}
|
||||
|
||||
export function buildGatewaySessionRow(params: {
|
||||
cfg: OpenClawConfig;
|
||||
storePath: string;
|
||||
@@ -2225,18 +2254,24 @@ export function buildGatewaySessionRow(params: {
|
||||
|
||||
return {
|
||||
key,
|
||||
createdBy: entry?.createdBy,
|
||||
spawnedBy: subagentOwner || entry?.spawnedBy,
|
||||
// The live registry controller takes precedence over the persisted spawner.
|
||||
controlOwnerSessionKey: subagentOwner || entry?.spawnedBy,
|
||||
swarmGroupId: entry?.swarmGroupId,
|
||||
spawnedWorkspaceDir: entry?.spawnedWorkspaceDir,
|
||||
spawnedCwd: entry?.spawnedCwd,
|
||||
worktree: entry?.worktree,
|
||||
execNode: entry?.execNode,
|
||||
execCwd: entry?.execCwd,
|
||||
forkedFromParent: entry?.forkedFromParent,
|
||||
forkedFromParent: sessionEntryForkedFromParent(entry) ? true : undefined,
|
||||
spawnDepth: entry?.spawnDepth,
|
||||
subagentRole: entry?.subagentRole,
|
||||
subagentControlScope: entry?.subagentControlScope,
|
||||
createdVia: entry?.createdVia,
|
||||
createdActor: projectSessionCreatedActor(entry?.createdActor, rowContext?.userProfileLabelById),
|
||||
createdAt: entry?.createdAt,
|
||||
forkSource: entry?.forkSource,
|
||||
previousSessionId: entry?.previousSessionId,
|
||||
kind: classifySessionKey(key, entry),
|
||||
label: entry?.label,
|
||||
category: entry?.category,
|
||||
@@ -2299,7 +2334,8 @@ export function buildGatewaySessionRow(params: {
|
||||
startedAt: subagentRun ? subagentStartedAt : entry?.startedAt,
|
||||
endedAt: subagentRun ? subagentEndedAt : entry?.endedAt,
|
||||
runtimeMs: subagentRun ? subagentRuntimeMs : entry?.runtimeMs,
|
||||
parentSessionKey: subagentOwner || entry?.parentSessionKey,
|
||||
// Navigation lineage is persisted; runtime control is exposed separately above.
|
||||
parentSessionKey: entry?.parentSessionKey,
|
||||
childSessions,
|
||||
responseUsage: entry?.responseUsage,
|
||||
effectiveResponseUsage: resolveEffectiveResponseUsage(
|
||||
@@ -2720,7 +2756,7 @@ function selectSessionEntries(params: {
|
||||
const creatorEntries = filterSessionEntries(params);
|
||||
const creatorId = normalizeOptionalString(params.opts.creatorId);
|
||||
const filtered = creatorId
|
||||
? creatorEntries.filter(([, entry]) => entry.createdBy?.id === creatorId)
|
||||
? creatorEntries.filter(([, entry]) => entry.createdActor?.id === creatorId)
|
||||
: creatorEntries;
|
||||
const limit = resolveSessionsListLimit(params.opts, params.defaultLimit);
|
||||
const offset = resolveSessionsListOffset(params.opts);
|
||||
@@ -2743,14 +2779,16 @@ function selectSessionEntries(params: {
|
||||
|
||||
function listSessionCreatorIdentities(
|
||||
entries: readonly SessionEntryPair[],
|
||||
): SessionCreatorIdentity[] {
|
||||
const creators = new Map<string, SessionCreatorIdentity>();
|
||||
userProfileLabelById: Map<string, string | undefined>,
|
||||
): Array<{ id: string; label?: string }> {
|
||||
const creators = new Map<string, { id: string; label?: string }>();
|
||||
for (const [, entry] of entries) {
|
||||
const id = normalizeOptionalString(entry.createdBy?.id);
|
||||
const actor = projectSessionCreatedActor(entry.createdActor, userProfileLabelById);
|
||||
const id = normalizeOptionalString(actor?.id);
|
||||
if (!id) {
|
||||
continue;
|
||||
}
|
||||
const label = normalizeOptionalString(entry.createdBy?.label);
|
||||
const label = normalizeOptionalString(actor?.label);
|
||||
const existing = creators.get(id);
|
||||
if (!existing || (label && (!existing.label || label.localeCompare(existing.label) < 0))) {
|
||||
creators.set(id, { id, ...(label ? { label } : {}) });
|
||||
@@ -2849,7 +2887,10 @@ export function listSessionsFromStore(params: {
|
||||
offset: offset > 0 ? offset : undefined,
|
||||
nextOffset,
|
||||
hasMore,
|
||||
creators: listSessionCreatorIdentities(creatorEntries),
|
||||
creators: listSessionCreatorIdentities(
|
||||
creatorEntries,
|
||||
sharedRowContext?.userProfileLabelById ?? new Map(),
|
||||
),
|
||||
defaults: getSessionDefaults(cfg, params.modelCatalog, { allowPluginNormalization: false }),
|
||||
sessions,
|
||||
};
|
||||
@@ -2902,7 +2943,8 @@ export async function listSessionsFromStoreAsync(params: {
|
||||
: undefined,
|
||||
defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT,
|
||||
});
|
||||
const { entries, totalCount, limitApplied, offset, nextOffset, hasMore } = selection;
|
||||
const { entries, creatorEntries, totalCount, limitApplied, offset, nextOffset, hasMore } =
|
||||
selection;
|
||||
const fullRowContext =
|
||||
rowContext || hasSpawnedByFilter || entries.length > SESSIONS_LIST_YIELD_BATCH_SIZE
|
||||
? getRowContext()
|
||||
@@ -2981,6 +3023,10 @@ export async function listSessionsFromStoreAsync(params: {
|
||||
offset: offset > 0 ? offset : undefined,
|
||||
nextOffset,
|
||||
hasMore,
|
||||
creators: listSessionCreatorIdentities(
|
||||
creatorEntries,
|
||||
sharedRowContext?.userProfileLabelById ?? new Map(),
|
||||
),
|
||||
defaults: getSessionDefaults(cfg, params.modelCatalog, { allowPluginNormalization: false }),
|
||||
sessions,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// Shared Gateway session projection types.
|
||||
// Keeps server methods and Control UI payloads aligned.
|
||||
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { SessionPlacement } from "../../packages/gateway-protocol/src/index.js";
|
||||
import type { SessionCreatorIdentity } from "../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type {
|
||||
SessionCreatedActor,
|
||||
SessionPlacement,
|
||||
SessionRow,
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import type { SessionObserverDigest } from "../../packages/gateway-protocol/src/schema/sessions.js";
|
||||
import type { QueueMode } from "../auto-reply/reply/queue/types.js";
|
||||
import type { ChatType } from "../channels/chat-type.js";
|
||||
@@ -46,8 +49,9 @@ type SessionCompactionCheckpointPreview = Pick<
|
||||
|
||||
export type GatewaySessionRow = {
|
||||
key: string;
|
||||
createdBy?: SessionCreatorIdentity;
|
||||
spawnedBy?: string;
|
||||
/** Current runtime controller, falling back to the durable spawning session. */
|
||||
controlOwnerSessionKey?: string;
|
||||
/** Collector swarm group that owns this child session, when applicable. */
|
||||
swarmGroupId?: string;
|
||||
spawnedWorkspaceDir?: string;
|
||||
@@ -62,6 +66,11 @@ export type GatewaySessionRow = {
|
||||
spawnDepth?: number;
|
||||
subagentRole?: SessionEntry["subagentRole"];
|
||||
subagentControlScope?: SessionEntry["subagentControlScope"];
|
||||
createdVia?: SessionEntry["createdVia"];
|
||||
createdActor?: SessionCreatedActor;
|
||||
createdAt?: SessionEntry["createdAt"];
|
||||
forkSource?: SessionEntry["forkSource"];
|
||||
previousSessionId?: SessionEntry["previousSessionId"];
|
||||
kind: "direct" | "group" | "global" | "unknown";
|
||||
label?: string;
|
||||
/** User-defined organization bucket; unrelated to chat-group kind/groupChannel. */
|
||||
@@ -151,6 +160,16 @@ export type GatewaySessionRow = {
|
||||
pluginExtensions?: PluginSessionExtensionProjection[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Compile-time drift guard: fails typecheck when the Gateway projection stops
|
||||
* matching the protocol schema's documented row fields. Value-level so the
|
||||
* unused-export scan sees a consumer.
|
||||
*/
|
||||
const sessionRowSchemaDriftGuard: Pick<GatewaySessionRow, keyof SessionRow> extends SessionRow
|
||||
? true
|
||||
: false = true;
|
||||
void sessionRowSchemaDriftGuard;
|
||||
|
||||
export type GatewayAgentRow = SharedGatewayAgentRow;
|
||||
|
||||
export type SessionPreviewItem = {
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
import type { SessionsPatchParams } from "../../packages/gateway-protocol/src/index.js";
|
||||
import {
|
||||
normalizeInheritedToolAllowlist,
|
||||
normalizeInheritedToolDenylist,
|
||||
} from "../agents/inherited-tool-deny.js";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { isAcpSessionKey, isSubagentSessionKey } from "../routing/session-key.js";
|
||||
|
||||
function supportsSpawnPolicy(storeKey: string): boolean {
|
||||
return isSubagentSessionKey(storeKey) || isAcpSessionKey(storeKey);
|
||||
}
|
||||
|
||||
function unsupportedField(field: string, storeKey: string): string | undefined {
|
||||
return supportsSpawnPolicy(storeKey)
|
||||
? undefined
|
||||
: `${field} is only supported for subagent:* or acp:* sessions`;
|
||||
}
|
||||
|
||||
/** Applies the remaining public child-policy fields after lineage became creation-only. */
|
||||
export function applySessionsPatchSubagentPolicy(params: {
|
||||
existing?: SessionEntry;
|
||||
next: SessionEntry;
|
||||
patch: SessionsPatchParams;
|
||||
storeKey: string;
|
||||
}): string | undefined {
|
||||
const { existing, next, patch, storeKey } = params;
|
||||
if ("completionOwnerSessionKey" in patch) {
|
||||
const raw = patch.completionOwnerSessionKey;
|
||||
if (raw === null && existing?.completionOwnerSessionKey) {
|
||||
return "completionOwnerSessionKey cannot be cleared once set";
|
||||
}
|
||||
if (raw !== null && raw !== undefined) {
|
||||
const unsupported = unsupportedField("completionOwnerSessionKey", storeKey);
|
||||
if (unsupported) {
|
||||
return unsupported;
|
||||
}
|
||||
const normalized = normalizeOptionalString(raw);
|
||||
if (!normalized) {
|
||||
return "invalid completionOwnerSessionKey: empty";
|
||||
}
|
||||
if (
|
||||
existing?.completionOwnerSessionKey &&
|
||||
existing.completionOwnerSessionKey !== normalized
|
||||
) {
|
||||
return "completionOwnerSessionKey cannot be changed once set";
|
||||
}
|
||||
next.completionOwnerSessionKey = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
if ("inheritedToolPolicyVersion" in patch) {
|
||||
const raw = patch.inheritedToolPolicyVersion;
|
||||
if (raw === null && existing?.inheritedToolPolicyVersion !== undefined) {
|
||||
return "inheritedToolPolicyVersion cannot be cleared once set";
|
||||
}
|
||||
if (raw !== null && raw !== undefined) {
|
||||
const unsupported = unsupportedField("inheritedToolPolicyVersion", storeKey);
|
||||
if (unsupported) {
|
||||
return unsupported;
|
||||
}
|
||||
if (raw !== 1) {
|
||||
return "invalid inheritedToolPolicyVersion (expected 1)";
|
||||
}
|
||||
next.inheritedToolPolicyVersion = 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of ["inheritedToolDeny", "inheritedToolAllow"] as const) {
|
||||
if (!(field in patch)) {
|
||||
continue;
|
||||
}
|
||||
const raw = patch[field];
|
||||
if (raw === null) {
|
||||
delete next[field];
|
||||
continue;
|
||||
}
|
||||
if (raw === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (!Array.isArray(raw)) {
|
||||
return `invalid ${field} (use an array of tool names)`;
|
||||
}
|
||||
const unsupported = unsupportedField(field, storeKey);
|
||||
if (unsupported) {
|
||||
return unsupported;
|
||||
}
|
||||
const normalized =
|
||||
field === "inheritedToolDeny"
|
||||
? normalizeInheritedToolDenylist(raw)
|
||||
: normalizeInheritedToolAllowlist(raw);
|
||||
if (normalized.length > 0) {
|
||||
next[field] = normalized;
|
||||
} else {
|
||||
delete next[field];
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1012,16 +1012,6 @@ describe("gateway sessions patch", () => {
|
||||
expect(entry.modelOverrideSource).toBe("user");
|
||||
});
|
||||
|
||||
test("sets spawnDepth for subagent sessions", async () => {
|
||||
const entry = expectPatchOk(
|
||||
await runPatch({
|
||||
storeKey: "agent:main:subagent:child",
|
||||
patch: { key: "agent:main:subagent:child", spawnDepth: 2 },
|
||||
}),
|
||||
);
|
||||
expect(entry.spawnDepth).toBe(2);
|
||||
});
|
||||
|
||||
test("validates thinking patches with live catalog reasoning metadata", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.providers.push({
|
||||
@@ -1279,19 +1269,6 @@ describe("gateway sessions patch", () => {
|
||||
expect(entry).toMatchObject({ label: "new label", thinkingLevel: "max" });
|
||||
});
|
||||
|
||||
test("sets spawnedBy for ACP sessions", async () => {
|
||||
const entry = expectPatchOk(
|
||||
await runPatch({
|
||||
storeKey: "agent:main:acp:child",
|
||||
patch: {
|
||||
key: "agent:main:acp:child",
|
||||
spawnedBy: "agent:main:main",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(entry.spawnedBy).toBe("agent:main:main");
|
||||
});
|
||||
|
||||
test("sets an immutable completion owner for ACP sessions", async () => {
|
||||
const entry = expectPatchOk(
|
||||
await runPatch({
|
||||
@@ -1315,29 +1292,6 @@ describe("gateway sessions patch", () => {
|
||||
expectPatchError(result, "completionOwnerSessionKey cannot be changed once set");
|
||||
});
|
||||
|
||||
test("sets spawnedWorkspaceDir for subagent sessions", async () => {
|
||||
const entry = expectPatchOk(
|
||||
await runPatch({
|
||||
storeKey: "agent:main:subagent:child",
|
||||
patch: {
|
||||
key: "agent:main:subagent:child",
|
||||
spawnedWorkspaceDir: "/tmp/subagent-workspace",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(entry.spawnedWorkspaceDir).toBe("/tmp/subagent-workspace");
|
||||
});
|
||||
|
||||
test("sets spawnDepth for ACP sessions", async () => {
|
||||
const entry = expectPatchOk(
|
||||
await runPatch({
|
||||
storeKey: "agent:main:acp:child",
|
||||
patch: { key: "agent:main:acp:child", spawnDepth: 2 },
|
||||
}),
|
||||
);
|
||||
expect(entry.spawnDepth).toBe(2);
|
||||
});
|
||||
|
||||
test("sets an immutable requester policy snapshot version for ACP sessions", async () => {
|
||||
const entry = expectPatchOk(
|
||||
await runPatch({
|
||||
@@ -1393,20 +1347,6 @@ describe("gateway sessions patch", () => {
|
||||
expect(entry.inheritedToolDeny?.at(-1)).toBe("exec");
|
||||
});
|
||||
|
||||
test("rejects spawnDepth on non-subagent sessions", async () => {
|
||||
const result = await runPatch({
|
||||
patch: { key: MAIN_SESSION_KEY, spawnDepth: 1 },
|
||||
});
|
||||
expectPatchError(result, "spawnDepth is only supported");
|
||||
});
|
||||
|
||||
test("rejects spawnedWorkspaceDir on non-subagent sessions", async () => {
|
||||
const result = await runPatch({
|
||||
patch: { key: MAIN_SESSION_KEY, spawnedWorkspaceDir: "/tmp/nope" },
|
||||
});
|
||||
expectPatchError(result, "spawnedWorkspaceDir is only supported");
|
||||
});
|
||||
|
||||
test("rejects inheritedToolDeny on non-subagent sessions", async () => {
|
||||
const result = await runPatch({
|
||||
patch: { key: MAIN_SESSION_KEY, inheritedToolDeny: ["exec"] },
|
||||
|
||||
@@ -13,10 +13,6 @@ import {
|
||||
} from "../../packages/gateway-protocol/src/index.js";
|
||||
import { readAcpSessionMetaForEntry } from "../acp/runtime/session-meta.js";
|
||||
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import {
|
||||
normalizeInheritedToolAllowlist,
|
||||
normalizeInheritedToolDenylist,
|
||||
} from "../agents/inherited-tool-deny.js";
|
||||
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
|
||||
import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js";
|
||||
import {
|
||||
@@ -40,7 +36,6 @@ import type { SessionEntry } from "../config/sessions.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { normalizeExecTarget } from "../infra/exec-approvals.js";
|
||||
import {
|
||||
isAcpSessionKey,
|
||||
isSubagentSessionKey,
|
||||
normalizeAgentId,
|
||||
parseAgentSessionKey,
|
||||
@@ -74,6 +69,7 @@ import {
|
||||
shouldPreserveSessionAuthProfileOverride,
|
||||
snapshotAgentModelFallback,
|
||||
} from "./session-model-patch-origin.js";
|
||||
import { applySessionsPatchSubagentPolicy } from "./sessions-patch-subagent-policy.js";
|
||||
|
||||
function invalid(message: string): { ok: false; error: ErrorShape } {
|
||||
return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message) };
|
||||
@@ -127,26 +123,6 @@ function normalizeExecAsk(raw: string): "off" | "on-miss" | "always" | undefined
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function supportsSpawnLineage(storeKey: string): boolean {
|
||||
return isSubagentSessionKey(storeKey) || isAcpSessionKey(storeKey);
|
||||
}
|
||||
|
||||
function normalizeSubagentRole(raw: string): "orchestrator" | "leaf" | undefined {
|
||||
const normalized = normalizeOptionalLowercaseString(raw);
|
||||
if (normalized === "orchestrator" || normalized === "leaf") {
|
||||
return normalized;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeSubagentControlScope(raw: string): "children" | "none" | undefined {
|
||||
const normalized = normalizeOptionalLowercaseString(raw);
|
||||
if (normalized === "children" || normalized === "none") {
|
||||
return normalized;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
type SessionPatchProjectionEntry = {
|
||||
entry: SessionEntry;
|
||||
sessionKey: string;
|
||||
@@ -238,185 +214,14 @@ export async function projectSessionsPatchEntry(params: {
|
||||
delete next.displayName;
|
||||
}
|
||||
|
||||
type PatchError = ReturnType<typeof invalid> | null;
|
||||
const checkSpawnLineage = (field: string): PatchError =>
|
||||
supportsSpawnLineage(storeKey)
|
||||
? null
|
||||
: invalid(`${field} is only supported for subagent:* or acp:* sessions`);
|
||||
const applyImmutableString = (
|
||||
field: "spawnedBy" | "completionOwnerSessionKey" | "spawnedWorkspaceDir" | "spawnedCwd",
|
||||
checkLineageBeforeEmpty: boolean,
|
||||
): PatchError => {
|
||||
if (!(field in patch)) {
|
||||
return null;
|
||||
}
|
||||
const raw = patch[field];
|
||||
if (raw === null) {
|
||||
return existing?.[field] ? invalid(`${field} cannot be cleared once set`) : null;
|
||||
}
|
||||
if (raw === undefined) {
|
||||
return null;
|
||||
}
|
||||
const earlyLineage = checkLineageBeforeEmpty ? checkSpawnLineage(field) : null;
|
||||
if (earlyLineage) {
|
||||
return earlyLineage;
|
||||
}
|
||||
const trimmed = normalizeOptionalString(raw) ?? "";
|
||||
if (!trimmed) {
|
||||
return invalid(`invalid ${field}: empty`);
|
||||
}
|
||||
const lateLineage = checkLineageBeforeEmpty ? null : checkSpawnLineage(field);
|
||||
if (lateLineage) {
|
||||
return lateLineage;
|
||||
}
|
||||
if (existing?.[field] && existing[field] !== trimmed) {
|
||||
return invalid(`${field} cannot be changed once set`);
|
||||
}
|
||||
next[field] = trimmed;
|
||||
return null;
|
||||
};
|
||||
const applyImmutableNormalized = <T extends "subagentRole" | "subagentControlScope">(
|
||||
field: T,
|
||||
normalize: (raw: string) => NonNullable<SessionEntry[T]> | undefined,
|
||||
invalidMessage: string,
|
||||
): PatchError => {
|
||||
if (!(field in patch)) {
|
||||
return null;
|
||||
}
|
||||
const raw = patch[field];
|
||||
if (raw === null) {
|
||||
return existing?.[field] ? invalid(`${field} cannot be cleared once set`) : null;
|
||||
}
|
||||
if (raw === undefined) {
|
||||
return null;
|
||||
}
|
||||
const lineage = checkSpawnLineage(field);
|
||||
if (lineage) {
|
||||
return lineage;
|
||||
}
|
||||
const normalized = normalize(raw);
|
||||
if (!normalized) {
|
||||
return invalid(invalidMessage);
|
||||
}
|
||||
if (existing?.[field] && existing[field] !== normalized) {
|
||||
return invalid(`${field} cannot be changed once set`);
|
||||
}
|
||||
next[field] = normalized;
|
||||
return null;
|
||||
};
|
||||
|
||||
for (const fieldParams of [
|
||||
{ field: "spawnedBy" as const, checkLineageBeforeEmpty: false },
|
||||
{ field: "completionOwnerSessionKey" as const, checkLineageBeforeEmpty: false },
|
||||
{ field: "spawnedWorkspaceDir" as const, checkLineageBeforeEmpty: true },
|
||||
{ field: "spawnedCwd" as const, checkLineageBeforeEmpty: true },
|
||||
]) {
|
||||
const result = applyImmutableString(fieldParams.field, fieldParams.checkLineageBeforeEmpty);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if ("spawnDepth" in patch) {
|
||||
const raw = patch.spawnDepth;
|
||||
if (raw === null) {
|
||||
if (typeof existing?.spawnDepth === "number") {
|
||||
return invalid("spawnDepth cannot be cleared once set");
|
||||
}
|
||||
} else if (raw !== undefined) {
|
||||
if (!supportsSpawnLineage(storeKey)) {
|
||||
return invalid("spawnDepth is only supported for subagent:* or acp:* sessions");
|
||||
}
|
||||
const numeric = raw;
|
||||
if (!Number.isInteger(numeric) || numeric < 0) {
|
||||
return invalid("invalid spawnDepth (use an integer >= 0)");
|
||||
}
|
||||
const normalized = numeric;
|
||||
if (typeof existing?.spawnDepth === "number" && existing.spawnDepth !== normalized) {
|
||||
return invalid("spawnDepth cannot be changed once set");
|
||||
}
|
||||
next.spawnDepth = normalized;
|
||||
}
|
||||
}
|
||||
|
||||
for (const fieldParams of [
|
||||
{
|
||||
field: "subagentRole" as const,
|
||||
normalize: normalizeSubagentRole,
|
||||
invalidMessage: 'invalid subagentRole (use "orchestrator" or "leaf")',
|
||||
},
|
||||
{
|
||||
field: "subagentControlScope" as const,
|
||||
normalize: normalizeSubagentControlScope,
|
||||
invalidMessage: 'invalid subagentControlScope (use "children" or "none")',
|
||||
},
|
||||
]) {
|
||||
const result = applyImmutableNormalized(
|
||||
fieldParams.field,
|
||||
fieldParams.normalize,
|
||||
fieldParams.invalidMessage,
|
||||
);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
if ("inheritedToolPolicyVersion" in patch) {
|
||||
const raw = patch.inheritedToolPolicyVersion;
|
||||
if (raw === null) {
|
||||
if (existing?.inheritedToolPolicyVersion !== undefined) {
|
||||
return invalid("inheritedToolPolicyVersion cannot be cleared once set");
|
||||
}
|
||||
} else if (raw !== undefined) {
|
||||
const lineage = checkSpawnLineage("inheritedToolPolicyVersion");
|
||||
if (lineage) {
|
||||
return lineage;
|
||||
}
|
||||
if (raw !== 1) {
|
||||
return invalid("invalid inheritedToolPolicyVersion (expected 1)");
|
||||
}
|
||||
next.inheritedToolPolicyVersion = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if ("inheritedToolDeny" in patch) {
|
||||
const raw = patch.inheritedToolDeny;
|
||||
if (raw === null) {
|
||||
delete next.inheritedToolDeny;
|
||||
} else if (raw !== undefined) {
|
||||
if (!Array.isArray(raw)) {
|
||||
return invalid("invalid inheritedToolDeny (use an array of tool names)");
|
||||
}
|
||||
if (!supportsSpawnLineage(storeKey)) {
|
||||
return invalid("inheritedToolDeny is only supported for subagent:* or acp:* sessions");
|
||||
}
|
||||
const inheritedToolDeny = normalizeInheritedToolDenylist(raw);
|
||||
if (inheritedToolDeny.length > 0) {
|
||||
next.inheritedToolDeny = inheritedToolDeny;
|
||||
} else {
|
||||
delete next.inheritedToolDeny;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ("inheritedToolAllow" in patch) {
|
||||
const raw = patch.inheritedToolAllow;
|
||||
if (raw === null) {
|
||||
delete next.inheritedToolAllow;
|
||||
} else if (raw !== undefined) {
|
||||
if (!Array.isArray(raw)) {
|
||||
return invalid("invalid inheritedToolAllow (use an array of tool names)");
|
||||
}
|
||||
if (!supportsSpawnLineage(storeKey)) {
|
||||
return invalid("inheritedToolAllow is only supported for subagent:* or acp:* sessions");
|
||||
}
|
||||
const inheritedToolAllow = normalizeInheritedToolAllowlist(raw);
|
||||
if (inheritedToolAllow.length > 0) {
|
||||
next.inheritedToolAllow = inheritedToolAllow;
|
||||
} else {
|
||||
delete next.inheritedToolAllow;
|
||||
}
|
||||
}
|
||||
const subagentPolicyError = applySessionsPatchSubagentPolicy({
|
||||
existing,
|
||||
next,
|
||||
patch,
|
||||
storeKey,
|
||||
});
|
||||
if (subagentPolicyError) {
|
||||
return invalid(subagentPolicyError);
|
||||
}
|
||||
|
||||
if ("label" in patch) {
|
||||
@@ -866,4 +671,3 @@ export async function applySessionsPatchToStore(params: {
|
||||
}
|
||||
return projected;
|
||||
}
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -145,6 +145,9 @@ describe("plugin runtime session creation", () => {
|
||||
},
|
||||
});
|
||||
expect(created.entry).toMatchObject({
|
||||
createdVia: "plugin",
|
||||
createdActor: { type: "system", id: "anthropic" },
|
||||
createdAt: expect.any(Number),
|
||||
pluginOwnerId: "anthropic",
|
||||
providerOverride: "claude-cli",
|
||||
modelOverride: "claude-opus-4-8",
|
||||
|
||||
@@ -303,6 +303,13 @@ async function createSessionEntry(
|
||||
},
|
||||
...(harnessInitial ? { authorizedAgentHarnessId: harnessInitial.agentHarnessId } : {}),
|
||||
...(cliInitial?.pluginOwnerId ? { authorizedPluginId: cliInitial.pluginOwnerId } : {}),
|
||||
creation: {
|
||||
via: "plugin",
|
||||
actor: {
|
||||
type: "system",
|
||||
...(cliInitial?.pluginOwnerId ? { id: cliInitial.pluginOwnerId } : {}),
|
||||
},
|
||||
},
|
||||
commandSource: "plugin-runtime",
|
||||
...(afterCreate ? { afterCreate: runAfterCreate } : {}),
|
||||
});
|
||||
|
||||
@@ -14,7 +14,6 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
|
||||
"pluginExtensionSlotKeys",
|
||||
"pluginNextTurnInjections",
|
||||
"sessionId",
|
||||
"createdBy",
|
||||
"lifecycleRevision",
|
||||
"updatedAt",
|
||||
"archivedAt",
|
||||
@@ -32,6 +31,11 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
|
||||
"spawnedCwd",
|
||||
"worktree",
|
||||
"parentSessionKey",
|
||||
"createdVia",
|
||||
"createdActor",
|
||||
"createdAt",
|
||||
"forkSource",
|
||||
"previousSessionId",
|
||||
"forkedFromParent",
|
||||
"spawnDepth",
|
||||
"swarmGroupId",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type SessionStateActorType = "human" | "agent" | "system";
|
||||
|
||||
export type SessionStateEventKind =
|
||||
| "created"
|
||||
| "human_direct_message"
|
||||
| "adopted"
|
||||
| "run_completed"
|
||||
@@ -12,6 +13,7 @@ export type SessionStateEventKind =
|
||||
|
||||
// Future utility-model materiality belongs at this deterministic seam; no config until then.
|
||||
export const NOTIFY_BY_SESSION_STATE_EVENT_KIND: Record<SessionStateEventKind, boolean> = {
|
||||
created: false,
|
||||
human_direct_message: true,
|
||||
upstream_missing: true,
|
||||
adopted: false,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user