mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 13:26:04 -06:00
feat(plugin-sdk): add session entry workflow helpers
Co-authored-by: Eduardo Piva <efpiva@gmail.com>
This commit is contained in:
@@ -7,6 +7,7 @@ Docs: https://docs.openclaw.ai
|
||||
### Changes
|
||||
|
||||
- Discord: allow configuring a bounded `agentComponents.ttlMs` callback registry lifetime for long-running component workflows, with per-account overrides and a 24-hour cap. (#84189) Thanks @100menotu001.
|
||||
- Plugin SDK: add row-level session workflow helpers and deprecate `loadSessionStore` so plugins can read and patch sessions without depending on the legacy whole-store shape. (#84693) Thanks @efpiva.
|
||||
- Gateway/plugins: reuse a compatible Gateway startup plugin registry during dispatch so safe plugin dispatches avoid redundant registry loading. (#84324) Thanks @ai-hpc.
|
||||
- Dependencies: refresh provider, plugin, UI, and tooling packages, update `protobufjs` to 8.4.0 to clear the current npm advisory, and carry the Claude ACP completion patch forward to `@agentclientprotocol/claude-agent-acp` 0.36.1.
|
||||
- Agents/tools: remove the old sender-owner tool gating path so configured tools stay visible for trusted sessions while command and channel-action auth still carry real sender identity.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
68bf012e03431fa1e29416489b829be3d2b6897f732d45de5b20fa30eb754119 plugin-sdk-api-baseline.json
|
||||
7c6aa6ff2b42935228cc5cba1c2dd963af7534ac37daec3a5a8d0b9b3ba1620d plugin-sdk-api-baseline.jsonl
|
||||
bb0da3ba4560521d2c9725cd96429f64ce8e6150972ba77be71fdf8ea03e0234 plugin-sdk-api-baseline.json
|
||||
4d951b989cc00a86f64907bb28d52a950a466116382c9877f24807b0fba3df44 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -153,16 +153,18 @@ two-party event loops that do not go through the shared channel-turn kernel.
|
||||
**Session store helpers** are under `api.runtime.agent.session`:
|
||||
|
||||
```typescript
|
||||
const storePath = api.runtime.agent.session.resolveStorePath(cfg);
|
||||
const store = api.runtime.agent.session.loadSessionStore(storePath);
|
||||
await api.runtime.agent.session.updateSessionStore(storePath, (nextStore) => {
|
||||
// Patch one entry without replacing the whole file from stale state.
|
||||
nextStore[sessionKey] = { ...nextStore[sessionKey], thinkingLevel: "high" };
|
||||
const entry = api.runtime.agent.session.getSessionEntry({ agentId, sessionKey });
|
||||
for (const { sessionKey, entry } of api.runtime.agent.session.listSessionEntries({ agentId })) {
|
||||
// Iterate session rows without depending on the legacy sessions.json shape.
|
||||
}
|
||||
await api.runtime.agent.session.patchSessionEntry({
|
||||
agentId,
|
||||
sessionKey,
|
||||
update: (entry) => ({ thinkingLevel: "high" }),
|
||||
});
|
||||
const filePath = api.runtime.agent.session.resolveSessionFilePath(cfg, sessionId);
|
||||
```
|
||||
|
||||
Prefer `updateSessionStore(...)` or `updateSessionStoreEntry(...)` for runtime writes. They route through the Gateway-owned session-store writer, preserve concurrent updates, and reuse the hot cache. `saveSessionStore(...)` remains available for compatibility and offline maintenance-style rewrites.
|
||||
Prefer `getSessionEntry(...)`, `listSessionEntries(...)`, `patchSessionEntry(...)`, or `upsertSessionEntry(...)` for session workflows. These helpers address sessions by agent/session identity so plugins do not depend on the legacy `sessions.json` storage shape. Use `preserveActivity: true` for metadata-only patches that should not refresh session activity, and `replaceEntry: true` only when the callback returns a complete entry and deleted fields must stay deleted. `loadSessionStore(...)` remains as a deprecated compatibility escape hatch for callers that intentionally need a mutable whole-store clone.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="api.runtime.agent.defaults">
|
||||
|
||||
@@ -248,7 +248,7 @@ focused channel/runtime subpaths, `config-contracts`, `string-coerce-runtime`,
|
||||
| `plugin-sdk/reply-history` | Shared short-window reply-history helpers. New message-turn code should use `createChannelHistoryWindow`; lower-level map helpers remain deprecated compatibility exports only |
|
||||
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
|
||||
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
|
||||
| `plugin-sdk/session-store-runtime` | Session store path, session-key, updated-at, and store mutation helpers |
|
||||
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and deprecated whole-store mutation helpers |
|
||||
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
|
||||
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |
|
||||
| `plugin-sdk/routing` | Route/session-key/account binding helpers such as `resolveAgentRoute`, `buildAgentSessionKey`, and `resolveDefaultAgentBoundAccountId` |
|
||||
|
||||
@@ -30,7 +30,10 @@ const hoisted = vi.hoisted(() => {
|
||||
closeActiveMemorySearchManager: vi.fn(async () => {}),
|
||||
sessionStore,
|
||||
updateSessionStore: vi.fn(
|
||||
async (_storePath: string, updater: (store: Record<string, unknown>) => void) => {
|
||||
async (
|
||||
_storePath: string,
|
||||
updater: (store: Record<string, Record<string, unknown>>) => void,
|
||||
) => {
|
||||
updater(sessionStore);
|
||||
},
|
||||
),
|
||||
@@ -113,6 +116,42 @@ describe("active-memory plugin", () => {
|
||||
resolveStorePath: vi.fn(() => "/tmp/openclaw-session-store.json"),
|
||||
loadSessionStore: vi.fn(() => hoisted.sessionStore),
|
||||
saveSessionStore: vi.fn(async () => {}),
|
||||
getSessionEntry: vi.fn(
|
||||
(params: { sessionKey: string }) => hoisted.sessionStore[params.sessionKey],
|
||||
),
|
||||
listSessionEntries: vi.fn(() =>
|
||||
Object.entries(hoisted.sessionStore).map(([sessionKey, entry]) => ({
|
||||
sessionKey,
|
||||
entry,
|
||||
})),
|
||||
),
|
||||
patchSessionEntry: vi.fn(
|
||||
async (params: {
|
||||
sessionKey: string;
|
||||
fallbackEntry?: Record<string, unknown>;
|
||||
update: (entry: Record<string, unknown>) => Record<string, unknown> | null;
|
||||
}) => {
|
||||
let result: Record<string, unknown> | null = null;
|
||||
await hoisted.updateSessionStore(
|
||||
"/tmp/openclaw-session-store.json",
|
||||
(store: Record<string, Record<string, unknown>>) => {
|
||||
const existing = store[params.sessionKey] ?? params.fallbackEntry;
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
const patch = params.update({ ...existing });
|
||||
if (!patch) {
|
||||
result = existing;
|
||||
return;
|
||||
}
|
||||
const next = { ...existing, ...patch };
|
||||
store[params.sessionKey] = next;
|
||||
result = next;
|
||||
},
|
||||
);
|
||||
return result;
|
||||
},
|
||||
),
|
||||
},
|
||||
},
|
||||
state: {
|
||||
@@ -2961,7 +3000,7 @@ describe("active-memory plugin", () => {
|
||||
|
||||
it("returns timeout within a hard deadline even when the subagent never checks the abort signal", async () => {
|
||||
const CONFIGURED_TIMEOUT_MS = 25;
|
||||
const HARD_DEADLINE_MARGIN_MS = 500;
|
||||
const HARD_DEADLINE_MARGIN_MS = 1_500;
|
||||
testing.setMinimumTimeoutMsForTests(1);
|
||||
testing.setSetupGraceTimeoutMsForTests(0);
|
||||
api.pluginConfig = {
|
||||
@@ -2999,6 +3038,7 @@ describe("active-memory plugin", () => {
|
||||
const CONFIGURED_TIMEOUT_MS = 50;
|
||||
testing.setMinimumTimeoutMsForTests(1);
|
||||
testing.setSetupGraceTimeoutMsForTests(0);
|
||||
testing.setTimeoutPartialDataGraceMsForTests(50);
|
||||
api.pluginConfig = {
|
||||
agents: ["main"],
|
||||
timeoutMs: CONFIGURED_TIMEOUT_MS,
|
||||
@@ -3142,6 +3182,10 @@ describe("active-memory plugin", () => {
|
||||
timeoutMs: 100,
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
hoisted.sessionStore["agent:main:memory-get-miss"] = {
|
||||
sessionId: "s-memory-get-miss",
|
||||
updatedAt: 0,
|
||||
};
|
||||
runEmbeddedPiAgent.mockImplementationOnce(async (params: { sessionFile: string }) => {
|
||||
await writeTranscriptJsonl(params.sessionFile, [
|
||||
{
|
||||
|
||||
@@ -20,10 +20,6 @@ import {
|
||||
import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { parseAgentSessionKey, parseThreadSessionSuffix } from "openclaw/plugin-sdk/routing";
|
||||
import { isPathInside, replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
|
||||
import {
|
||||
resolveSessionStoreEntry,
|
||||
updateSessionStore,
|
||||
} from "openclaw/plugin-sdk/session-store-runtime";
|
||||
import { tempWorkspace, resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 15_000;
|
||||
@@ -542,20 +538,15 @@ function resolveCanonicalSessionKeyFromSessionId(params: {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const storePath = params.api.runtime.agent.session.resolveStorePath(
|
||||
params.api.config.session?.store,
|
||||
{
|
||||
agentId: params.agentId,
|
||||
},
|
||||
);
|
||||
const store = params.api.runtime.agent.session.loadSessionStore(storePath, { clone: false });
|
||||
let bestMatch:
|
||||
| {
|
||||
sessionKey: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
| undefined;
|
||||
for (const [sessionKey, entry] of Object.entries(store)) {
|
||||
for (const { sessionKey, entry } of params.api.runtime.agent.session.listSessionEntries({
|
||||
agentId: params.agentId,
|
||||
})) {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
continue;
|
||||
}
|
||||
@@ -673,17 +664,10 @@ function resolveRecallRunChannelContext(params: {
|
||||
}
|
||||
|
||||
try {
|
||||
const storePath = params.api.runtime.agent.session.resolveStorePath(
|
||||
params.api.config.session?.store,
|
||||
{
|
||||
agentId: params.agentId,
|
||||
},
|
||||
);
|
||||
const store = params.api.runtime.agent.session.loadSessionStore(storePath, { clone: false });
|
||||
const sessionEntry = resolveSessionStoreEntry({
|
||||
store,
|
||||
const sessionEntry = params.api.runtime.agent.session.getSessionEntry({
|
||||
agentId: params.agentId,
|
||||
sessionKey: resolvedSessionKey,
|
||||
}).existing;
|
||||
});
|
||||
const rawStrongEntryChannel =
|
||||
normalizeOptionalString(sessionEntry?.lastChannel) ??
|
||||
normalizeOptionalString(sessionEntry?.channel);
|
||||
@@ -1594,13 +1578,11 @@ async function persistPluginStatusLines(params: {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const storePath = params.api.runtime.agent.session.resolveStorePath(
|
||||
params.api.config.session?.store,
|
||||
agentId ? { agentId } : undefined,
|
||||
);
|
||||
if (!params.statusLine && !debugLine) {
|
||||
const store = params.api.runtime.agent.session.loadSessionStore(storePath, { clone: false });
|
||||
const existingEntry = resolveSessionStoreEntry({ store, sessionKey }).existing;
|
||||
const existingEntry = params.api.runtime.agent.session.getSessionEntry({
|
||||
agentId,
|
||||
sessionKey,
|
||||
});
|
||||
const hasActiveMemoryEntry = Array.isArray(existingEntry?.pluginDebugEntries)
|
||||
? existingEntry.pluginDebugEntries.some((entry) => entry?.pluginId === "active-memory")
|
||||
: false;
|
||||
@@ -1608,39 +1590,38 @@ async function persistPluginStatusLines(params: {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
const resolved = resolveSessionStoreEntry({ store, sessionKey });
|
||||
const existing = resolved.existing;
|
||||
if (!existing) {
|
||||
return;
|
||||
}
|
||||
const previousEntries = Array.isArray(existing.pluginDebugEntries)
|
||||
? existing.pluginDebugEntries
|
||||
: [];
|
||||
const nextEntries = previousEntries.filter(
|
||||
(entry): entry is PluginDebugEntry =>
|
||||
Boolean(entry) &&
|
||||
typeof entry === "object" &&
|
||||
typeof entry.pluginId === "string" &&
|
||||
entry.pluginId !== "active-memory",
|
||||
);
|
||||
const nextLines: string[] = [];
|
||||
if (params.statusLine) {
|
||||
nextLines.push(params.statusLine);
|
||||
}
|
||||
if (debugLine) {
|
||||
nextLines.push(debugLine);
|
||||
}
|
||||
if (nextLines.length > 0) {
|
||||
nextEntries.push({
|
||||
pluginId: "active-memory",
|
||||
lines: nextLines,
|
||||
});
|
||||
}
|
||||
store[resolved.normalizedKey] = {
|
||||
...existing,
|
||||
pluginDebugEntries: nextEntries.length > 0 ? nextEntries : undefined,
|
||||
};
|
||||
await params.api.runtime.agent.session.patchSessionEntry({
|
||||
agentId,
|
||||
sessionKey,
|
||||
preserveActivity: true,
|
||||
update: (existing) => {
|
||||
const previousEntries = Array.isArray(existing.pluginDebugEntries)
|
||||
? existing.pluginDebugEntries
|
||||
: [];
|
||||
const nextEntries = previousEntries.filter(
|
||||
(entry): entry is PluginDebugEntry =>
|
||||
Boolean(entry) &&
|
||||
typeof entry === "object" &&
|
||||
typeof entry.pluginId === "string" &&
|
||||
entry.pluginId !== "active-memory",
|
||||
);
|
||||
const nextLines: string[] = [];
|
||||
if (params.statusLine) {
|
||||
nextLines.push(params.statusLine);
|
||||
}
|
||||
if (debugLine) {
|
||||
nextLines.push(debugLine);
|
||||
}
|
||||
if (nextLines.length > 0) {
|
||||
nextEntries.push({
|
||||
pluginId: "active-memory",
|
||||
lines: nextLines,
|
||||
});
|
||||
}
|
||||
return {
|
||||
pluginDebugEntries: nextEntries.length > 0 ? nextEntries : undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
params.api.logger.debug?.(
|
||||
|
||||
@@ -9,6 +9,10 @@ type TestSessionEntry = {
|
||||
providerOverride?: string;
|
||||
modelOverride?: string;
|
||||
modelOverrideSource?: string;
|
||||
model?: string;
|
||||
modelProvider?: string;
|
||||
contextTokens?: number;
|
||||
authProfileOverride?: string;
|
||||
};
|
||||
|
||||
type EmbeddedAgentArgs = {
|
||||
@@ -32,6 +36,34 @@ function createAgentRuntime(payloads: Array<Record<string, unknown>>) {
|
||||
return await mutator(sessionStore);
|
||||
},
|
||||
);
|
||||
const getSessionEntry = vi.fn(
|
||||
(params: { sessionKey: string }) => sessionStore[params.sessionKey],
|
||||
);
|
||||
const patchSessionEntry = vi.fn(
|
||||
async (params: {
|
||||
sessionKey: string;
|
||||
fallbackEntry?: TestSessionEntry;
|
||||
replaceEntry?: boolean;
|
||||
update: (entry: TestSessionEntry) => Partial<TestSessionEntry> | null;
|
||||
}) => {
|
||||
const existing = sessionStore[params.sessionKey] ?? params.fallbackEntry;
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
const patch = params.update({ ...existing });
|
||||
if (!patch) {
|
||||
return existing;
|
||||
}
|
||||
const next = params.replaceEntry ? (patch as TestSessionEntry) : { ...existing, ...patch };
|
||||
sessionStore[params.sessionKey] = next;
|
||||
return next;
|
||||
},
|
||||
);
|
||||
const upsertSessionEntry = vi.fn(
|
||||
async (params: { sessionKey: string; entry: TestSessionEntry }) => {
|
||||
sessionStore[params.sessionKey] = { ...params.entry };
|
||||
},
|
||||
);
|
||||
const runEmbeddedPiAgent = vi.fn(async () => ({
|
||||
payloads,
|
||||
meta: { durationMs: 12, aborted: false },
|
||||
@@ -71,6 +103,9 @@ function createAgentRuntime(payloads: Array<Record<string, unknown>>) {
|
||||
loadSessionStore: () => sessionStore,
|
||||
saveSessionStore,
|
||||
updateSessionStore,
|
||||
getSessionEntry,
|
||||
patchSessionEntry,
|
||||
upsertSessionEntry,
|
||||
resolveSessionFilePath,
|
||||
},
|
||||
} as unknown as CoreAgentDeps;
|
||||
@@ -80,6 +115,7 @@ function createAgentRuntime(payloads: Array<Record<string, unknown>>) {
|
||||
runEmbeddedPiAgent,
|
||||
saveSessionStore,
|
||||
updateSessionStore,
|
||||
patchSessionEntry,
|
||||
sessionStore,
|
||||
resolveAgentDir,
|
||||
resolveAgentWorkspaceDir,
|
||||
@@ -187,9 +223,17 @@ describe("generateVoiceResponse", () => {
|
||||
});
|
||||
|
||||
it("pins the voice session to responseModel before running the embedded agent", async () => {
|
||||
const { runtime, runEmbeddedPiAgent, updateSessionStore, sessionStore } = createAgentRuntime([
|
||||
const { runtime, runEmbeddedPiAgent, patchSessionEntry, sessionStore } = createAgentRuntime([
|
||||
{ text: '{"spoken":"Pinned model works."}' },
|
||||
]);
|
||||
sessionStore["voice:15550001111"] = {
|
||||
sessionId: "existing-session",
|
||||
updatedAt: 100,
|
||||
model: "old-model",
|
||||
modelProvider: "old-provider",
|
||||
contextTokens: 123,
|
||||
authProfileOverride: "old-auth-profile",
|
||||
};
|
||||
const voiceConfig = VoiceCallConfigSchema.parse({
|
||||
responseModel: "openai/gpt-4.1-nano",
|
||||
responseTimeoutMs: 5000,
|
||||
@@ -210,12 +254,20 @@ describe("generateVoiceResponse", () => {
|
||||
expect(pinnedSessionEntry?.providerOverride).toBe("openai");
|
||||
expect(pinnedSessionEntry?.modelOverride).toBe("gpt-4.1-nano");
|
||||
expect(pinnedSessionEntry?.modelOverrideSource).toBe("auto");
|
||||
const updateSessionStoreCall = requireFirstMockCall(
|
||||
updateSessionStore.mock.calls,
|
||||
"session store update",
|
||||
expect(pinnedSessionEntry?.model).toBeUndefined();
|
||||
expect(pinnedSessionEntry?.modelProvider).toBeUndefined();
|
||||
expect(pinnedSessionEntry?.contextTokens).toBeUndefined();
|
||||
expect(pinnedSessionEntry?.authProfileOverride).toBeUndefined();
|
||||
const patchSessionEntryCall = requireFirstMockCall(
|
||||
patchSessionEntry.mock.calls,
|
||||
"session entry patch",
|
||||
);
|
||||
expect(updateSessionStoreCall[0]).toBe("/tmp/openclaw/main/sessions.json");
|
||||
expect(updateSessionStoreCall[1]).toBeTypeOf("function");
|
||||
expect(patchSessionEntryCall[0]).toMatchObject({
|
||||
storePath: "/tmp/openclaw/main/sessions.json",
|
||||
sessionKey: "voice:15550001111",
|
||||
replaceEntry: true,
|
||||
});
|
||||
expect((patchSessionEntryCall[0] as { update?: unknown }).update).toBeTypeOf("function");
|
||||
const args = requireEmbeddedAgentArgs(runEmbeddedPiAgent);
|
||||
expect(args.provider).toBe("openai");
|
||||
expect(args.model).toBe("gpt-4.1-nano");
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/model-session-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { SessionEntry } from "../api.js";
|
||||
import { resolveVoiceCallSessionKey, type VoiceCallConfig } from "./config.js";
|
||||
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
|
||||
import { resolveVoiceResponseModel } from "./response-model.js";
|
||||
@@ -251,34 +250,47 @@ export async function generateVoiceResponse(
|
||||
await agentRuntime.ensureAgentWorkspace({ dir: workspaceDir });
|
||||
|
||||
// Load or create session entry
|
||||
const sessionStore = agentRuntime.session.loadSessionStore(storePath);
|
||||
const now = Date.now();
|
||||
const existingSessionEntry = sessionStore[resolvedSessionKey] as SessionEntry | undefined;
|
||||
const existingSessionEntry = agentRuntime.session.getSessionEntry({
|
||||
storePath,
|
||||
sessionKey: resolvedSessionKey,
|
||||
});
|
||||
|
||||
// Resolve model from config
|
||||
const { provider, model } = resolveVoiceResponseModel({ voiceConfig, agentRuntime });
|
||||
|
||||
let sessionEntry = existingSessionEntry;
|
||||
if (!sessionEntry?.sessionId || voiceConfig.responseModel) {
|
||||
sessionEntry = await agentRuntime.session.updateSessionStore(storePath, (store) => {
|
||||
let entry = store[resolvedSessionKey] as SessionEntry | undefined;
|
||||
if (!entry?.sessionId) {
|
||||
entry = {
|
||||
...entry,
|
||||
sessionEntry =
|
||||
(await agentRuntime.session.patchSessionEntry({
|
||||
storePath,
|
||||
sessionKey: resolvedSessionKey,
|
||||
replaceEntry: true,
|
||||
fallbackEntry: sessionEntry ?? {
|
||||
sessionId: crypto.randomUUID(),
|
||||
updatedAt: now,
|
||||
};
|
||||
store[resolvedSessionKey] = entry;
|
||||
}
|
||||
if (voiceConfig.responseModel) {
|
||||
applyModelOverrideToSessionEntry({
|
||||
entry,
|
||||
selection: { provider, model },
|
||||
selectionSource: "auto",
|
||||
});
|
||||
}
|
||||
return entry;
|
||||
});
|
||||
},
|
||||
update: (entry) => {
|
||||
const next = entry.sessionId
|
||||
? { ...entry }
|
||||
: {
|
||||
...entry,
|
||||
sessionId: crypto.randomUUID(),
|
||||
updatedAt: now,
|
||||
};
|
||||
if (voiceConfig.responseModel) {
|
||||
applyModelOverrideToSessionEntry({
|
||||
entry: next,
|
||||
selection: { provider, model },
|
||||
selectionSource: "auto",
|
||||
});
|
||||
}
|
||||
return next;
|
||||
},
|
||||
})) ?? undefined;
|
||||
}
|
||||
if (!sessionEntry?.sessionId) {
|
||||
return { text: null, error: "Voice response session could not be initialized" };
|
||||
}
|
||||
const sessionId = sessionEntry.sessionId;
|
||||
|
||||
|
||||
+15
-20
@@ -56,6 +56,7 @@ import {
|
||||
import { isStoredCredentialCompatibleWithAuthProvider } from "./auth-profiles/order.js";
|
||||
import { clearSessionAuthProfileOverride } from "./auth-profiles/session-override.js";
|
||||
import { ensureAuthProfileStore } from "./auth-profiles/store.js";
|
||||
import { createAgentAttemptLifecycleCallbacks } from "./command/attempt-callbacks.js";
|
||||
import {
|
||||
persistSessionEntry as persistSessionEntryBase,
|
||||
prependInternalEventContext,
|
||||
@@ -1083,7 +1084,11 @@ async function agentCommandInternal(
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
let lifecycleEnded = false;
|
||||
const attemptLifecycleState = {
|
||||
currentTurnUserMessagePersisted: false,
|
||||
lifecycleEnded: false,
|
||||
};
|
||||
const attemptLifecycleCallbacks = createAgentAttemptLifecycleCallbacks(attemptLifecycleState);
|
||||
const attemptExecutionRuntime = await loadAttemptExecutionRuntime();
|
||||
const runContext = resolveAgentRunContext(opts);
|
||||
const messageChannel = resolveMessageChannel(
|
||||
@@ -1123,7 +1128,7 @@ async function agentCommandInternal(
|
||||
});
|
||||
|
||||
let fallbackAttemptIndex = 0;
|
||||
let currentTurnUserMessagePersisted = false;
|
||||
attemptLifecycleState.currentTurnUserMessagePersisted = false;
|
||||
const fallbackResult = await runWithModelFallback<AgentAttemptResult>({
|
||||
cfg,
|
||||
provider,
|
||||
@@ -1216,19 +1221,9 @@ async function agentCommandInternal(
|
||||
!isNewSession || (await attemptExecutionRuntime.sessionFileHasContent(sessionFile)),
|
||||
suppressPromptPersistenceOnRetry:
|
||||
opts.suppressPromptPersistence === true ||
|
||||
(isFallbackRetry && currentTurnUserMessagePersisted),
|
||||
onUserMessagePersisted: () => {
|
||||
currentTurnUserMessagePersisted = true;
|
||||
},
|
||||
onAgentEvent: (evt) => {
|
||||
if (
|
||||
evt.stream === "lifecycle" &&
|
||||
typeof evt.data?.phase === "string" &&
|
||||
(evt.data.phase === "end" || evt.data.phase === "error")
|
||||
) {
|
||||
lifecycleEnded = true;
|
||||
}
|
||||
},
|
||||
(isFallbackRetry && attemptLifecycleState.currentTurnUserMessagePersisted),
|
||||
onUserMessagePersisted: attemptLifecycleCallbacks.onUserMessagePersisted,
|
||||
onAgentEvent: attemptLifecycleCallbacks.onAgentEvent,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -1290,7 +1285,7 @@ async function agentCommandInternal(
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!lifecycleEnded) {
|
||||
if (!attemptLifecycleState.lifecycleEnded) {
|
||||
const stopReason = result.meta.stopReason;
|
||||
if (stopReason && stopReason !== "end_turn") {
|
||||
console.error(`[agent] run ${runId} ended with stopReason=${stopReason}`);
|
||||
@@ -1315,7 +1310,7 @@ async function agentCommandInternal(
|
||||
log.error(
|
||||
`Live session model switch in subagent run ${runId}: exceeded maximum retries (${MAX_LIVE_SWITCH_RETRIES})`,
|
||||
);
|
||||
if (!lifecycleEnded) {
|
||||
if (!attemptLifecycleState.lifecycleEnded) {
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
@@ -1340,7 +1335,7 @@ async function agentCommandInternal(
|
||||
`Live session model switch in subagent run ${runId}: ` +
|
||||
`rejected ${sanitizeForLog(err.provider)}/${sanitizeForLog(err.model)} (not in allowlist)`,
|
||||
);
|
||||
if (!lifecycleEnded) {
|
||||
if (!attemptLifecycleState.lifecycleEnded) {
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
@@ -1384,13 +1379,13 @@ async function agentCommandInternal(
|
||||
storedModelOverride = err.model;
|
||||
storedModelOverrideSource = "user";
|
||||
}
|
||||
lifecycleEnded = false;
|
||||
attemptLifecycleState.lifecycleEnded = false;
|
||||
log.info(
|
||||
`Live session model switch in subagent run ${runId}: switching to ${sanitizeForLog(err.provider)}/${sanitizeForLog(err.model)}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!lifecycleEnded) {
|
||||
if (!attemptLifecycleState.lifecycleEnded) {
|
||||
emitAgentEvent({
|
||||
runId,
|
||||
stream: "lifecycle",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAgentAttemptLifecycleCallbacks } from "./attempt-callbacks.js";
|
||||
|
||||
describe("createAgentAttemptLifecycleCallbacks", () => {
|
||||
it("tracks user-message persistence without closing over the agent command scope", () => {
|
||||
const state = { currentTurnUserMessagePersisted: false, lifecycleEnded: false };
|
||||
const callbacks = createAgentAttemptLifecycleCallbacks(state);
|
||||
|
||||
callbacks.onUserMessagePersisted?.({
|
||||
role: "user",
|
||||
content: "hello",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
expect(state.currentTurnUserMessagePersisted).toBe(true);
|
||||
expect(state.lifecycleEnded).toBe(false);
|
||||
});
|
||||
|
||||
it("tracks terminal lifecycle phases", () => {
|
||||
const state = { currentTurnUserMessagePersisted: false, lifecycleEnded: false };
|
||||
const callbacks = createAgentAttemptLifecycleCallbacks(state);
|
||||
|
||||
callbacks.onAgentEvent({ stream: "lifecycle", data: { phase: "start" } });
|
||||
expect(state.lifecycleEnded).toBe(false);
|
||||
|
||||
callbacks.onAgentEvent({ stream: "lifecycle", data: { phase: "end" } });
|
||||
expect(state.lifecycleEnded).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
|
||||
export type AgentAttemptLifecycleState = {
|
||||
currentTurnUserMessagePersisted: boolean;
|
||||
lifecycleEnded: boolean;
|
||||
};
|
||||
|
||||
export type AgentAttemptLifecycleEvent = {
|
||||
stream: string;
|
||||
data?: Record<string, unknown>;
|
||||
sessionKey?: string;
|
||||
};
|
||||
|
||||
export function createAgentAttemptLifecycleCallbacks(state: AgentAttemptLifecycleState): {
|
||||
onUserMessagePersisted: (message: Extract<AgentMessage, { role: "user" }>) => void;
|
||||
onAgentEvent: (evt: AgentAttemptLifecycleEvent) => void;
|
||||
} {
|
||||
return {
|
||||
onUserMessagePersisted: () => {
|
||||
state.currentTurnUserMessagePersisted = true;
|
||||
},
|
||||
onAgentEvent: (evt) => {
|
||||
if (
|
||||
evt.stream === "lifecycle" &&
|
||||
typeof evt.data?.phase === "string" &&
|
||||
(evt.data.phase === "end" || evt.data.phase === "error")
|
||||
) {
|
||||
state.lifecycleEnded = true;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import {
|
||||
callGateway,
|
||||
getRuntimeConfig,
|
||||
loadSessionStore,
|
||||
readSessionEntry,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveStorePath,
|
||||
} from "./subagent-announce.runtime.js";
|
||||
@@ -573,7 +573,7 @@ export async function buildCompactAnnounceStatsLine(params: {
|
||||
const cfg = subagentAnnounceOutputDeps.getRuntimeConfig();
|
||||
const agentId = resolveAgentIdFromSessionKey(params.sessionKey);
|
||||
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||
let entry = loadSessionStore(storePath)[params.sessionKey];
|
||||
let entry = readSessionEntry(storePath, params.sessionKey);
|
||||
const tokenWaitAttempts = isFastTestMode() ? 1 : 3;
|
||||
for (let attempt = 0; attempt < tokenWaitAttempts; attempt += 1) {
|
||||
const hasTokenData =
|
||||
@@ -586,7 +586,7 @@ export async function buildCompactAnnounceStatsLine(params: {
|
||||
if (!isFastTestMode()) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
}
|
||||
entry = loadSessionStore(storePath)[params.sessionKey];
|
||||
entry = readSessionEntry(storePath, params.sessionKey);
|
||||
}
|
||||
|
||||
const input = typeof entry?.inputTokens === "number" ? entry.inputTokens : 0;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { getRuntimeConfig } from "../config/config.js";
|
||||
export {
|
||||
loadSessionStore,
|
||||
readSessionEntry,
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveStorePath,
|
||||
} from "../config/sessions.js";
|
||||
|
||||
@@ -60,6 +60,8 @@ vi.mock("./subagent-announce.runtime.js", () => ({
|
||||
isEmbeddedPiRunActive: (sessionId: string) => isEmbeddedPiRunActiveMock(sessionId),
|
||||
getRuntimeConfig: () => mockConfig,
|
||||
loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath),
|
||||
readSessionEntry: (storePath: string, sessionKey: string) =>
|
||||
(loadSessionStoreMock(storePath) as Record<string, unknown>)[sessionKey],
|
||||
resolveAgentIdFromSessionKey: (sessionKey: string) =>
|
||||
resolveAgentIdFromSessionKeyMock(sessionKey),
|
||||
resolveMainSessionKey: (cfg: unknown) => resolveMainSessionKeyMock(cfg),
|
||||
|
||||
@@ -191,6 +191,7 @@ vi.mock("./subagent-announce.runtime.js", () => ({
|
||||
},
|
||||
getRuntimeConfig: () => configOverride,
|
||||
loadSessionStore: vi.fn(() => sessionStore),
|
||||
readSessionEntry: (_storePath: string, sessionKey: string) => sessionStore[sessionKey],
|
||||
resolveAgentIdFromSessionKey: () => "main",
|
||||
resolveStorePath: () => "/tmp/sessions-main.json",
|
||||
resolveMainSessionKey: () => "agent:main:main",
|
||||
|
||||
@@ -191,6 +191,7 @@ describe("dispatchReplyFromConfig ACP abort", () => {
|
||||
internalHookMocks.triggerInternalHook.mockReset();
|
||||
sessionStoreMocks.currentEntry = undefined;
|
||||
sessionStoreMocks.loadSessionStore.mockReset().mockReturnValue({});
|
||||
sessionStoreMocks.readSessionEntry.mockReset().mockReturnValue(undefined);
|
||||
sessionStoreMocks.resolveStorePath.mockReset().mockReturnValue("/tmp/mock-sessions.json");
|
||||
sessionStoreMocks.resolveSessionStoreEntry.mockReset().mockReturnValue({ existing: undefined });
|
||||
acpMocks.listAcpSessionEntries.mockReset().mockResolvedValue([]);
|
||||
|
||||
@@ -83,6 +83,7 @@ describe("dispatchReplyFromConfig reply_dispatch hook", () => {
|
||||
sessionBindingMocks.touch.mockReset();
|
||||
sessionStoreMocks.currentEntry = undefined;
|
||||
sessionStoreMocks.loadSessionStore.mockReset().mockReturnValue({});
|
||||
sessionStoreMocks.readSessionEntry.mockReset().mockReturnValue(undefined);
|
||||
sessionStoreMocks.resolveStorePath.mockReset().mockReturnValue("/tmp/mock-sessions.json");
|
||||
sessionStoreMocks.resolveSessionStoreEntry.mockReset().mockReturnValue({ existing: undefined });
|
||||
sessionStoreMocks.updateSessionStoreEntry.mockClear();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export { resolveStorePath } from "../../config/sessions/paths.js";
|
||||
export {
|
||||
loadSessionStore,
|
||||
readSessionEntry,
|
||||
resolveSessionStoreEntry,
|
||||
updateSessionStoreEntry,
|
||||
} from "../../config/sessions/store.js";
|
||||
|
||||
@@ -92,6 +92,7 @@ const pluginConversationBindingMocks = vi.hoisted(() => ({
|
||||
const sessionStoreMocks = vi.hoisted(() => ({
|
||||
currentEntry: undefined as Record<string, unknown> | undefined,
|
||||
loadSessionStore: vi.fn(() => ({})),
|
||||
readSessionEntry: vi.fn(() => sessionStoreMocks.currentEntry),
|
||||
resolveStorePath: vi.fn(() => "/tmp/mock-sessions.json"),
|
||||
resolveSessionStoreEntry: vi.fn(() => ({ existing: sessionStoreMocks.currentEntry })),
|
||||
updateSessionStoreEntry: vi.fn(
|
||||
@@ -209,6 +210,7 @@ vi.mock("../../config/sessions/thread-info.js", () => ({
|
||||
vi.mock("./dispatch-from-config.runtime.js", () => ({
|
||||
createInternalHookEvent: internalHookMocks.createInternalHookEvent,
|
||||
loadSessionStore: sessionStoreMocks.loadSessionStore,
|
||||
readSessionEntry: sessionStoreMocks.readSessionEntry,
|
||||
resolveSessionStoreEntry: sessionStoreMocks.resolveSessionStoreEntry,
|
||||
resolveStorePath: sessionStoreMocks.resolveStorePath,
|
||||
triggerInternalHook: internalHookMocks.triggerInternalHook,
|
||||
|
||||
@@ -110,6 +110,7 @@ const pluginConversationBindingMocks = vi.hoisted(() => ({
|
||||
const sessionStoreMocks = vi.hoisted(() => ({
|
||||
currentEntry: undefined as Record<string, unknown> | undefined,
|
||||
loadSessionStore: vi.fn(() => ({})),
|
||||
readSessionEntry: vi.fn(() => sessionStoreMocks.currentEntry),
|
||||
resolveStorePath: vi.fn(() => "/tmp/mock-sessions.json"),
|
||||
resolveSessionStoreEntry: vi.fn(() => ({ existing: sessionStoreMocks.currentEntry })),
|
||||
updateSessionStoreEntry: vi.fn(
|
||||
@@ -381,6 +382,7 @@ vi.mock("../../config/sessions/thread-info.js", () => ({
|
||||
vi.mock("./dispatch-from-config.runtime.js", () => ({
|
||||
createInternalHookEvent: internalHookMocks.createInternalHookEvent,
|
||||
loadSessionStore: sessionStoreMocks.loadSessionStore,
|
||||
readSessionEntry: sessionStoreMocks.readSessionEntry,
|
||||
resolveSessionStoreEntry: sessionStoreMocks.resolveSessionStoreEntry,
|
||||
resolveStorePath: sessionStoreMocks.resolveStorePath,
|
||||
triggerInternalHook: internalHookMocks.triggerInternalHook,
|
||||
@@ -880,6 +882,8 @@ describe("dispatchReplyFromConfig", () => {
|
||||
sessionBindingMocks.touch.mockReset();
|
||||
sessionStoreMocks.currentEntry = undefined;
|
||||
sessionStoreMocks.loadSessionStore.mockClear();
|
||||
sessionStoreMocks.readSessionEntry.mockReset();
|
||||
sessionStoreMocks.readSessionEntry.mockImplementation(() => sessionStoreMocks.currentEntry);
|
||||
sessionStoreMocks.resolveStorePath.mockClear();
|
||||
sessionStoreMocks.resolveSessionStoreEntry.mockClear();
|
||||
threadInfoMocks.parseSessionThreadInfo.mockReset();
|
||||
@@ -1877,6 +1881,55 @@ describe("dispatchReplyFromConfig", () => {
|
||||
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
||||
});
|
||||
|
||||
it("refreshes verbose progress with session entry snapshots", async () => {
|
||||
setNoAbort();
|
||||
sessionStoreMocks.currentEntry = {
|
||||
verboseLevel: "on",
|
||||
};
|
||||
sessionStoreMocks.readSessionEntry.mockReturnValue({ verboseLevel: "off" });
|
||||
const cfg = {
|
||||
...emptyConfig,
|
||||
agents: {
|
||||
defaults: {
|
||||
verboseDefault: "on",
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
Provider: "telegram",
|
||||
ChatType: "direct",
|
||||
SessionKey: "agent:main:main",
|
||||
});
|
||||
|
||||
const replyResolver = async (
|
||||
_ctx: MsgContext,
|
||||
opts?: GetReplyOptions,
|
||||
_cfg?: OpenClawConfig,
|
||||
) => {
|
||||
sessionStoreMocks.loadSessionStore.mockClear();
|
||||
sessionStoreMocks.resolveSessionStoreEntry.mockClear();
|
||||
sessionStoreMocks.readSessionEntry.mockClear();
|
||||
await opts?.onPlanUpdate?.({
|
||||
phase: "update",
|
||||
explanation: "Inspect code, patch it, run tests.",
|
||||
steps: ["Inspect code", "Patch code", "Run tests"],
|
||||
});
|
||||
return { text: "done" } satisfies ReplyPayload;
|
||||
};
|
||||
|
||||
await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver });
|
||||
|
||||
expect(sessionStoreMocks.readSessionEntry).toHaveBeenCalledWith(
|
||||
"/tmp/mock-sessions.json",
|
||||
"agent:main:main",
|
||||
);
|
||||
expect(sessionStoreMocks.loadSessionStore).not.toHaveBeenCalled();
|
||||
expect(sessionStoreMocks.resolveSessionStoreEntry).not.toHaveBeenCalled();
|
||||
expect(dispatcher.sendToolResult).not.toHaveBeenCalled();
|
||||
expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "done" });
|
||||
});
|
||||
|
||||
it("suppresses text-only tool summaries when preview tool-progress suppression is enabled", async () => {
|
||||
setNoAbort();
|
||||
const cfg = emptyConfig;
|
||||
|
||||
@@ -107,6 +107,7 @@ import { resolveConversationBindingContextFromMessage } from "./conversation-bin
|
||||
import {
|
||||
createInternalHookEvent,
|
||||
loadSessionStore,
|
||||
readSessionEntry,
|
||||
resolveSessionStoreEntry,
|
||||
resolveStorePath,
|
||||
triggerInternalHook,
|
||||
@@ -351,8 +352,7 @@ const createShouldEmitVerboseProgress = (params: {
|
||||
const resolveLevel = () => {
|
||||
if (params.sessionKey && params.storePath) {
|
||||
try {
|
||||
const store = loadSessionStore(params.storePath);
|
||||
const entry = resolveSessionStoreEntry({ store, sessionKey: params.sessionKey }).existing;
|
||||
const entry = readSessionEntry(params.storePath, params.sessionKey);
|
||||
const currentLevel = normalizeVerboseLevel(entry?.verboseLevel ?? "");
|
||||
if (currentLevel) {
|
||||
return currentLevel;
|
||||
|
||||
@@ -2,11 +2,23 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js";
|
||||
import { readSessionStoreCache, writeSessionStoreCache } from "./sessions/store-cache.js";
|
||||
import {
|
||||
getSerializedSessionStore,
|
||||
getSerializedSessionStoreCacheStatsForTest,
|
||||
getSessionStoreStringInternStatsForTest,
|
||||
readSessionStoreCache,
|
||||
setSerializedSessionStore,
|
||||
writeSessionStoreCache,
|
||||
} from "./sessions/store-cache.js";
|
||||
import {
|
||||
clearSessionStoreCacheForTest,
|
||||
loadSessionStore,
|
||||
readSessionEntries,
|
||||
readSessionEntry,
|
||||
readSessionStoreSnapshot,
|
||||
readSessionUpdatedAt,
|
||||
saveSessionStore,
|
||||
updateSessionStore,
|
||||
} from "./sessions/store.js";
|
||||
import type { SessionEntry } from "./sessions/types.js";
|
||||
|
||||
@@ -53,6 +65,35 @@ describe("Session Store Cache", () => {
|
||||
afterEach(() => {
|
||||
clearSessionStoreCacheForTest();
|
||||
delete process.env.OPENCLAW_SESSION_CACHE_TTL_MS;
|
||||
delete process.env.OPENCLAW_SESSION_SERIALIZED_CACHE_MAX_BYTES;
|
||||
});
|
||||
|
||||
it("bounds the serialized session store cache by total bytes", () => {
|
||||
process.env.OPENCLAW_SESSION_SERIALIZED_CACHE_MAX_BYTES = "64";
|
||||
clearSessionStoreCacheForTest();
|
||||
|
||||
setSerializedSessionStore("store:1", "a".repeat(40));
|
||||
setSerializedSessionStore("store:2", "b".repeat(40));
|
||||
|
||||
expect(getSerializedSessionStore("store:1")).toBeUndefined();
|
||||
expect(getSerializedSessionStore("store:2")).toBe("b".repeat(40));
|
||||
expect(getSerializedSessionStoreCacheStatsForTest().entries).toBe(1);
|
||||
expect(getSerializedSessionStoreCacheStatsForTest().totalBytes).toBe(40);
|
||||
});
|
||||
|
||||
it("bounds the serialized session store cache by path count", () => {
|
||||
const maxEntries = getSerializedSessionStoreCacheStatsForTest().maxEntries;
|
||||
|
||||
for (let index = 0; index < maxEntries + 2; index += 1) {
|
||||
setSerializedSessionStore(`store:${index}`, `serialized:${index}`);
|
||||
}
|
||||
|
||||
expect(getSerializedSessionStore("store:0")).toBeUndefined();
|
||||
expect(getSerializedSessionStore("store:1")).toBeUndefined();
|
||||
expect(getSerializedSessionStore(`store:${maxEntries + 1}`)).toBe(
|
||||
`serialized:${maxEntries + 1}`,
|
||||
);
|
||||
expect(getSerializedSessionStoreCacheStatsForTest().entries).toBe(maxEntries);
|
||||
});
|
||||
|
||||
it("should load session store from disk on first call", async () => {
|
||||
@@ -247,6 +288,190 @@ describe("Session Store Cache", () => {
|
||||
stringifySpy.mockRestore();
|
||||
});
|
||||
|
||||
it("interns duplicate large skillsSnapshot prompts across cached loads", async () => {
|
||||
const largePrompt = "skill prompt ".repeat(200);
|
||||
const testStore = {
|
||||
"session:1": createSessionEntry({
|
||||
skillsSnapshot: {
|
||||
prompt: largePrompt,
|
||||
skills: [{ name: "alpha" }],
|
||||
},
|
||||
}),
|
||||
"session:2": createSessionEntry({
|
||||
sessionId: "id-2",
|
||||
displayName: "Test Session 2",
|
||||
skillsSnapshot: {
|
||||
prompt: largePrompt,
|
||||
skills: [{ name: "beta" }],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
await saveSessionStore(storePath, testStore);
|
||||
clearSessionStoreCacheForTest();
|
||||
|
||||
const loaded1 = loadSessionStore(storePath);
|
||||
const afterFirstLoad = getSessionStoreStringInternStatsForTest();
|
||||
expect(afterFirstLoad.poolSize).toBe(1);
|
||||
expect(afterFirstLoad.stored).toBe(1);
|
||||
expect(afterFirstLoad.reused).toBeGreaterThanOrEqual(1);
|
||||
|
||||
if (loaded1["session:1"].skillsSnapshot?.skills?.length) {
|
||||
loaded1["session:1"].skillsSnapshot.skills[0].name = "mutated";
|
||||
}
|
||||
|
||||
const loaded2 = loadSessionStore(storePath);
|
||||
const afterSecondLoad = getSessionStoreStringInternStatsForTest();
|
||||
expect(afterSecondLoad.poolSize).toBe(1);
|
||||
expect(afterSecondLoad.reused).toBeGreaterThanOrEqual(afterFirstLoad.reused + 2);
|
||||
expect(loaded2["session:1"].skillsSnapshot?.skills?.[0]?.name).toBe("alpha");
|
||||
});
|
||||
|
||||
it("does not intern short skillsSnapshot prompts", async () => {
|
||||
const testStore = {
|
||||
"session:1": createSessionEntry({
|
||||
skillsSnapshot: {
|
||||
prompt: "short prompt",
|
||||
skills: [{ name: "alpha" }],
|
||||
},
|
||||
}),
|
||||
"session:2": createSessionEntry({
|
||||
sessionId: "id-2",
|
||||
displayName: "Test Session 2",
|
||||
skillsSnapshot: {
|
||||
prompt: "short prompt",
|
||||
skills: [{ name: "beta" }],
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
await saveSessionStore(storePath, testStore);
|
||||
clearSessionStoreCacheForTest();
|
||||
|
||||
loadSessionStore(storePath);
|
||||
|
||||
const stats = getSessionStoreStringInternStatsForTest();
|
||||
expect(stats.poolSize).toBe(0);
|
||||
expect(stats.skippedSmall).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("reads updatedAt from immutable session snapshots without cloning cached stores", async () => {
|
||||
const updatedAt = Date.now();
|
||||
const testStore = createSingleSessionStore(
|
||||
createSessionEntry({
|
||||
updatedAt,
|
||||
}),
|
||||
"agent:main:main",
|
||||
);
|
||||
|
||||
await saveSessionStore(storePath, testStore);
|
||||
clearSessionStoreCacheForTest();
|
||||
readSessionStoreSnapshot(storePath);
|
||||
expect(readSessionEntry(storePath, "agent:main:main")?.updatedAt).toBe(updatedAt);
|
||||
|
||||
const parseSpy = vi.spyOn(JSON, "parse");
|
||||
|
||||
expect(readSessionUpdatedAt({ storePath, sessionKey: "agent:main:main" })).toBe(updatedAt);
|
||||
expect(parseSpy).not.toHaveBeenCalled();
|
||||
|
||||
parseSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("serves immutable session snapshots without cloning cache hits", async () => {
|
||||
const testStore = createSingleSessionStore(
|
||||
createSessionEntry({
|
||||
origin: { provider: "openai" },
|
||||
skillsSnapshot: {
|
||||
prompt: "snapshot skill prompt ".repeat(200),
|
||||
skills: [{ name: "alpha" }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await saveSessionStore(storePath, testStore);
|
||||
clearSessionStoreCacheForTest();
|
||||
|
||||
const snapshot1 = readSessionStoreSnapshot(storePath);
|
||||
const snapshot2 = readSessionStoreSnapshot(storePath);
|
||||
|
||||
expect(snapshot2).toBe(snapshot1);
|
||||
expect(Object.isFrozen(snapshot1)).toBe(true);
|
||||
expect(Object.isFrozen(snapshot1["session:1"])).toBe(true);
|
||||
expect(Object.isFrozen(snapshot1["session:1"].skillsSnapshot?.skills)).toBe(true);
|
||||
expect(readSessionEntry(storePath, "session:1")?.sessionId).toBe("id-1");
|
||||
expect(readSessionEntries(storePath).map(([key]) => key)).toEqual(["session:1"]);
|
||||
|
||||
expect(() => {
|
||||
(snapshot1 as Record<string, SessionEntry>)["session:2"] = createSessionEntry({
|
||||
sessionId: "id-2",
|
||||
});
|
||||
}).toThrow(TypeError);
|
||||
|
||||
const mutable = loadSessionStore(storePath);
|
||||
mutable["session:1"].origin = { provider: "mutated" };
|
||||
|
||||
expect(readSessionStoreSnapshot(storePath)["session:1"].origin?.provider).toBe("openai");
|
||||
});
|
||||
|
||||
it("does not tag snapshots with stats from writes racing after a disk read", async () => {
|
||||
await saveSessionStore(
|
||||
storePath,
|
||||
createSingleSessionStore(createSessionEntry({ displayName: "Before race" })),
|
||||
);
|
||||
clearSessionStoreCacheForTest();
|
||||
|
||||
const afterRaceStore = createSingleSessionStore(
|
||||
createSessionEntry({ displayName: "After cross-process race" }),
|
||||
);
|
||||
const originalReadFileSync = fs.readFileSync.bind(fs);
|
||||
let wroteAfterRead = false;
|
||||
const readSpy = vi.spyOn(fs, "readFileSync").mockImplementation((file, ...args) => {
|
||||
const result = originalReadFileSync(
|
||||
file,
|
||||
...(args as [Parameters<typeof fs.readFileSync>[1]]),
|
||||
);
|
||||
if (file === storePath && !wroteAfterRead) {
|
||||
wroteAfterRead = true;
|
||||
fs.writeFileSync(storePath, JSON.stringify(afterRaceStore, null, 2));
|
||||
const bumped = new Date(Date.now() + 2_000);
|
||||
fs.utimesSync(storePath, bumped, bumped);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const first = readSessionStoreSnapshot(storePath);
|
||||
expect(first["session:1"].displayName).toBe("Before race");
|
||||
|
||||
readSpy.mockRestore();
|
||||
|
||||
const second = readSessionStoreSnapshot(storePath);
|
||||
expect(second["session:1"].displayName).toBe("After cross-process race");
|
||||
});
|
||||
|
||||
it("publishes a new immutable snapshot after session store writes", async () => {
|
||||
await saveSessionStore(storePath, createSingleSessionStore());
|
||||
|
||||
const before = readSessionStoreSnapshot(storePath);
|
||||
|
||||
await updateSessionStore(
|
||||
storePath,
|
||||
(store) => {
|
||||
store["session:1"] = {
|
||||
...store["session:1"],
|
||||
displayName: "Updated Session",
|
||||
updatedAt: Date.now() + 1,
|
||||
};
|
||||
},
|
||||
{ skipMaintenance: true },
|
||||
);
|
||||
|
||||
const after = readSessionStoreSnapshot(storePath);
|
||||
|
||||
expect(after).not.toBe(before);
|
||||
expect(before["session:1"].displayName).toBe("Test Session 1");
|
||||
expect(after["session:1"].displayName).toBe("Updated Session");
|
||||
});
|
||||
|
||||
it("should refresh cache when store file changes on disk", async () => {
|
||||
const testStore = createSingleSessionStore();
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
buildGroupDisplayName,
|
||||
deriveSessionKey,
|
||||
loadSessionStore,
|
||||
patchSessionEntry,
|
||||
resolveSessionFilePath,
|
||||
resolveSessionFilePathOptions,
|
||||
resolveSessionKey,
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
updateLastRoute,
|
||||
updateSessionStore,
|
||||
updateSessionStoreEntry,
|
||||
upsertSessionEntry,
|
||||
} from "./sessions.js";
|
||||
|
||||
describe("sessions", () => {
|
||||
@@ -506,6 +508,102 @@ describe("sessions", () => {
|
||||
expect(store[sessionKey]?.thinkingLevel).toBe("low");
|
||||
});
|
||||
|
||||
it("patchSessionEntry can preserve activity for metadata-only updates", async () => {
|
||||
const sessionKey = "agent:main:main";
|
||||
const { storePath } = await createSessionStoreFixture({
|
||||
prefix: "patchSessionEntry-preserve-activity",
|
||||
entries: {
|
||||
[sessionKey]: {
|
||||
sessionId: "sess-1",
|
||||
updatedAt: 100,
|
||||
pluginDebugEntries: [{ pluginId: "other", lines: ["keep"] }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await patchSessionEntry({
|
||||
storePath,
|
||||
sessionKey,
|
||||
preserveActivity: true,
|
||||
update: () => ({
|
||||
pluginDebugEntries: [{ pluginId: "active-memory", lines: ["status"] }],
|
||||
}),
|
||||
});
|
||||
|
||||
const store = loadSessionStore(storePath);
|
||||
expect(store[sessionKey]?.updatedAt).toBe(100);
|
||||
expect(store[sessionKey]?.pluginDebugEntries).toEqual([
|
||||
{ pluginId: "active-memory", lines: ["status"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("patchSessionEntry can replace an entry so deleted fields stay deleted", async () => {
|
||||
const sessionKey = "agent:main:main";
|
||||
const { storePath } = await createSessionStoreFixture({
|
||||
prefix: "patchSessionEntry-replace-entry",
|
||||
entries: {
|
||||
[sessionKey]: {
|
||||
sessionId: "sess-1",
|
||||
updatedAt: 100,
|
||||
model: "old-model",
|
||||
modelProvider: "old-provider",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await patchSessionEntry({
|
||||
storePath,
|
||||
sessionKey,
|
||||
replaceEntry: true,
|
||||
update: (entry) => {
|
||||
const next = { ...entry, providerOverride: "openai" };
|
||||
delete next.model;
|
||||
delete next.modelProvider;
|
||||
return next;
|
||||
},
|
||||
});
|
||||
|
||||
const store = loadSessionStore(storePath);
|
||||
expect(store[sessionKey]?.providerOverride).toBe("openai");
|
||||
expect(store[sessionKey]?.model).toBeUndefined();
|
||||
expect(store[sessionKey]?.modelProvider).toBeUndefined();
|
||||
});
|
||||
|
||||
it("upsertSessionEntry preserves existing ACP metadata by default", async () => {
|
||||
const sessionKey = "agent:main:main";
|
||||
const acp = {
|
||||
backend: "codex",
|
||||
agent: "main",
|
||||
runtimeSessionName: "runtime-session",
|
||||
mode: "persistent" as const,
|
||||
state: "idle" as const,
|
||||
lastActivityAt: 100,
|
||||
};
|
||||
const { storePath } = await createSessionStoreFixture({
|
||||
prefix: "upsertSessionEntry-acp",
|
||||
entries: {
|
||||
[sessionKey]: {
|
||||
sessionId: "sess-1",
|
||||
updatedAt: 100,
|
||||
acp,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await upsertSessionEntry({
|
||||
storePath,
|
||||
sessionKey,
|
||||
entry: {
|
||||
sessionId: "sess-2",
|
||||
updatedAt: 200,
|
||||
},
|
||||
});
|
||||
|
||||
const store = loadSessionStore(storePath);
|
||||
expect(store[sessionKey]?.sessionId).toBe("sess-2");
|
||||
expect(store[sessionKey]?.acp).toStrictEqual(acp);
|
||||
});
|
||||
|
||||
it("updateSessionStore preserves concurrent additions", async () => {
|
||||
const dir = await createCaseDir("updateSessionStore");
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
|
||||
@@ -3,10 +3,15 @@ import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import { createSessionConversationTestRegistry } from "../../test-utils/session-conversation-registry.js";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
|
||||
const storeState = vi.hoisted(() => ({
|
||||
store: {} as Record<string, SessionEntry>,
|
||||
stores: {} as Record<string, Record<string, SessionEntry>>,
|
||||
}));
|
||||
const storeState = vi.hoisted(() => {
|
||||
const state = {
|
||||
store: {} as Record<string, SessionEntry>,
|
||||
stores: {} as Record<string, Record<string, SessionEntry>>,
|
||||
loadSessionStore: vi.fn((storePath: string) => state.stores[storePath] ?? state.store),
|
||||
readSessionStoreSnapshot: vi.fn((storePath: string) => state.stores[storePath] ?? state.store),
|
||||
};
|
||||
return state;
|
||||
});
|
||||
|
||||
vi.mock("../io.js", () => ({
|
||||
getRuntimeConfig: () => ({}),
|
||||
@@ -18,7 +23,8 @@ vi.mock("./paths.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./store.js", () => ({
|
||||
loadSessionStore: (storePath: string) => storeState.stores[storePath] ?? storeState.store,
|
||||
loadSessionStore: storeState.loadSessionStore,
|
||||
readSessionStoreSnapshot: storeState.readSessionStoreSnapshot,
|
||||
}));
|
||||
|
||||
vi.mock("./targets.js", () => ({
|
||||
@@ -46,6 +52,8 @@ beforeEach(() => {
|
||||
setActivePluginRegistry(createSessionConversationTestRegistry());
|
||||
storeState.store = {};
|
||||
storeState.stores = {};
|
||||
storeState.loadSessionStore.mockClear();
|
||||
storeState.readSessionStoreSnapshot.mockClear();
|
||||
});
|
||||
|
||||
describe("extractDeliveryInfo", () => {
|
||||
@@ -85,6 +93,21 @@ describe("extractDeliveryInfo", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses session-store snapshots for direct session keys", () => {
|
||||
const sessionKey = "agent:main:webchat:dm:user-123";
|
||||
storeState.store[sessionKey] = buildEntry({
|
||||
channel: "webchat",
|
||||
to: "webchat:user-123",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
const result = extractDeliveryInfo(sessionKey);
|
||||
|
||||
expect(result.deliveryContext?.to).toBe("webchat:user-123");
|
||||
expect(storeState.readSessionStoreSnapshot).toHaveBeenCalledWith("/tmp/sessions.json");
|
||||
expect(storeState.loadSessionStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns deliveryContext for direct session keys", () => {
|
||||
const sessionKey = "agent:main:webchat:dm:user-123";
|
||||
storeState.store[sessionKey] = buildEntry({
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getRuntimeConfig } from "../io.js";
|
||||
import type { OpenClawConfig } from "../types.openclaw.js";
|
||||
import { resolveStorePath } from "./paths.js";
|
||||
import { normalizeStoreSessionKey } from "./store-entry.js";
|
||||
import { loadSessionStore } from "./store.js";
|
||||
import { readSessionStoreSnapshot } from "./store.js";
|
||||
import { resolveAllAgentSessionStoreTargetsSync } from "./targets.js";
|
||||
import { parseSessionThreadInfo } from "./thread-info.js";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
@@ -79,26 +79,31 @@ function resolveDeliveryStorePaths(cfg: OpenClawConfig, agentId: string): string
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
function asSessionEntry(entry: unknown): SessionEntry | undefined {
|
||||
return entry as SessionEntry | undefined;
|
||||
}
|
||||
|
||||
function findSessionEntryInStore(
|
||||
store: ReturnType<typeof loadSessionStore>,
|
||||
store: ReturnType<typeof readSessionStoreSnapshot>,
|
||||
keys: readonly string[],
|
||||
) {
|
||||
let normalizedIndex: Map<string, SessionEntry> | undefined;
|
||||
let bestEntry: SessionEntry | undefined;
|
||||
let bestUpdatedAt = 0;
|
||||
let bestRoutable = false;
|
||||
const acceptCandidate = (candidate: SessionEntry | undefined) => {
|
||||
const acceptCandidate = (candidate: unknown) => {
|
||||
if (!candidate) {
|
||||
return;
|
||||
}
|
||||
const candidateRoutable = hasRoutableDeliveryContext(deliveryContextFromSession(candidate));
|
||||
const candidateUpdatedAt = candidate.updatedAt ?? 0;
|
||||
const entry = candidate as SessionEntry;
|
||||
const candidateRoutable = hasRoutableDeliveryContext(deliveryContextFromSession(entry));
|
||||
const candidateUpdatedAt = entry.updatedAt ?? 0;
|
||||
if (
|
||||
!bestEntry ||
|
||||
(candidateRoutable && !bestRoutable) ||
|
||||
(candidateRoutable === bestRoutable && candidateUpdatedAt > bestUpdatedAt)
|
||||
) {
|
||||
bestEntry = candidate;
|
||||
bestEntry = entry;
|
||||
bestUpdatedAt = candidateUpdatedAt;
|
||||
bestRoutable = candidateRoutable;
|
||||
}
|
||||
@@ -110,7 +115,7 @@ function findSessionEntryInStore(
|
||||
let foundRoutableCandidate = false;
|
||||
if (Object.prototype.hasOwnProperty.call(store, normalized)) {
|
||||
foundRoutableCandidate ||= hasRoutableDeliveryContext(
|
||||
deliveryContextFromSession(store[normalized]),
|
||||
deliveryContextFromSession(asSessionEntry(store[normalized])),
|
||||
);
|
||||
acceptCandidate(store[normalized]);
|
||||
}
|
||||
@@ -119,13 +124,13 @@ function findSessionEntryInStore(
|
||||
Object.prototype.hasOwnProperty.call(store, foldedLegacyKey)
|
||||
) {
|
||||
foundRoutableCandidate ||= hasRoutableDeliveryContext(
|
||||
deliveryContextFromSession(store[foldedLegacyKey]),
|
||||
deliveryContextFromSession(asSessionEntry(store[foldedLegacyKey])),
|
||||
);
|
||||
acceptCandidate(store[foldedLegacyKey]);
|
||||
}
|
||||
if (trimmed !== normalized && Object.prototype.hasOwnProperty.call(store, trimmed)) {
|
||||
foundRoutableCandidate ||= hasRoutableDeliveryContext(
|
||||
deliveryContextFromSession(store[trimmed]),
|
||||
deliveryContextFromSession(asSessionEntry(store[trimmed])),
|
||||
);
|
||||
acceptCandidate(store[trimmed]);
|
||||
}
|
||||
@@ -142,10 +147,14 @@ function findSessionEntryInStore(
|
||||
}
|
||||
|
||||
function buildFreshestSessionEntryIndex(
|
||||
store: Record<string, SessionEntry>,
|
||||
store: Readonly<Record<string, unknown>>,
|
||||
): Map<string, SessionEntry> {
|
||||
const index = new Map<string, SessionEntry>();
|
||||
for (const [key, entry] of Object.entries(store)) {
|
||||
for (const [key, candidate] of Object.entries(store)) {
|
||||
const entry = asSessionEntry(candidate);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeStoreSessionKey(key);
|
||||
const existing = index.get(normalized);
|
||||
const entryRoutable = hasRoutableDeliveryContext(deliveryContextFromSession(entry));
|
||||
@@ -200,7 +209,7 @@ function loadDeliverySessionEntry(params: {
|
||||
}
|
||||
| undefined;
|
||||
for (const storePath of resolveDeliveryStorePaths(params.cfg, agentId)) {
|
||||
const store = loadSessionStore(storePath);
|
||||
const store = readSessionStoreSnapshot(storePath);
|
||||
const entry = findSessionEntryInStore(store, sessionKeys);
|
||||
const baseEntry = findSessionEntryInStore(store, baseKeys);
|
||||
if (!entry && !baseEntry) {
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
import { createExpiringMapCache, isCacheEnabled, resolveCacheTtlMs } from "../cache-utils.js";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
|
||||
export type DeepReadonly<T> = T extends (...args: never[]) => unknown
|
||||
? T
|
||||
: T extends readonly (infer U)[]
|
||||
? ReadonlyArray<DeepReadonly<U>>
|
||||
: T extends object
|
||||
? { readonly [K in keyof T]: DeepReadonly<T[K]> }
|
||||
: T;
|
||||
|
||||
export type SessionStoreSnapshot = DeepReadonly<Record<string, SessionEntry>>;
|
||||
|
||||
export type SessionStoreSnapshotEntry = DeepReadonly<SessionEntry>;
|
||||
|
||||
export type SessionStoreSnapshotEntries = ReadonlyArray<
|
||||
readonly [string, SessionStoreSnapshotEntry]
|
||||
>;
|
||||
|
||||
type SessionStoreCacheEntry = {
|
||||
store: Record<string, SessionEntry>;
|
||||
mtimeMs?: number;
|
||||
@@ -8,18 +24,190 @@ type SessionStoreCacheEntry = {
|
||||
serialized?: string;
|
||||
};
|
||||
|
||||
type SessionStoreSnapshotCacheEntry = {
|
||||
snapshot: SessionStoreSnapshot;
|
||||
mtimeMs?: number;
|
||||
sizeBytes?: number;
|
||||
generation: number;
|
||||
createdAt: number;
|
||||
entryCount: number;
|
||||
};
|
||||
|
||||
type SerializedSessionStoreCacheEntry = {
|
||||
serialized: string;
|
||||
sizeBytes: number;
|
||||
};
|
||||
|
||||
const DEFAULT_SESSION_STORE_TTL_MS = 45_000; // 45 seconds (between 30-60s)
|
||||
const DEFAULT_SESSION_STORE_SERIALIZED_CACHE_MAX_ENTRIES = 64;
|
||||
const DEFAULT_SESSION_STORE_SERIALIZED_CACHE_MAX_BYTES = 64 * 1024 * 1024;
|
||||
const LARGE_SESSION_STORE_STRING_MIN_CHARS = 512;
|
||||
const LARGE_SESSION_STORE_STRING_MAX_INTERNED = 256;
|
||||
|
||||
const SESSION_STORE_CACHE = createExpiringMapCache<string, SessionStoreCacheEntry>({
|
||||
ttlMs: getSessionStoreTtl,
|
||||
});
|
||||
const SESSION_STORE_SERIALIZED_CACHE = new Map<string, string>();
|
||||
const SESSION_STORE_SNAPSHOT_CACHE = createExpiringMapCache<string, SessionStoreSnapshotCacheEntry>(
|
||||
{
|
||||
ttlMs: getSessionStoreTtl,
|
||||
},
|
||||
);
|
||||
const SESSION_STORE_SERIALIZED_CACHE = new Map<string, SerializedSessionStoreCacheEntry>();
|
||||
const SESSION_STORE_STRING_INTERN_POOL = new Map<string, string>();
|
||||
const SESSION_STORE_STRING_INTERN_STATS = {
|
||||
stored: 0,
|
||||
reused: 0,
|
||||
skippedSmall: 0,
|
||||
skippedFull: 0,
|
||||
};
|
||||
let sessionStoreSnapshotGeneration = 0;
|
||||
let sessionStoreSerializedCacheBytes = 0;
|
||||
|
||||
function parseNonNegativeInteger(value: string | undefined): number | null {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function getSerializedSessionStoreCacheMaxBytes(): number {
|
||||
return (
|
||||
parseNonNegativeInteger(process.env.OPENCLAW_SESSION_SERIALIZED_CACHE_MAX_BYTES) ??
|
||||
DEFAULT_SESSION_STORE_SERIALIZED_CACHE_MAX_BYTES
|
||||
);
|
||||
}
|
||||
|
||||
function getSerializedSessionStoreCacheMaxEntries(): number {
|
||||
return DEFAULT_SESSION_STORE_SERIALIZED_CACHE_MAX_ENTRIES;
|
||||
}
|
||||
|
||||
function resetSessionStoreStringInternStats(): void {
|
||||
SESSION_STORE_STRING_INTERN_STATS.stored = 0;
|
||||
SESSION_STORE_STRING_INTERN_STATS.reused = 0;
|
||||
SESSION_STORE_STRING_INTERN_STATS.skippedSmall = 0;
|
||||
SESSION_STORE_STRING_INTERN_STATS.skippedFull = 0;
|
||||
}
|
||||
|
||||
function internLargeSessionStoreString(value: string): string {
|
||||
if (value.length < LARGE_SESSION_STORE_STRING_MIN_CHARS) {
|
||||
SESSION_STORE_STRING_INTERN_STATS.skippedSmall += 1;
|
||||
return value;
|
||||
}
|
||||
const interned = SESSION_STORE_STRING_INTERN_POOL.get(value);
|
||||
if (interned !== undefined) {
|
||||
SESSION_STORE_STRING_INTERN_STATS.reused += 1;
|
||||
return interned;
|
||||
}
|
||||
if (SESSION_STORE_STRING_INTERN_POOL.size >= LARGE_SESSION_STORE_STRING_MAX_INTERNED) {
|
||||
SESSION_STORE_STRING_INTERN_STATS.skippedFull += 1;
|
||||
return value;
|
||||
}
|
||||
SESSION_STORE_STRING_INTERN_POOL.set(value, value);
|
||||
SESSION_STORE_STRING_INTERN_STATS.stored += 1;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function internSessionEntryLargeStrings(entry: SessionEntry): void {
|
||||
const snapshot = entry.skillsSnapshot;
|
||||
if (!snapshot?.prompt) {
|
||||
return;
|
||||
}
|
||||
// The live session store repeatedly clones a small set of large skills prompts.
|
||||
// Intern only that known high-duplication field so behavior and serialization stay unchanged.
|
||||
snapshot.prompt = internLargeSessionStoreString(snapshot.prompt);
|
||||
}
|
||||
|
||||
export function internSessionStoreLargeStrings(store: Record<string, SessionEntry>): void {
|
||||
for (const entry of Object.values(store)) {
|
||||
internSessionEntryLargeStrings(entry);
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionStoreStringInternStatsForTest(): {
|
||||
poolSize: number;
|
||||
stored: number;
|
||||
reused: number;
|
||||
skippedSmall: number;
|
||||
skippedFull: number;
|
||||
minChars: number;
|
||||
maxEntries: number;
|
||||
} {
|
||||
return {
|
||||
poolSize: SESSION_STORE_STRING_INTERN_POOL.size,
|
||||
stored: SESSION_STORE_STRING_INTERN_STATS.stored,
|
||||
reused: SESSION_STORE_STRING_INTERN_STATS.reused,
|
||||
skippedSmall: SESSION_STORE_STRING_INTERN_STATS.skippedSmall,
|
||||
skippedFull: SESSION_STORE_STRING_INTERN_STATS.skippedFull,
|
||||
minChars: LARGE_SESSION_STORE_STRING_MIN_CHARS,
|
||||
maxEntries: LARGE_SESSION_STORE_STRING_MAX_INTERNED,
|
||||
};
|
||||
}
|
||||
|
||||
export function getSerializedSessionStoreCacheStatsForTest(): {
|
||||
entries: number;
|
||||
totalBytes: number;
|
||||
maxEntries: number;
|
||||
maxBytes: number;
|
||||
} {
|
||||
pruneSerializedSessionStoreCache();
|
||||
return {
|
||||
entries: SESSION_STORE_SERIALIZED_CACHE.size,
|
||||
totalBytes: sessionStoreSerializedCacheBytes,
|
||||
maxEntries: getSerializedSessionStoreCacheMaxEntries(),
|
||||
maxBytes: getSerializedSessionStoreCacheMaxBytes(),
|
||||
};
|
||||
}
|
||||
|
||||
function deepFreeze<T>(value: T, seen = new WeakSet<object>()): DeepReadonly<T> {
|
||||
if (!value || typeof value !== "object") {
|
||||
return value as DeepReadonly<T>;
|
||||
}
|
||||
const object = value as object;
|
||||
if (seen.has(object)) {
|
||||
return value as DeepReadonly<T>;
|
||||
}
|
||||
seen.add(object);
|
||||
for (const child of Object.values(value as Record<string, unknown>)) {
|
||||
deepFreeze(child, seen);
|
||||
}
|
||||
return Object.freeze(value) as DeepReadonly<T>;
|
||||
}
|
||||
|
||||
export function cloneSessionStoreRecord(
|
||||
store: Record<string, SessionEntry>,
|
||||
serialized?: string,
|
||||
): Record<string, SessionEntry> {
|
||||
return JSON.parse(serialized ?? JSON.stringify(store)) as Record<string, SessionEntry>;
|
||||
const cloned = JSON.parse(serialized ?? JSON.stringify(store)) as Record<string, SessionEntry>;
|
||||
internSessionStoreLargeStrings(cloned);
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function cloneJsonLikeValue<T>(value: T): T {
|
||||
if (!value || typeof value !== "object") {
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => cloneJsonLikeValue(item)) as T;
|
||||
}
|
||||
const cloned: Record<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
||||
cloned[key] = cloneJsonLikeValue(child);
|
||||
}
|
||||
return cloned as T;
|
||||
}
|
||||
|
||||
export function cloneSessionStoreSnapshot(
|
||||
store: Record<string, SessionEntry>,
|
||||
serialized?: string,
|
||||
): SessionStoreSnapshot {
|
||||
const cloned =
|
||||
serialized === undefined
|
||||
? cloneJsonLikeValue(store)
|
||||
: cloneSessionStoreRecord(store, serialized);
|
||||
internSessionStoreLargeStrings(cloned);
|
||||
return deepFreeze(cloned);
|
||||
}
|
||||
|
||||
export function getSessionStoreTtl(): number {
|
||||
@@ -35,30 +223,108 @@ export function isSessionStoreCacheEnabled(): boolean {
|
||||
|
||||
export function clearSessionStoreCaches(): void {
|
||||
SESSION_STORE_CACHE.clear();
|
||||
SESSION_STORE_SNAPSHOT_CACHE.clear();
|
||||
SESSION_STORE_SERIALIZED_CACHE.clear();
|
||||
sessionStoreSerializedCacheBytes = 0;
|
||||
SESSION_STORE_STRING_INTERN_POOL.clear();
|
||||
resetSessionStoreStringInternStats();
|
||||
}
|
||||
|
||||
export function invalidateSessionStoreCache(storePath: string): void {
|
||||
SESSION_STORE_CACHE.delete(storePath);
|
||||
SESSION_STORE_SNAPSHOT_CACHE.delete(storePath);
|
||||
deleteSerializedSessionStore(storePath);
|
||||
}
|
||||
|
||||
function deleteSerializedSessionStore(storePath: string): void {
|
||||
const cached = SESSION_STORE_SERIALIZED_CACHE.get(storePath);
|
||||
if (!cached) {
|
||||
return;
|
||||
}
|
||||
SESSION_STORE_SERIALIZED_CACHE.delete(storePath);
|
||||
sessionStoreSerializedCacheBytes -= cached.sizeBytes;
|
||||
}
|
||||
|
||||
function pruneSerializedSessionStoreCache(): void {
|
||||
const maxEntries = getSerializedSessionStoreCacheMaxEntries();
|
||||
const maxBytes = getSerializedSessionStoreCacheMaxBytes();
|
||||
while (
|
||||
SESSION_STORE_SERIALIZED_CACHE.size > 0 &&
|
||||
(SESSION_STORE_SERIALIZED_CACHE.size > maxEntries ||
|
||||
sessionStoreSerializedCacheBytes > maxBytes)
|
||||
) {
|
||||
const oldestKey = SESSION_STORE_SERIALIZED_CACHE.keys().next().value;
|
||||
if (typeof oldestKey !== "string") {
|
||||
break;
|
||||
}
|
||||
deleteSerializedSessionStore(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
export function getSerializedSessionStore(storePath: string): string | undefined {
|
||||
return SESSION_STORE_SERIALIZED_CACHE.get(storePath);
|
||||
pruneSerializedSessionStoreCache();
|
||||
return SESSION_STORE_SERIALIZED_CACHE.get(storePath)?.serialized;
|
||||
}
|
||||
|
||||
export function setSerializedSessionStore(storePath: string, serialized?: string): void {
|
||||
deleteSerializedSessionStore(storePath);
|
||||
if (serialized === undefined) {
|
||||
SESSION_STORE_SERIALIZED_CACHE.delete(storePath);
|
||||
return;
|
||||
}
|
||||
SESSION_STORE_SERIALIZED_CACHE.set(storePath, serialized);
|
||||
const sizeBytes = Buffer.byteLength(serialized, "utf8");
|
||||
const maxEntries = getSerializedSessionStoreCacheMaxEntries();
|
||||
const maxBytes = getSerializedSessionStoreCacheMaxBytes();
|
||||
if (maxEntries <= 0 || maxBytes <= 0 || sizeBytes > maxBytes) {
|
||||
return;
|
||||
}
|
||||
SESSION_STORE_SERIALIZED_CACHE.set(storePath, { serialized, sizeBytes });
|
||||
sessionStoreSerializedCacheBytes += sizeBytes;
|
||||
pruneSerializedSessionStoreCache();
|
||||
}
|
||||
|
||||
export function dropSessionStoreObjectCache(storePath: string): void {
|
||||
SESSION_STORE_CACHE.delete(storePath);
|
||||
}
|
||||
|
||||
export function dropSessionStoreSnapshotCache(storePath: string): void {
|
||||
SESSION_STORE_SNAPSHOT_CACHE.delete(storePath);
|
||||
}
|
||||
|
||||
export function readSessionStoreSnapshotCache(params: {
|
||||
storePath: string;
|
||||
mtimeMs?: number;
|
||||
sizeBytes?: number;
|
||||
}): SessionStoreSnapshot | null {
|
||||
const cached = SESSION_STORE_SNAPSHOT_CACHE.get(params.storePath);
|
||||
if (!cached) {
|
||||
return null;
|
||||
}
|
||||
if (params.mtimeMs !== cached.mtimeMs || params.sizeBytes !== cached.sizeBytes) {
|
||||
invalidateSessionStoreCache(params.storePath);
|
||||
return null;
|
||||
}
|
||||
return cached.snapshot;
|
||||
}
|
||||
|
||||
export function writeSessionStoreSnapshotCache(params: {
|
||||
storePath: string;
|
||||
store: Record<string, SessionEntry>;
|
||||
mtimeMs?: number;
|
||||
sizeBytes?: number;
|
||||
serialized?: string;
|
||||
}): SessionStoreSnapshot {
|
||||
const snapshot = cloneSessionStoreSnapshot(params.store, params.serialized);
|
||||
SESSION_STORE_SNAPSHOT_CACHE.set(params.storePath, {
|
||||
snapshot,
|
||||
mtimeMs: params.mtimeMs,
|
||||
sizeBytes: params.sizeBytes,
|
||||
generation: (sessionStoreSnapshotGeneration += 1),
|
||||
createdAt: Date.now(),
|
||||
entryCount: Object.keys(snapshot).length,
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
export function readSessionStoreCache(params: {
|
||||
storePath: string;
|
||||
mtimeMs?: number;
|
||||
@@ -103,13 +369,16 @@ export function writeSessionStoreCache(params: {
|
||||
sizeBytes?: number;
|
||||
serialized?: string;
|
||||
}): void {
|
||||
const store =
|
||||
params.serialized === undefined ? cloneSessionStoreRecord(params.store) : params.store;
|
||||
if (params.serialized !== undefined) {
|
||||
internSessionStoreLargeStrings(store);
|
||||
}
|
||||
SESSION_STORE_CACHE.set(params.storePath, {
|
||||
store: params.serialized === undefined ? cloneSessionStoreRecord(params.store) : params.store,
|
||||
store,
|
||||
mtimeMs: params.mtimeMs,
|
||||
sizeBytes: params.sizeBytes,
|
||||
serialized: params.serialized,
|
||||
});
|
||||
if (params.serialized !== undefined) {
|
||||
SESSION_STORE_SERIALIZED_CACHE.set(params.storePath, params.serialized);
|
||||
}
|
||||
setSerializedSessionStore(params.storePath, params.serialized);
|
||||
}
|
||||
|
||||
@@ -11,12 +11,20 @@ import {
|
||||
import { getFileStatSnapshot } from "../cache-utils.js";
|
||||
import {
|
||||
cloneSessionStoreRecord,
|
||||
cloneSessionStoreSnapshot,
|
||||
internSessionEntryLargeStrings,
|
||||
isSessionStoreCacheEnabled,
|
||||
readSessionStoreCache,
|
||||
readSessionStoreSnapshotCache,
|
||||
setSerializedSessionStore,
|
||||
writeSessionStoreCache,
|
||||
writeSessionStoreSnapshotCache,
|
||||
type SessionStoreSnapshot,
|
||||
type SessionStoreSnapshotEntries,
|
||||
type SessionStoreSnapshotEntry,
|
||||
} from "./store-cache.js";
|
||||
import { normalizePersistedSessionEntryShape } from "./store-entry-shape.js";
|
||||
import { resolveSessionStoreEntry } from "./store-entry.js";
|
||||
import { collectSessionMaintenancePreserveKeys } from "./store-maintenance-preserve.js";
|
||||
import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js";
|
||||
import {
|
||||
@@ -335,6 +343,7 @@ export function normalizeSessionStore(store: Record<string, SessionEntry>): bool
|
||||
),
|
||||
),
|
||||
);
|
||||
internSessionEntryLargeStrings(normalized);
|
||||
if (normalized !== entry) {
|
||||
store[key] = normalized;
|
||||
changed = true;
|
||||
@@ -380,8 +389,9 @@ export function loadSessionStore(
|
||||
store = parsed;
|
||||
serializedFromDisk = raw;
|
||||
}
|
||||
fileStat = getFileStatSnapshot(storePath) ?? fileStat;
|
||||
mtimeMs = fileStat?.mtimeMs;
|
||||
// Cache with the stat observed before this read. If another process
|
||||
// writes the file after readFileSync returns, a post-read stat could tag
|
||||
// stale content as current and make future cache hits return old data.
|
||||
break;
|
||||
} catch {
|
||||
if (attempt < maxReadAttempts - 1) {
|
||||
@@ -446,3 +456,44 @@ export function loadSessionStore(
|
||||
|
||||
return opts.clone === false ? store : cloneSessionStoreRecord(store, serializedFromDisk);
|
||||
}
|
||||
|
||||
export function readSessionStoreSnapshot(storePath: string): SessionStoreSnapshot {
|
||||
const currentFileStat = getFileStatSnapshot(storePath);
|
||||
if (isSessionStoreCacheEnabled()) {
|
||||
const cached = readSessionStoreSnapshotCache({
|
||||
storePath,
|
||||
mtimeMs: currentFileStat?.mtimeMs,
|
||||
sizeBytes: currentFileStat?.sizeBytes,
|
||||
});
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
const store = loadSessionStore(storePath);
|
||||
if (!isSessionStoreCacheEnabled()) {
|
||||
return cloneSessionStoreSnapshot(store);
|
||||
}
|
||||
return writeSessionStoreSnapshotCache({
|
||||
storePath,
|
||||
store,
|
||||
mtimeMs: currentFileStat?.mtimeMs,
|
||||
sizeBytes: currentFileStat?.sizeBytes,
|
||||
});
|
||||
}
|
||||
|
||||
export function readSessionEntry(
|
||||
storePath: string,
|
||||
sessionKey: string,
|
||||
): SessionStoreSnapshotEntry | undefined {
|
||||
const snapshot = readSessionStoreSnapshot(storePath);
|
||||
const resolved = resolveSessionStoreEntry({
|
||||
store: snapshot as Record<string, SessionEntry>,
|
||||
sessionKey,
|
||||
});
|
||||
return resolved.existing as SessionStoreSnapshotEntry | undefined;
|
||||
}
|
||||
|
||||
export function readSessionEntries(storePath: string): SessionStoreSnapshotEntries {
|
||||
return Object.entries(readSessionStoreSnapshot(storePath)) as SessionStoreSnapshotEntries;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
||||
import type { MsgContext } from "../../auto-reply/templating.js";
|
||||
import { writeTextAtomic } from "../../infra/json-files.js";
|
||||
import { createSubsystemLogger } from "../../logging/subsystem.js";
|
||||
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
|
||||
import {
|
||||
deliveryContextFromChannelRoute,
|
||||
deliveryContextFromSession,
|
||||
@@ -12,18 +13,28 @@ import {
|
||||
} from "../../utils/delivery-context.shared.js";
|
||||
import type { DeliveryContext } from "../../utils/delivery-context.types.js";
|
||||
import { getFileStatSnapshot } from "../cache-utils.js";
|
||||
import { getRuntimeConfig } from "../io.js";
|
||||
import { enforceSessionDiskBudget, type SessionDiskBudgetSweepResult } from "./disk-budget.js";
|
||||
import { deriveSessionMetaPatch } from "./metadata.js";
|
||||
import { resolveStorePath } from "./paths.js";
|
||||
import {
|
||||
cloneSessionStoreRecord,
|
||||
dropSessionStoreObjectCache,
|
||||
dropSessionStoreSnapshotCache,
|
||||
getSerializedSessionStore,
|
||||
isSessionStoreCacheEnabled,
|
||||
setSerializedSessionStore,
|
||||
takeMutableSessionStoreCache,
|
||||
writeSessionStoreCache,
|
||||
writeSessionStoreSnapshotCache,
|
||||
} from "./store-cache.js";
|
||||
import { normalizeStoreSessionKey, resolveSessionStoreEntry } from "./store-entry.js";
|
||||
import { loadSessionStore, normalizeSessionStore } from "./store-load.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
normalizeSessionStore,
|
||||
readSessionEntries,
|
||||
readSessionEntry,
|
||||
} from "./store-load.js";
|
||||
import { collectSessionMaintenancePreserveKeys } from "./store-maintenance-preserve.js";
|
||||
import { resolveMaintenanceConfig } from "./store-maintenance-runtime.js";
|
||||
import {
|
||||
@@ -49,7 +60,17 @@ export {
|
||||
getSessionStoreWriterQueueSizeForTest,
|
||||
} from "./store-writer-state.js";
|
||||
export { withSessionStoreWriterForTest } from "./store-writer.js";
|
||||
export { loadSessionStore } from "./store-load.js";
|
||||
export {
|
||||
loadSessionStore,
|
||||
readSessionEntries,
|
||||
readSessionEntry,
|
||||
readSessionStoreSnapshot,
|
||||
} from "./store-load.js";
|
||||
export type {
|
||||
SessionStoreSnapshot,
|
||||
SessionStoreSnapshotEntries,
|
||||
SessionStoreSnapshotEntry,
|
||||
} from "./store-cache.js";
|
||||
export { normalizeStoreSessionKey, resolveSessionStoreEntry } from "./store-entry.js";
|
||||
|
||||
const log = createSubsystemLogger("sessions/store");
|
||||
@@ -83,9 +104,7 @@ export function readSessionUpdatedAt(params: {
|
||||
sessionKey: string;
|
||||
}): number | undefined {
|
||||
try {
|
||||
const store = loadSessionStore(params.storePath);
|
||||
const resolved = resolveSessionStoreEntry({ store, sessionKey: params.sessionKey });
|
||||
return resolved.existing?.updatedAt;
|
||||
return readSessionEntry(params.storePath, params.sessionKey)?.updatedAt;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -133,6 +152,49 @@ type SaveSessionStoreOptions = {
|
||||
maintenanceConfig?: ResolvedSessionMaintenanceConfig;
|
||||
};
|
||||
|
||||
type SessionEntryWorkflowOptions = {
|
||||
agentId?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
storePath?: string;
|
||||
};
|
||||
|
||||
function cloneSessionEntry(entry: SessionEntry): SessionEntry {
|
||||
return cloneSessionStoreRecord({ entry }).entry;
|
||||
}
|
||||
|
||||
function resolveSessionWorkflowStorePath(
|
||||
options: SessionEntryWorkflowOptions & { sessionKey?: string },
|
||||
): string {
|
||||
if (options.storePath) {
|
||||
return options.storePath;
|
||||
}
|
||||
const agentId = options.agentId ?? resolveAgentIdFromSessionKey(options.sessionKey);
|
||||
return resolveStorePath(getRuntimeConfig().session?.store, {
|
||||
agentId,
|
||||
env: options.env,
|
||||
});
|
||||
}
|
||||
|
||||
export function getSessionEntry(
|
||||
options: SessionEntryWorkflowOptions & { sessionKey: string },
|
||||
): SessionEntry | undefined {
|
||||
const entry = readSessionEntry(resolveSessionWorkflowStorePath(options), options.sessionKey) as
|
||||
| SessionEntry
|
||||
| undefined;
|
||||
return entry ? cloneSessionEntry(entry) : undefined;
|
||||
}
|
||||
|
||||
export function listSessionEntries(
|
||||
options: SessionEntryWorkflowOptions = {},
|
||||
): Array<{ sessionKey: string; entry: SessionEntry }> {
|
||||
return readSessionEntries(resolveSessionWorkflowStorePath(options)).map(
|
||||
([sessionKey, entry]) => ({
|
||||
sessionKey,
|
||||
entry: cloneSessionEntry(entry as SessionEntry),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function updateSessionStoreWriteCaches(params: {
|
||||
storePath: string;
|
||||
store: Record<string, SessionEntry>;
|
||||
@@ -142,6 +204,7 @@ function updateSessionStoreWriteCaches(params: {
|
||||
setSerializedSessionStore(params.storePath, params.serialized);
|
||||
if (!isSessionStoreCacheEnabled()) {
|
||||
dropSessionStoreObjectCache(params.storePath);
|
||||
dropSessionStoreSnapshotCache(params.storePath);
|
||||
return;
|
||||
}
|
||||
writeSessionStoreCache({
|
||||
@@ -151,6 +214,12 @@ function updateSessionStoreWriteCaches(params: {
|
||||
sizeBytes: fileStat?.sizeBytes,
|
||||
serialized: params.serialized,
|
||||
});
|
||||
writeSessionStoreSnapshotCache({
|
||||
storePath: params.storePath,
|
||||
store: params.store,
|
||||
mtimeMs: fileStat?.mtimeMs,
|
||||
sizeBytes: fileStat?.sizeBytes,
|
||||
});
|
||||
}
|
||||
|
||||
function loadMutableSessionStoreForWriter(storePath: string): Record<string, SessionEntry> {
|
||||
@@ -575,6 +644,67 @@ export async function updateSessionStoreEntry(params: {
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchSessionEntry(
|
||||
params: SessionEntryWorkflowOptions & {
|
||||
sessionKey: string;
|
||||
fallbackEntry?: SessionEntry;
|
||||
preserveActivity?: boolean;
|
||||
replaceEntry?: boolean;
|
||||
update: (
|
||||
entry: SessionEntry,
|
||||
) => Promise<Partial<SessionEntry> | null> | Partial<SessionEntry> | null;
|
||||
},
|
||||
): Promise<SessionEntry | null> {
|
||||
const storePath = resolveSessionWorkflowStorePath(params);
|
||||
return await runExclusiveSessionStoreWrite(storePath, async () => {
|
||||
const store = loadMutableSessionStoreForWriter(storePath);
|
||||
const resolved = resolveSessionStoreEntry({ store, sessionKey: params.sessionKey });
|
||||
const existing = resolved.existing ?? params.fallbackEntry;
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
const patch = await params.update(cloneSessionEntry(existing));
|
||||
if (!patch) {
|
||||
return existing;
|
||||
}
|
||||
const next = params.replaceEntry
|
||||
? cloneSessionEntry(patch as SessionEntry)
|
||||
: params.preserveActivity
|
||||
? mergeSessionEntryPreserveActivity(existing, patch)
|
||||
: mergeSessionEntry(existing, patch);
|
||||
return await persistResolvedSessionEntry({
|
||||
storePath,
|
||||
store,
|
||||
resolved,
|
||||
next,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function upsertSessionEntry(
|
||||
params: SessionEntryWorkflowOptions & {
|
||||
sessionKey: string;
|
||||
entry: SessionEntry;
|
||||
allowDropAcpMeta?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
const storePath = resolveSessionWorkflowStorePath(params);
|
||||
await runExclusiveSessionStoreWrite(storePath, async () => {
|
||||
const store = loadMutableSessionStoreForWriter(storePath);
|
||||
const resolved = resolveSessionStoreEntry({ store, sessionKey: params.sessionKey });
|
||||
const next = cloneSessionEntry(params.entry);
|
||||
if (!params.allowDropAcpMeta && resolved.existing?.acp && !next.acp) {
|
||||
next.acp = resolved.existing.acp;
|
||||
}
|
||||
await persistResolvedSessionEntry({
|
||||
storePath,
|
||||
store,
|
||||
resolved,
|
||||
next,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function recordSessionMetaFromInbound(params: {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
|
||||
@@ -27,6 +27,7 @@ vi.mock("../../config/sessions/paths.js", () => ({
|
||||
|
||||
vi.mock("../../config/sessions/store-load.js", () => ({
|
||||
loadSessionStore: vi.fn().mockReturnValue({}),
|
||||
readSessionEntry: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/outbound/channel-selection.runtime.js", () => ({
|
||||
@@ -57,7 +58,7 @@ const mockedModuleIds = [
|
||||
"../../pairing/allow-from-store-read.js",
|
||||
];
|
||||
|
||||
import { loadSessionStore } from "../../config/sessions/store-load.js";
|
||||
import { loadSessionStore, readSessionEntry } from "../../config/sessions/store-load.js";
|
||||
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.runtime.js";
|
||||
import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-id-resolution.js";
|
||||
import { resolveOutboundTarget } from "../../infra/outbound/targets.runtime.js";
|
||||
@@ -118,6 +119,8 @@ beforeEach(() => {
|
||||
vi.mocked(readChannelAllowFromStoreEntriesSync).mockReset();
|
||||
vi.mocked(readChannelAllowFromStoreEntriesSync).mockReturnValue([]);
|
||||
vi.mocked(resolveOutboundTarget).mockReset();
|
||||
vi.mocked(loadSessionStore).mockReset().mockReturnValue({});
|
||||
vi.mocked(readSessionEntry).mockReset().mockReturnValue(undefined);
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
@@ -194,6 +197,7 @@ type SessionStore = ReturnType<typeof loadSessionStore>;
|
||||
|
||||
function setSessionStore(store: SessionStore) {
|
||||
vi.mocked(loadSessionStore).mockReturnValue(store);
|
||||
vi.mocked(readSessionEntry).mockImplementation((_storePath, sessionKey) => store[sessionKey]);
|
||||
}
|
||||
|
||||
function setMainSessionEntry(entry?: SessionStore[string]) {
|
||||
@@ -242,6 +246,21 @@ async function resolveLastTarget(cfg: OpenClawConfig) {
|
||||
}
|
||||
|
||||
describe("resolveDeliveryTarget", () => {
|
||||
it("uses session-entry snapshot reads for implicit last delivery lookup", async () => {
|
||||
setLastSessionEntry({
|
||||
sessionId: "sess-w1",
|
||||
lastChannel: "alpha",
|
||||
lastTo: "room-allowed",
|
||||
});
|
||||
|
||||
const result = await resolveLastTarget(makeCfg({ channels: { alpha: { allowFrom: [] } } }));
|
||||
|
||||
expect(result.channel).toBe("alpha");
|
||||
expect(result.to).toBe("room-allowed");
|
||||
expect(readSessionEntry).toHaveBeenCalledWith("/tmp/test-store.json", "agent:test:main");
|
||||
expect(loadSessionStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reroutes implicit delivery to an authorized allowFrom recipient", async () => {
|
||||
setLastSessionEntry({
|
||||
sessionId: "sess-w1",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { parseExplicitTargetForLoadedChannel } from "../../channels/plugins/targ
|
||||
import type { ChannelId } from "../../channels/plugins/types.public.js";
|
||||
import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js";
|
||||
import { resolveStorePath } from "../../config/sessions/paths.js";
|
||||
import { loadSessionStore } from "../../config/sessions/store-load.js";
|
||||
import { readSessionEntry } from "../../config/sessions/store-load.js";
|
||||
import type { SessionEntry } from "../../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
@@ -132,7 +132,6 @@ export async function resolveDeliveryTarget(
|
||||
const sessionCfg = cfg.session;
|
||||
const mainSessionKey = resolveAgentMainSessionKey({ cfg, agentId });
|
||||
const storePath = resolveStorePath(sessionCfg?.store, { agentId });
|
||||
const store = loadSessionStore(storePath);
|
||||
|
||||
// Look up thread-specific session first (e.g. agent:main:main:thread:1234),
|
||||
// then fall back to the main session entry.
|
||||
@@ -156,8 +155,11 @@ export async function resolveDeliveryTarget(
|
||||
deliveryContext: storedDeliveryContext,
|
||||
} satisfies SessionEntry)
|
||||
: undefined;
|
||||
const threadEntry = threadSessionKey ? store[threadSessionKey] : undefined;
|
||||
const main = storedDeliveryEntry ?? threadEntry ?? store[mainSessionKey];
|
||||
const threadEntry = threadSessionKey
|
||||
? (readSessionEntry(storePath, threadSessionKey) as SessionEntry | undefined)
|
||||
: undefined;
|
||||
const mainEntry = readSessionEntry(storePath, mainSessionKey) as SessionEntry | undefined;
|
||||
const main = storedDeliveryEntry ?? threadEntry ?? mainEntry;
|
||||
|
||||
const preliminary = resolveSessionDeliveryTarget({
|
||||
entry: main,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { resolveFailoverReasonFromError } from "../../agents/failover-error.js";
|
||||
import { formatEmbeddedAgentExecutionPhase } from "../../agents/pi-embedded-runner/execution-phase.js";
|
||||
import { readSessionEntry } from "../../config/sessions/store-load.js";
|
||||
import type { SessionEntry } from "../../config/sessions/types.js";
|
||||
import type { CronConfig, CronRetryOn } from "../../config/types.cron.js";
|
||||
import type { HeartbeatRunResult } from "../../infra/heartbeat-wake.js";
|
||||
import {
|
||||
HEARTBEAT_SKIP_CRON_IN_PROGRESS,
|
||||
isRetryableHeartbeatBusySkipReason,
|
||||
} from "../../infra/heartbeat-wake.js";
|
||||
import { loadSessionStore } from "../../config/sessions/store-load.js";
|
||||
import {
|
||||
DEFAULT_AGENT_ID,
|
||||
isSubagentSessionKey,
|
||||
@@ -438,9 +439,7 @@ function normalizeCronLaneSegment(value: string | undefined, fallback: string):
|
||||
|
||||
function resolveMainSessionCronRunSessionKey(job: CronJob, startedAt: number): string {
|
||||
const explicitAgentId = job.agentId?.trim();
|
||||
const agentId = normalizeAgentId(
|
||||
explicitAgentId || resolveAgentIdFromSessionKey(job.sessionKey),
|
||||
);
|
||||
const agentId = normalizeAgentId(explicitAgentId || resolveAgentIdFromSessionKey(job.sessionKey));
|
||||
const jobSegment = normalizeCronLaneSegment(job.id, "job");
|
||||
const runSegment = normalizeCronLaneSegment(String(Math.max(0, Math.floor(startedAt))), "run");
|
||||
return `agent:${agentId}:cron:${jobSegment}:run:${runSegment}`;
|
||||
@@ -463,7 +462,8 @@ function resolveMainSessionCronDeliveryContext(
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
return deliveryContextFromSession(loadSessionStore(storePath)[targetSessionKey]);
|
||||
const sessionEntry = readSessionEntry(storePath, targetSessionKey) as SessionEntry | undefined;
|
||||
return deliveryContextFromSession(sessionEntry);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,15 @@
|
||||
* config-mutation, and runtime-config-snapshot.
|
||||
*/
|
||||
|
||||
import { loadSessionStore as loadSessionStoreImpl } from "../config/sessions/store-load.js";
|
||||
|
||||
/**
|
||||
* @deprecated Use getSessionEntry/listSessionEntries for reads and
|
||||
* patchSessionEntry/upsertSessionEntry for writes. loadSessionStore keeps the
|
||||
* legacy mutable whole-store shape and will remain a compatibility escape hatch.
|
||||
*/
|
||||
export const loadSessionStore = loadSessionStoreImpl;
|
||||
|
||||
export { resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
export {
|
||||
requireRuntimeConfig,
|
||||
@@ -132,13 +141,16 @@ export type {
|
||||
} from "../config/types.js";
|
||||
export {
|
||||
clearSessionStoreCacheForTest,
|
||||
loadSessionStore,
|
||||
getSessionEntry,
|
||||
listSessionEntries,
|
||||
patchSessionEntry,
|
||||
readSessionUpdatedAt,
|
||||
recordSessionMetaFromInbound,
|
||||
saveSessionStore,
|
||||
updateLastRoute,
|
||||
updateSessionStore,
|
||||
updateSessionStoreEntry,
|
||||
upsertSessionEntry,
|
||||
resolveSessionStoreEntry,
|
||||
} from "../config/sessions/store.js";
|
||||
export { resolveSessionKey } from "../config/sessions/session-key.js";
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
// Narrow session-store helpers for channel hot paths.
|
||||
|
||||
export { loadSessionStore } from "../config/sessions/store-load.js";
|
||||
import { loadSessionStore as loadSessionStoreImpl } from "../config/sessions/store-load.js";
|
||||
|
||||
/**
|
||||
* @deprecated Use getSessionEntry/listSessionEntries for reads and
|
||||
* patchSessionEntry/upsertSessionEntry for writes. loadSessionStore keeps the
|
||||
* legacy mutable whole-store shape and will remain a compatibility escape hatch.
|
||||
*/
|
||||
export const loadSessionStore = loadSessionStoreImpl;
|
||||
|
||||
export { resolveSessionStoreEntry } from "../config/sessions/store-entry.js";
|
||||
export { resolveSessionTranscriptPathInDir, resolveStorePath } from "../config/sessions/paths.js";
|
||||
export { resolveAndPersistSessionFile } from "../config/sessions/session-file.js";
|
||||
@@ -10,12 +18,16 @@ export { resolveGroupSessionKey } from "../config/sessions/group.js";
|
||||
export { canonicalizeMainSessionAlias } from "../config/sessions/main-session.js";
|
||||
export {
|
||||
clearSessionStoreCacheForTest,
|
||||
getSessionEntry,
|
||||
listSessionEntries,
|
||||
patchSessionEntry,
|
||||
readSessionUpdatedAt,
|
||||
recordSessionMetaFromInbound,
|
||||
saveSessionStore,
|
||||
updateLastRoute,
|
||||
updateSessionStore,
|
||||
updateSessionStoreEntry,
|
||||
upsertSessionEntry,
|
||||
} from "../config/sessions/store.js";
|
||||
export {
|
||||
evaluateSessionFreshness,
|
||||
|
||||
@@ -379,6 +379,22 @@ export function createPluginRuntimeMock(overrides: DeepPartial<PluginRuntime> =
|
||||
loadSessionStore: vi.fn(
|
||||
() => ({}),
|
||||
) as unknown as PluginRuntime["agent"]["session"]["loadSessionStore"],
|
||||
getSessionEntry: vi.fn(
|
||||
() => undefined,
|
||||
) as unknown as PluginRuntime["agent"]["session"]["getSessionEntry"],
|
||||
listSessionEntries: vi.fn(
|
||||
() => [],
|
||||
) as unknown as PluginRuntime["agent"]["session"]["listSessionEntries"],
|
||||
patchSessionEntry: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
null,
|
||||
) as unknown as PluginRuntime["agent"]["session"]["patchSessionEntry"],
|
||||
upsertSessionEntry: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
undefined,
|
||||
) as unknown as PluginRuntime["agent"]["session"]["upsertSessionEntry"],
|
||||
saveSessionStore: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
|
||||
@@ -311,6 +311,10 @@ describe("plugin runtime command execution", () => {
|
||||
"resolveAgentDir",
|
||||
]);
|
||||
expectFunctionKeys(runtime.agent.session as Record<string, unknown>, [
|
||||
"getSessionEntry",
|
||||
"listSessionEntries",
|
||||
"patchSessionEntry",
|
||||
"upsertSessionEntry",
|
||||
"updateSessionStore",
|
||||
"updateSessionStoreEntry",
|
||||
"resolveSessionFilePath",
|
||||
|
||||
@@ -11,10 +11,14 @@ import { normalizeThinkLevel, resolveThinkingProfile } from "../../auto-reply/th
|
||||
import { getRuntimeConfig } from "../../config/config.js";
|
||||
import { resolveSessionFilePath, resolveStorePath } from "../../config/sessions/paths.js";
|
||||
import {
|
||||
getSessionEntry,
|
||||
listSessionEntries,
|
||||
loadSessionStore,
|
||||
patchSessionEntry,
|
||||
saveSessionStore,
|
||||
updateSessionStore,
|
||||
updateSessionStoreEntry,
|
||||
upsertSessionEntry,
|
||||
} from "../../config/sessions/store.js";
|
||||
import { createLazyRuntimeMethod, createLazyRuntimeModule } from "../../shared/lazy-runtime.js";
|
||||
import { defineCachedValue } from "./runtime-cache.js";
|
||||
@@ -71,6 +75,10 @@ export function createRuntimeAgent(): PluginRuntime["agent"] {
|
||||
);
|
||||
defineCachedValue(agentRuntime, "session", () => ({
|
||||
resolveStorePath,
|
||||
getSessionEntry,
|
||||
listSessionEntries,
|
||||
patchSessionEntry,
|
||||
upsertSessionEntry,
|
||||
loadSessionStore,
|
||||
saveSessionStore,
|
||||
updateSessionStore,
|
||||
|
||||
@@ -203,6 +203,15 @@ export type PluginRuntimeCore = {
|
||||
ensureAgentWorkspace: typeof import("../../agents/workspace.js").ensureAgentWorkspace;
|
||||
session: {
|
||||
resolveStorePath: typeof import("../../config/sessions/paths.js").resolveStorePath;
|
||||
getSessionEntry: typeof import("../../config/sessions/store.js").getSessionEntry;
|
||||
listSessionEntries: typeof import("../../config/sessions/store.js").listSessionEntries;
|
||||
patchSessionEntry: typeof import("../../config/sessions/store.js").patchSessionEntry;
|
||||
upsertSessionEntry: typeof import("../../config/sessions/store.js").upsertSessionEntry;
|
||||
/**
|
||||
* @deprecated Use getSessionEntry/listSessionEntries for reads and
|
||||
* patchSessionEntry/upsertSessionEntry for writes. This keeps the legacy
|
||||
* mutable whole-store compatibility shape.
|
||||
*/
|
||||
loadSessionStore: typeof import("../../config/sessions/store-load.js").loadSessionStore;
|
||||
saveSessionStore: import("../../config/sessions/runtime-types.js").SaveSessionStore;
|
||||
updateSessionStore: typeof import("../../config/sessions/store.js").updateSessionStore;
|
||||
|
||||
@@ -42,6 +42,35 @@ function createAgentRuntime(payloads: unknown[] = [{ text: "Speak this." }]) {
|
||||
return await mutator(sessionStore);
|
||||
},
|
||||
);
|
||||
const getSessionEntry = vi.fn(
|
||||
(params: { sessionKey: string }) => sessionStore[params.sessionKey],
|
||||
);
|
||||
const patchSessionEntry = vi.fn(
|
||||
async (params: {
|
||||
sessionKey: string;
|
||||
fallbackEntry?: Record<string, unknown>;
|
||||
update: (
|
||||
entry: Record<string, unknown>,
|
||||
) => Promise<Record<string, unknown> | null> | Record<string, unknown> | null;
|
||||
}) => {
|
||||
const existing = sessionStore[params.sessionKey] ?? params.fallbackEntry;
|
||||
if (!existing) {
|
||||
return null;
|
||||
}
|
||||
const patch = await params.update({ ...existing });
|
||||
if (!patch) {
|
||||
return existing;
|
||||
}
|
||||
const next = { ...existing, ...patch };
|
||||
sessionStore[params.sessionKey] = next;
|
||||
return next;
|
||||
},
|
||||
);
|
||||
const upsertSessionEntry = vi.fn(
|
||||
async (params: { sessionKey: string; entry: Record<string, unknown> }) => {
|
||||
sessionStore[params.sessionKey] = { ...params.entry };
|
||||
},
|
||||
);
|
||||
return {
|
||||
runtime: {
|
||||
resolveAgentDir: vi.fn(() => "/tmp/agent"),
|
||||
@@ -53,6 +82,9 @@ function createAgentRuntime(payloads: unknown[] = [{ text: "Speak this." }]) {
|
||||
loadSessionStore: vi.fn(() => sessionStore),
|
||||
saveSessionStore: vi.fn(async () => {}),
|
||||
updateSessionStore,
|
||||
getSessionEntry,
|
||||
patchSessionEntry,
|
||||
upsertSessionEntry,
|
||||
resolveSessionFilePath: vi.fn(
|
||||
(_sessionId: string, entry?: { sessionFile?: string }) =>
|
||||
entry?.sessionFile ?? "/tmp/session.json",
|
||||
|
||||
@@ -87,7 +87,6 @@ function resolveRealtimeVoiceAgentDeliveryContext(params: {
|
||||
}): DeliveryContext | undefined {
|
||||
const requesterSessionKey = params.spawnedBy?.trim();
|
||||
try {
|
||||
const store = params.agentRuntime.session.loadSessionStore(params.storePath);
|
||||
const candidates: string[] = [];
|
||||
if (requesterSessionKey) {
|
||||
const { baseSessionKey } = parseSessionThreadInfoFast(requesterSessionKey);
|
||||
@@ -97,7 +96,11 @@ function resolveRealtimeVoiceAgentDeliveryContext(params: {
|
||||
}
|
||||
candidates.push(params.sessionKey);
|
||||
for (const key of candidates) {
|
||||
const context = deliveryContextFromSession(store[key] as SessionEntry | undefined);
|
||||
const entry = params.agentRuntime.session.getSessionEntry({
|
||||
storePath: params.storePath,
|
||||
sessionKey: key,
|
||||
});
|
||||
const context = deliveryContextFromSession(entry);
|
||||
if (hasRoutableDeliveryContext(context)) {
|
||||
return context;
|
||||
}
|
||||
@@ -119,64 +122,72 @@ async function resolveRealtimeVoiceAgentConsultSessionEntry(params: {
|
||||
logger: Pick<RuntimeLogger, "warn">;
|
||||
}): Promise<SessionEntry> {
|
||||
const now = Date.now();
|
||||
return await params.agentRuntime.session.updateSessionStore(params.storePath, async (store) => {
|
||||
const existing = store[params.sessionKey] as SessionEntry | undefined;
|
||||
const deliveryFields = resolveDeliverySessionFields(params.deliveryContext);
|
||||
if (existing?.sessionId?.trim()) {
|
||||
const next: SessionEntry = { ...existing, ...deliveryFields, updatedAt: now };
|
||||
store[params.sessionKey] = next;
|
||||
return next;
|
||||
}
|
||||
const deliveryFields = resolveDeliverySessionFields(params.deliveryContext);
|
||||
const requesterSessionKey = params.spawnedBy?.trim();
|
||||
const requesterAgentId = parseAgentSessionKey(requesterSessionKey)?.agentId;
|
||||
const shouldFork =
|
||||
params.contextMode === "fork" &&
|
||||
requesterSessionKey &&
|
||||
(!requesterAgentId || requesterAgentId === params.agentId);
|
||||
let forkDecisionWarning: string | undefined;
|
||||
|
||||
const requesterSessionKey = params.spawnedBy?.trim();
|
||||
const requesterAgentId = parseAgentSessionKey(requesterSessionKey)?.agentId;
|
||||
const shouldFork =
|
||||
params.contextMode === "fork" &&
|
||||
requesterSessionKey &&
|
||||
(!requesterAgentId || requesterAgentId === params.agentId);
|
||||
|
||||
if (shouldFork) {
|
||||
const parentEntry = store[requesterSessionKey] as SessionEntry | undefined;
|
||||
if (parentEntry?.sessionId?.trim()) {
|
||||
const decision = await realtimeVoiceAgentConsultDeps.resolveParentForkDecision({
|
||||
parentEntry,
|
||||
const patched = await params.agentRuntime.session.patchSessionEntry({
|
||||
storePath: params.storePath,
|
||||
sessionKey: params.sessionKey,
|
||||
fallbackEntry: {
|
||||
sessionId: "",
|
||||
updatedAt: now,
|
||||
},
|
||||
update: async (entry) => {
|
||||
if (entry.sessionId?.trim()) {
|
||||
return { ...deliveryFields, updatedAt: now };
|
||||
}
|
||||
if (shouldFork) {
|
||||
const parentEntry = params.agentRuntime.session.getSessionEntry({
|
||||
storePath: params.storePath,
|
||||
sessionKey: requesterSessionKey,
|
||||
});
|
||||
if (decision.status === "fork") {
|
||||
const fork = await realtimeVoiceAgentConsultDeps.forkSessionFromParent({
|
||||
if (parentEntry?.sessionId?.trim()) {
|
||||
const decision = await realtimeVoiceAgentConsultDeps.resolveParentForkDecision({
|
||||
parentEntry,
|
||||
agentId: params.agentId,
|
||||
sessionsDir: path.dirname(params.storePath),
|
||||
storePath: params.storePath,
|
||||
});
|
||||
if (fork) {
|
||||
const next: SessionEntry = {
|
||||
...existing,
|
||||
...deliveryFields,
|
||||
sessionId: fork.sessionId,
|
||||
sessionFile: fork.sessionFile,
|
||||
spawnedBy: requesterSessionKey,
|
||||
forkedFromParent: true,
|
||||
updatedAt: now,
|
||||
};
|
||||
store[params.sessionKey] = next;
|
||||
return next;
|
||||
if (decision.status === "fork") {
|
||||
const fork = await realtimeVoiceAgentConsultDeps.forkSessionFromParent({
|
||||
parentEntry,
|
||||
agentId: params.agentId,
|
||||
sessionsDir: path.dirname(params.storePath),
|
||||
});
|
||||
if (fork) {
|
||||
return {
|
||||
...deliveryFields,
|
||||
sessionId: fork.sessionId,
|
||||
sessionFile: fork.sessionFile,
|
||||
spawnedBy: requesterSessionKey,
|
||||
forkedFromParent: true,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
forkDecisionWarning = decision.message;
|
||||
}
|
||||
} else {
|
||||
params.logger.warn(`[talk] ${decision.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const next: SessionEntry = {
|
||||
...existing,
|
||||
...deliveryFields,
|
||||
sessionId: realtimeVoiceAgentConsultDeps.randomUUID(),
|
||||
...(requesterSessionKey ? { spawnedBy: requesterSessionKey } : {}),
|
||||
updatedAt: now,
|
||||
};
|
||||
store[params.sessionKey] = next;
|
||||
return next;
|
||||
return {
|
||||
...deliveryFields,
|
||||
sessionId: realtimeVoiceAgentConsultDeps.randomUUID(),
|
||||
...(requesterSessionKey ? { spawnedBy: requesterSessionKey } : {}),
|
||||
updatedAt: now,
|
||||
};
|
||||
},
|
||||
});
|
||||
if (forkDecisionWarning) {
|
||||
params.logger.warn(`[talk] ${forkDecisionWarning}`);
|
||||
}
|
||||
if (patched?.sessionId?.trim()) {
|
||||
return patched;
|
||||
}
|
||||
throw new Error("realtime voice agent consult session could not be initialized");
|
||||
}
|
||||
|
||||
export async function consultRealtimeVoiceAgent(params: {
|
||||
|
||||
Reference in New Issue
Block a user