refactor: add SDK transcript identity target API (#95030)

This commit is contained in:
Josh Lehman
2026-06-20 14:01:07 -07:00
committed by GitHub
parent 0a707afb9a
commit 7a0d36f3d0
14 changed files with 867 additions and 8 deletions
@@ -1,2 +1,2 @@
118c0f05ded3d3671e4caca646f8c5c13799757705fec2d769b1657367ec0243 plugin-sdk-api-baseline.json
6795c59b8ce6c8203bfca5d932b562d3d2b718e93701faa3a52e57cb45d277d4 plugin-sdk-api-baseline.jsonl
9edb033535fe1325c18b431190672dc3a826dba312e376c13c98fcf9043060dd plugin-sdk-api-baseline.json
78f26963fe2e6d7903ce2e1067699200d825f391c0010df46f48d9abd2915e65 plugin-sdk-api-baseline.jsonl
+3 -1
View File
@@ -166,7 +166,9 @@ two-party event loops that do not go through the shared inbound reply runner.
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(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are kept only during the transition before SQLite migration for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers must migrate to entry helpers before the SQLite storage flip.
For transcript reads and writes, import `openclaw/plugin-sdk/session-transcript-runtime` and use `resolveSessionTranscriptIdentity(...)`, `resolveSessionTranscriptTarget(...)`, `readSessionTranscriptEvents(...)`, `appendSessionTranscriptMessageByIdentity(...)`, `publishSessionTranscriptUpdateByIdentity(...)`, or `withSessionTranscriptWriteLock(...)` with `{ agentId, sessionKey, sessionId }`. These APIs let plugins identify a transcript, read its events, append messages, publish updates, and run related operations under the same transcript write lock. Pass `sessionFile` only when adapting code that already receives an active transcript artifact and needs each helper to operate on that same artifact.
`loadSessionStore(...)`, `saveSessionStore(...)`, `updateSessionStore(...)`, and `resolveSessionFilePath(...)` are compatibility helpers for plugins that still intentionally depend on the legacy whole-store or transcript-file shape. New plugin code must not use those helpers, and existing callers should migrate to entry helpers.
</Accordion>
<Accordion title="api.runtime.agent.defaults">
+1
View File
@@ -248,6 +248,7 @@ usage endpoint failed or returned no usable usage data.
| `plugin-sdk/reply-reference` | `createReplyReferencePlanner` |
| `plugin-sdk/reply-chunking` | Narrow text/markdown chunking helpers |
| `plugin-sdk/session-store-runtime` | Session workflow helpers (`getSessionEntry`, `listSessionEntries`, `patchSessionEntry`, `upsertSessionEntry`), legacy session store path/session-key helpers, updated-at reads, and transition-only whole-store/file-path compatibility helpers |
| `plugin-sdk/session-transcript-runtime` | Transcript identity, scoped target/read/write helpers, update publishing, write locks, and transcript memory hit keys |
| `plugin-sdk/sqlite-runtime` | Focused SQLite agent-schema, path, and transaction helpers for first-party runtime |
| `plugin-sdk/cron-store-runtime` | Cron store path/load/save helpers |
| `plugin-sdk/state-paths` | State/OAuth dir path helpers |
+4
View File
@@ -975,6 +975,10 @@
"types": "./dist/plugin-sdk/session-store-runtime.d.ts",
"default": "./dist/plugin-sdk/session-store-runtime.js"
},
"./plugin-sdk/session-transcript-runtime": {
"types": "./dist/plugin-sdk/session-transcript-runtime.d.ts",
"default": "./dist/plugin-sdk/session-transcript-runtime.js"
},
"./plugin-sdk/sqlite-runtime": {
"types": "./dist/plugin-sdk/sqlite-runtime.d.ts",
"default": "./dist/plugin-sdk/sqlite-runtime.js"
+3
View File
@@ -114,6 +114,9 @@ export const pluginSdkDocMetadata = {
"runtime-store": {
category: "runtime",
},
"session-transcript-runtime": {
category: "runtime",
},
"sqlite-runtime": {
category: "runtime",
},
+1
View File
@@ -217,6 +217,7 @@
"session-binding-runtime",
"session-key-runtime",
"session-store-runtime",
"session-transcript-runtime",
"sqlite-runtime",
"sqlite-runtime-testing",
"session-transcript-hit",
+4 -3
View File
@@ -125,6 +125,7 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
"channel-policy": 8,
"channel-route": 5,
"session-store-runtime": 1,
"session-transcript-runtime": 1,
"group-access": 13,
"media-generation-runtime-shared": 3,
"music-generation-core": 20,
@@ -161,9 +162,9 @@ let budgets;
let publicDeprecatedExportsByEntrypointBudget;
try {
budgets = {
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 320),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10301),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5171),
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 321),
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10331),
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5183),
publicDeprecatedExports: readBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
3245,
+5 -1
View File
@@ -866,7 +866,11 @@ export async function loadTranscriptEvents(
const transcript = await resolveTranscriptReadAccess(scope);
const events: TranscriptEvent[] = [];
for await (const line of streamSessionTranscriptLines(transcript.sessionFile)) {
events.push(JSON.parse(line) as TranscriptEvent);
try {
events.push(JSON.parse(line) as TranscriptEvent);
} catch {
continue;
}
}
return events;
}
+62 -1
View File
@@ -26,7 +26,10 @@ import {
streamSessionTranscriptLinesReverse,
} from "./transcript-stream.js";
import { isCanonicalSessionTranscriptEntry } from "./transcript-tree.js";
import { resolveOwnedSessionTranscriptWriteLockRunner } from "./transcript-write-context.js";
import {
resolveOwnedSessionTranscriptWriteLockRunner,
type OwnedSessionTranscriptPublishedEntry,
} from "./transcript-write-context.js";
import { CURRENT_SESSION_VERSION } from "./version.js";
const SESSION_MANAGER_APPEND_MAX_BYTES = 8 * 1024 * 1024;
@@ -385,6 +388,13 @@ export type AppendSessionTranscriptMessageResult<TMessage> = {
appended: boolean;
};
export type SessionTranscriptAppendTransactionContext = {
appendEvent: (event: unknown) => Promise<void>;
appendMessage: <TMessage>(
params: Omit<AppendSessionTranscriptMessageParams<TMessage>, "config" | "transcriptPath">,
) => Promise<AppendSessionTranscriptMessageResult<TMessage> | undefined>;
};
function isTranscriptAgentMessage(value: unknown): value is AgentMessage {
return (
typeof value === "object" &&
@@ -464,6 +474,57 @@ export async function appendSessionTranscriptMessageWithOwnedWriteLock<TMessage>
return await activeLockRunner(() => appendSessionTranscriptMessageLocked(params));
}
/**
* Runs a group of transcript appends through one append queue and write lock.
*/
export async function runSessionTranscriptAppendTransaction<T>(
params: Pick<AppendSessionTranscriptMessageParams, "config" | "transcriptPath">,
run: (context: SessionTranscriptAppendTransactionContext) => Promise<T> | T,
): Promise<T> {
const publishedEntries: OwnedSessionTranscriptPublishedEntry[] = [];
const runTransaction = async (): Promise<T> =>
await run({
appendEvent: async (event) => {
const result = await appendSessionTranscriptEventLocked({
config: params.config,
event,
transcriptPath: params.transcriptPath,
});
publishedEntries.push({ kind: "serialized", serialized: result.serializedEntry });
},
appendMessage: async (messageParams) => {
const result = await appendSessionTranscriptMessageLocked({
...messageParams,
config: params.config,
onHeaderCreated: (header) => {
publishedEntries.push({ kind: "header", serialized: header });
},
transcriptPath: params.transcriptPath,
});
if (result?.appended === true) {
publishedEntries.push({ kind: "id", id: result.messageId });
}
return result;
},
});
const activeLockRunner = resolveOwnedSessionTranscriptWriteLockRunner({
sessionFile: params.transcriptPath,
});
if (activeLockRunner) {
return await activeLockRunner(
() => withSessionTranscriptAppendQueue(params.transcriptPath, runTransaction),
{
publishOwnedWrite: true,
resolvePublishedEntries: () => publishedEntries,
resolvePublishedEntriesAfterFailure: () => publishedEntries,
},
);
}
return await withSessionTranscriptAppendQueue(params.transcriptPath, () =>
withSessionTranscriptWriteLock(params, runTransaction),
);
}
type AppendSessionTranscriptEventParams = {
config?: OpenClawConfig;
event: unknown;
@@ -1,9 +1,13 @@
// Session transcript hit tests cover transcript match formatting and path resolution.
import fs from "node:fs";
import { describe, expect, it } from "vitest";
import type { SessionEntry } from "../config/sessions/types.js";
import {
extractTranscriptIdentityFromSessionsMemoryHit,
extractTranscriptStemFromSessionsMemoryHit,
formatSessionTranscriptMemoryHitKey,
parseSessionTranscriptMemoryHitKey,
resolveSessionTranscriptMemoryHitKeyToSessionKeys,
resolveTranscriptStemToSessionKeys,
} from "./session-transcript-hit.js";
@@ -273,3 +277,34 @@ describe("resolveTranscriptStemToSessionKeys", () => {
).toEqual([]);
});
});
describe("session transcript memory hit key compatibility exports", () => {
it("keeps hit-subpath memory helpers off the runtime writer import path", () => {
const source = fs.readFileSync(new URL("./session-transcript-hit.ts", import.meta.url), "utf8");
expect(source).not.toContain("session-transcript-runtime.js");
});
it("exports storage-neutral memory hit key helpers from the legacy hit subpath", () => {
const key = formatSessionTranscriptMemoryHitKey({
agentId: "main",
sessionId: "session:legacy",
});
const store: Record<string, SessionEntry> = {
"agent:main:discord:direct:42": {
sessionFile: "/tmp/not-the-identity.jsonl",
sessionId: "session:legacy",
updatedAt: 10,
},
};
expect(key).toBe("transcript:main:session%3Alegacy");
expect(parseSessionTranscriptMemoryHitKey(key)).toMatchObject({
agentId: "main",
sessionId: "session:legacy",
});
expect(resolveSessionTranscriptMemoryHitKeyToSessionKeys({ key, store })).toEqual([
"agent:main:discord:direct:42",
]);
});
});
+13
View File
@@ -5,6 +5,19 @@ import { uniqueStrings } from "../../packages/normalization-core/src/string-norm
import { parseUsageCountedSessionIdFromFileName } from "../config/sessions/artifacts.js";
import type { SessionEntry } from "../config/sessions/types.js";
import { normalizeAgentId } from "../routing/session-key.js";
export {
formatSessionTranscriptMemoryHitKey,
parseSessionTranscriptMemoryHitKey,
resolveSessionTranscriptMemoryHitKeyToSessionKeys,
} from "./session-transcript-memory-hit.js";
export type {
ResolveSessionTranscriptMemoryHitKeyParams,
SessionTranscriptIdentity,
SessionTranscriptMemoryHitIdentity,
SessionTranscriptMemoryHitKey,
SessionTranscriptMemoryHitKeyParams,
SessionTranscriptReadParams,
} from "./session-transcript-memory-hit.js";
export { loadCombinedSessionStoreForGateway } from "../config/sessions/combined-store-gateway.js";
@@ -0,0 +1,120 @@
import { normalizeOptionalString } from "../../packages/normalization-core/src/string-coerce.js";
import { uniqueStrings } from "../../packages/normalization-core/src/string-normalization.js";
import type { SessionEntry } from "../config/sessions/types.js";
import { normalizeAgentId, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
const SESSION_TRANSCRIPT_MEMORY_HIT_PREFIX = "transcript";
export type SessionTranscriptIdentity = {
agentId: string;
memoryKey: SessionTranscriptMemoryHitKey;
sessionId: string;
sessionKey: string;
};
export type SessionTranscriptMemoryHitIdentity = {
agentId: string;
key: SessionTranscriptMemoryHitKey;
sessionId: string;
};
export type SessionTranscriptMemoryHitKey = `transcript:${string}:${string}`;
export type SessionTranscriptReadParams = {
agentId?: string;
env?: NodeJS.ProcessEnv;
hydrateSkillPromptRefs?: boolean;
sessionId: string;
sessionKey: string;
storePath?: string;
threadId?: string | number;
};
export type SessionTranscriptMemoryHitKeyParams = {
agentId: string;
sessionId: string;
};
export type ResolveSessionTranscriptMemoryHitKeyParams = {
includeSyntheticFallback?: boolean;
key: string;
store: Record<string, SessionEntry>;
};
function requireMemoryKeySegment(value: string, label: string): string {
const normalized = normalizeOptionalString(value);
if (!normalized) {
throw new Error(`Cannot build session transcript memory hit key without ${label}.`);
}
return encodeURIComponent(normalized);
}
function decodeMemoryKeySegment(value: string): string | null {
try {
return normalizeOptionalString(decodeURIComponent(value)) ?? null;
} catch {
return null;
}
}
function syntheticSessionKey(identity: SessionTranscriptMemoryHitIdentity): string {
return `agent:${identity.agentId}:${identity.sessionId}`;
}
/**
* Builds the memory hit key for one session transcript.
*/
export function formatSessionTranscriptMemoryHitKey(
params: SessionTranscriptMemoryHitKeyParams,
): SessionTranscriptMemoryHitKey {
const agentId = requireMemoryKeySegment(normalizeAgentId(params.agentId), "agentId");
const sessionId = requireMemoryKeySegment(params.sessionId, "sessionId");
return `${SESSION_TRANSCRIPT_MEMORY_HIT_PREFIX}:${agentId}:${sessionId}`;
}
/**
* Parses a session transcript memory hit key.
*/
export function parseSessionTranscriptMemoryHitKey(
key: string,
): SessionTranscriptMemoryHitIdentity | null {
const parts = key.split(":");
if (parts.length !== 3 || parts[0] !== SESSION_TRANSCRIPT_MEMORY_HIT_PREFIX) {
return null;
}
const agentId = decodeMemoryKeySegment(parts[1] ?? "");
const sessionId = decodeMemoryKeySegment(parts[2] ?? "");
if (!agentId || !sessionId) {
return null;
}
return {
agentId: normalizeAgentId(agentId),
key: formatSessionTranscriptMemoryHitKey({ agentId, sessionId }),
sessionId,
};
}
/**
* Maps a session transcript memory hit key back to visible session store keys.
*/
export function resolveSessionTranscriptMemoryHitKeyToSessionKeys(
params: ResolveSessionTranscriptMemoryHitKeyParams,
): string[] {
const identity = parseSessionTranscriptMemoryHitKey(params.key);
if (!identity) {
return [];
}
const matches = Object.entries(params.store)
.filter(([sessionKey, entry]) => {
return (
entry.sessionId === identity.sessionId &&
normalizeAgentId(resolveAgentIdFromSessionKey(sessionKey)) === identity.agentId
);
})
.map(([sessionKey]) => sessionKey);
const deduped = uniqueStrings(matches);
if (deduped.length > 0) {
return deduped;
}
return params.includeSyntheticFallback === false ? [] : [syntheticSessionKey(identity)];
}
@@ -0,0 +1,406 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { appendTranscriptEvent, upsertSessionEntry } from "../config/sessions/session-accessor.js";
import { loadSessionStore } from "../config/sessions/store.js";
import { withOwnedSessionTranscriptWrites } from "../config/sessions/transcript-write-context.js";
import * as transcriptEvents from "../sessions/transcript-events.js";
import {
appendSessionTranscriptMessageByIdentity,
formatSessionTranscriptMemoryHitKey,
parseSessionTranscriptMemoryHitKey,
publishSessionTranscriptUpdateByIdentity,
readSessionTranscriptEvents,
resolveSessionTranscriptIdentity,
resolveSessionTranscriptTarget,
resolveSessionTranscriptMemoryHitKeyToSessionKeys,
withSessionTranscriptWriteLock,
} from "./session-transcript-runtime.js";
describe("session transcript runtime SDK", () => {
let tempDir: string;
let storePath: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-sdk-transcript-"));
storePath = path.join(tempDir, "sessions.json");
});
afterEach(() => {
vi.restoreAllMocks();
fs.rmSync(tempDir, { force: true, recursive: true });
});
it("resolves transcript identity and reads events without returning sessionFile", async () => {
const scope = {
agentId: "Main",
sessionId: "session-with-colon",
sessionKey: "agent:main:main",
storePath,
};
const event = { id: "event-1", type: "metadata" };
await upsertSessionEntry(scope, { sessionId: scope.sessionId, updatedAt: 10 });
await appendTranscriptEvent(scope, event);
const identity = await resolveSessionTranscriptIdentity(scope);
expect(identity).toEqual({
agentId: "main",
memoryKey: "transcript:main:session-with-colon",
sessionId: scope.sessionId,
sessionKey: "agent:main:main",
});
expect(identity).not.toHaveProperty("sessionFile");
await expect(readSessionTranscriptEvents(scope)).resolves.toEqual([event]);
});
it("does not persist sessionFile metadata for identity-only reads", async () => {
const scope = {
agentId: "main",
sessionId: "read-only-session",
sessionKey: "agent:main:main",
storePath,
};
await upsertSessionEntry(scope, { sessionId: scope.sessionId, updatedAt: 10 });
await expect(resolveSessionTranscriptIdentity(scope)).resolves.toMatchObject({
memoryKey: "transcript:main:read-only-session",
});
await expect(readSessionTranscriptEvents(scope)).resolves.toEqual([]);
expect(loadSessionStore(storePath)[scope.sessionKey]?.sessionFile).toBeUndefined();
});
it("skips malformed transcript lines when reading by scoped identity", async () => {
const scope = {
agentId: "main",
sessionFile: path.join(tempDir, "malformed-lines.jsonl"),
sessionId: "malformed-session",
sessionKey: "agent:main:main",
storePath,
};
const firstEvent = { id: "event-valid-1", type: "message" };
const secondEvent = { id: "event-valid-2", type: "message" };
fs.writeFileSync(
scope.sessionFile,
[
JSON.stringify(firstEvent),
"{malformed-json",
JSON.stringify(secondEvent),
"not-json",
"",
].join("\n"),
"utf8",
);
await expect(readSessionTranscriptEvents(scope)).resolves.toEqual([firstEvent, secondEvent]);
});
it("binds scoped reads to an explicit active transcript file without exposing it", async () => {
const scope = {
agentId: "main",
sessionFile: path.join(tempDir, "active-session.jsonl"),
sessionId: "active-session",
sessionKey: "agent:main:main",
storePath,
};
const event = { id: "event-active", type: "metadata" };
await upsertSessionEntry(scope, {
sessionFile: path.join(tempDir, "store-default.jsonl"),
sessionId: scope.sessionId,
updatedAt: 10,
});
await appendTranscriptEvent(scope, event);
const target = await resolveSessionTranscriptTarget(scope);
expect(target).toEqual({
agentId: "main",
memoryKey: "transcript:main:active-session",
sessionId: "active-session",
sessionKey: "agent:main:main",
targetKind: "active-session-file",
});
expect(target).not.toHaveProperty("sessionFile");
await expect(readSessionTranscriptEvents(scope)).resolves.toEqual([event]);
expect(fs.readFileSync(scope.sessionFile, "utf8")).toContain("event-active");
});
it("appends messages by the same explicit scoped transcript target", async () => {
const scope = {
agentId: "main",
sessionFile: path.join(tempDir, "mirror-target.jsonl"),
sessionId: "mirror-session",
sessionKey: "agent:main:main",
storePath,
};
const message = {
role: "assistant",
content: [{ type: "text", text: "hello" }],
timestamp: 1,
};
const appended = await appendSessionTranscriptMessageByIdentity({
...scope,
message,
});
expect(appended).toBeDefined();
expect(appended?.message).toMatchObject(message);
await expect(readSessionTranscriptEvents(scope)).resolves.toEqual([
expect.objectContaining({ type: "session" }),
expect.objectContaining({ message: expect.objectContaining({ role: "assistant" }) }),
]);
});
it("publishes updates with the resolved scoped transcript identity", async () => {
const scope = {
agentId: "main",
sessionFile: path.join(tempDir, "publish-target.jsonl"),
sessionId: "publish-session",
sessionKey: "agent:main:main",
storePath,
};
const emitSpy = vi.spyOn(transcriptEvents, "emitSessionTranscriptUpdate");
await publishSessionTranscriptUpdateByIdentity({
...scope,
update: {
agentId: "stale-agent",
messageId: "message-from-direct-publish",
sessionKey: "agent:stale:other",
},
});
expect(emitSpy).toHaveBeenCalledWith({
agentId: "main",
messageId: "message-from-direct-publish",
sessionFile: scope.sessionFile,
sessionKey: "agent:main:main",
});
});
it("locks read and append helpers to one scoped transcript target", async () => {
const scope = {
agentId: "main",
sessionFile: path.join(tempDir, "locked-target.jsonl"),
sessionId: "locked-session",
sessionKey: "agent:main:main",
storePath,
};
const target = await withSessionTranscriptWriteLock(scope, async (locked) => {
expect(await locked.readEvents()).toEqual([]);
await locked.appendMessage({
message: {
role: "assistant",
content: [{ type: "text", text: "locked" }],
timestamp: 1,
},
});
return locked.target;
});
expect(target).toMatchObject({
sessionId: "locked-session",
targetKind: "active-session-file",
});
expect(target).not.toHaveProperty("sessionFile");
await expect(readSessionTranscriptEvents(scope)).resolves.toEqual([
expect.objectContaining({ type: "session" }),
expect.objectContaining({ message: expect.objectContaining({ role: "assistant" }) }),
]);
});
it("uses the owned active transcript write context for scoped locked appends", async () => {
const scope = {
agentId: "main",
sessionFile: path.join(tempDir, "owned-active-target.jsonl"),
sessionId: "owned-active-session",
sessionKey: "agent:main:main",
storePath,
};
const storeDefaultFile = path.join(tempDir, "store-default-owned.jsonl");
const lockEvents: string[] = [];
await upsertSessionEntry(scope, {
sessionFile: storeDefaultFile,
sessionId: scope.sessionId,
updatedAt: 10,
});
const target = await withOwnedSessionTranscriptWrites(
{
sessionFile: scope.sessionFile,
sessionKey: scope.sessionKey,
withSessionWriteLock: async (run) => {
lockEvents.push("lock");
return await run();
},
},
async () =>
await withSessionTranscriptWriteLock(scope, async (locked) => {
await locked.appendMessage({
message: {
role: "assistant",
content: [{ type: "text", text: "owned locked" }],
timestamp: 1,
},
});
return locked.target;
}),
);
expect(lockEvents).toEqual(["lock"]);
expect(target).toMatchObject({
sessionId: "owned-active-session",
targetKind: "active-session-file",
});
expect(fs.readFileSync(scope.sessionFile, "utf8")).toContain("owned locked");
expect(fs.existsSync(storeDefaultFile)).toBe(false);
await expect(readSessionTranscriptEvents(scope)).resolves.toEqual([
expect.objectContaining({ type: "session" }),
expect.objectContaining({ message: expect.objectContaining({ role: "assistant" }) }),
]);
});
it("publishes queued locked updates after callback appends are visible", async () => {
const scope = {
agentId: "main",
sessionFile: path.join(tempDir, "queued-publish-target.jsonl"),
sessionId: "queued-publish-session",
sessionKey: "agent:main:main",
storePath,
};
const observedUpdates: Array<{
callbackCompleted: boolean;
fileText: string;
update: unknown;
}> = [];
let callbackCompleted = false;
const emitSpy = vi
.spyOn(transcriptEvents, "emitSessionTranscriptUpdate")
.mockImplementation((update) => {
observedUpdates.push({
callbackCompleted,
fileText: fs.readFileSync(scope.sessionFile, "utf8"),
update,
});
});
const result = await withSessionTranscriptWriteLock(scope, async (locked) => {
await locked.appendMessage({
message: {
role: "assistant",
content: [{ type: "text", text: "queued publish" }],
timestamp: 1,
},
});
await locked.publishUpdate({
messageId: "message-from-callback",
});
expect(emitSpy).not.toHaveBeenCalled();
callbackCompleted = true;
return "complete";
});
expect(result).toBe("complete");
expect(emitSpy).toHaveBeenCalledTimes(1);
expect(observedUpdates).toEqual([
{
callbackCompleted: true,
fileText: expect.stringContaining("queued publish"),
update: expect.objectContaining({
messageId: "message-from-callback",
agentId: "main",
sessionFile: scope.sessionFile,
sessionKey: scope.sessionKey,
}),
},
]);
});
it("does not publish queued locked updates when the callback throws", async () => {
const scope = {
agentId: "main",
sessionFile: path.join(tempDir, "failed-queued-publish-target.jsonl"),
sessionId: "failed-queued-publish-session",
sessionKey: "agent:main:main",
storePath,
};
const emitSpy = vi.spyOn(transcriptEvents, "emitSessionTranscriptUpdate");
await expect(
withSessionTranscriptWriteLock(scope, async (locked) => {
await locked.appendMessage({
message: {
role: "assistant",
content: [{ type: "text", text: "durable but failed" }],
timestamp: 1,
},
});
await locked.publishUpdate({ sessionKey: scope.sessionKey });
throw new Error("stop before commit");
}),
).rejects.toThrow("stop before commit");
expect(emitSpy).not.toHaveBeenCalled();
expect(fs.readFileSync(scope.sessionFile, "utf8")).toContain("durable but failed");
});
it("round-trips encoded memory hit keys with opaque session ids", () => {
const key = formatSessionTranscriptMemoryHitKey({
agentId: "SECONDARY",
sessionId: "my-plugin:task/1",
});
expect(key).toBe("transcript:secondary:my-plugin%3Atask%2F1");
expect(parseSessionTranscriptMemoryHitKey(key)).toEqual({
agentId: "secondary",
key,
sessionId: "my-plugin:task/1",
});
});
it("resolves memory hit keys by agent and session id instead of transcript basename", async () => {
const scope = {
agentId: "main",
sessionId: "session-id",
sessionKey: "agent:main:telegram:direct:123",
storePath,
};
await upsertSessionEntry(scope, {
sessionFile: path.join(tempDir, "legacy-file-name.jsonl"),
sessionId: scope.sessionId,
updatedAt: 10,
});
const keys = resolveSessionTranscriptMemoryHitKeyToSessionKeys({
key: formatSessionTranscriptMemoryHitKey(scope),
store: loadSessionStore(storePath),
});
expect(keys).toEqual(["agent:main:telegram:direct:123"]);
});
it("can avoid synthetic fallback keys for strict live-store checks", () => {
const key = formatSessionTranscriptMemoryHitKey({
agentId: "main",
sessionId: "deleted-session",
});
expect(resolveSessionTranscriptMemoryHitKeyToSessionKeys({ key, store: {} })).toEqual([
"agent:main:deleted-session",
]);
expect(
resolveSessionTranscriptMemoryHitKeyToSessionKeys({
includeSyntheticFallback: false,
key,
store: {},
}),
).toEqual([]);
});
});
@@ -0,0 +1,208 @@
import {
appendTranscriptMessage,
publishTranscriptUpdate,
resolveSessionTranscriptRuntimeReadTarget,
resolveSessionTranscriptRuntimeTarget,
type TranscriptMessageAppendOptions,
type TranscriptMessageAppendResult,
type TranscriptUpdatePayload,
} from "../config/sessions/session-accessor.js";
import { runSessionTranscriptAppendTransaction } from "../config/sessions/transcript-append.js";
import { streamSessionTranscriptLines } from "../config/sessions/transcript-stream.js";
import { normalizeAgentId } from "../routing/session-key.js";
import {
formatSessionTranscriptMemoryHitKey,
parseSessionTranscriptMemoryHitKey,
resolveSessionTranscriptMemoryHitKeyToSessionKeys,
type ResolveSessionTranscriptMemoryHitKeyParams,
type SessionTranscriptIdentity,
type SessionTranscriptMemoryHitIdentity,
type SessionTranscriptMemoryHitKey,
type SessionTranscriptMemoryHitKeyParams,
type SessionTranscriptReadParams,
} from "./session-transcript-memory-hit.js";
export {
formatSessionTranscriptMemoryHitKey,
parseSessionTranscriptMemoryHitKey,
resolveSessionTranscriptMemoryHitKeyToSessionKeys,
};
export type {
ResolveSessionTranscriptMemoryHitKeyParams,
SessionTranscriptIdentity,
SessionTranscriptMemoryHitIdentity,
SessionTranscriptMemoryHitKey,
SessionTranscriptMemoryHitKeyParams,
SessionTranscriptReadParams,
};
export type SessionTranscriptEvent = unknown;
export type SessionTranscriptTargetParams = SessionTranscriptReadParams & {
/**
* @deprecated Prefer `{ agentId, sessionKey, sessionId }`. Pass this only
* when adapting code that already receives an active transcript artifact and
* needs each helper to operate on that same artifact.
*/
sessionFile?: string;
};
export type SessionTranscriptTarget = SessionTranscriptIdentity & {
targetKind: "active-session-file" | "runtime-session";
};
export type SessionTranscriptAppendMessageParams<TMessage> = SessionTranscriptTargetParams &
TranscriptMessageAppendOptions<TMessage>;
export type SessionTranscriptWriteLockParams = SessionTranscriptTargetParams & {
config?: TranscriptMessageAppendOptions<unknown>["config"];
};
export type SessionTranscriptWriteLockContext = {
appendMessage: <TMessage>(
options: Omit<TranscriptMessageAppendOptions<TMessage>, "config">,
) => Promise<TranscriptMessageAppendResult<TMessage> | undefined>;
publishUpdate: (update?: TranscriptUpdatePayload) => Promise<void>;
readEvents: () => Promise<SessionTranscriptEvent[]>;
target: SessionTranscriptTarget;
};
/**
* Resolves the public identity for a transcript without returning its file path.
*/
export async function resolveSessionTranscriptIdentity(
params: SessionTranscriptReadParams,
): Promise<SessionTranscriptIdentity> {
const target = await resolveSessionTranscriptRuntimeReadTarget(params);
const agentId = normalizeAgentId(target.agentId);
return {
agentId,
memoryKey: formatSessionTranscriptMemoryHitKey({ agentId, sessionId: target.sessionId }),
sessionId: target.sessionId,
sessionKey: target.sessionKey,
};
}
/**
* Resolves the public target for transcript operations without exposing the
* current storage path as identity.
*/
export async function resolveSessionTranscriptTarget(
params: SessionTranscriptTargetParams,
): Promise<SessionTranscriptTarget> {
const target = await resolveSessionTranscriptRuntimeReadTarget(params);
return projectPublicTarget({
...target,
targetKind: params.sessionFile?.trim() ? "active-session-file" : "runtime-session",
});
}
/**
* Reads transcript events by public session identity instead of file path.
*/
export async function readSessionTranscriptEvents(
params: SessionTranscriptTargetParams,
): Promise<SessionTranscriptEvent[]> {
const target = await resolveSessionTranscriptRuntimeReadTarget(params);
const events: SessionTranscriptEvent[] = [];
for await (const line of streamSessionTranscriptLines(target.sessionFile)) {
try {
events.push(JSON.parse(line) as SessionTranscriptEvent);
} catch {
continue;
}
}
return events;
}
/**
* Appends a transcript message by scoped transcript target.
*/
export async function appendSessionTranscriptMessageByIdentity<TMessage>(
params: SessionTranscriptAppendMessageParams<TMessage>,
): Promise<TranscriptMessageAppendResult<TMessage> | undefined> {
return await appendTranscriptMessage(params, params);
}
/**
* Publishes a transcript update by scoped transcript target.
*/
export async function publishSessionTranscriptUpdateByIdentity(
params: SessionTranscriptTargetParams & { update?: TranscriptUpdatePayload },
): Promise<void> {
const target = await resolveSessionTranscriptRuntimeTarget(params);
await publishTranscriptUpdate(
{
...params,
sessionFile: target.sessionFile,
},
{
...params.update,
agentId: target.agentId,
sessionKey: target.sessionKey,
},
);
}
/**
* Runs transcript work under the write lock for the resolved scoped target.
*/
export async function withSessionTranscriptWriteLock<T>(
params: SessionTranscriptWriteLockParams,
run: (context: SessionTranscriptWriteLockContext) => Promise<T> | T,
): Promise<T> {
const storageTarget = await resolveSessionTranscriptRuntimeTarget(params);
const target = projectPublicTarget({
...storageTarget,
targetKind: params.sessionFile?.trim() ? "active-session-file" : "runtime-session",
});
const boundScope = {
...params,
sessionFile: storageTarget.sessionFile,
};
// Treat publishUpdate as a post-commit callback: future transactional stores
// must not expose updates when the scoped write callback fails.
const queuedUpdates: Array<TranscriptUpdatePayload | undefined> = [];
const result = await runSessionTranscriptAppendTransaction(
{
config: params.config,
transcriptPath: storageTarget.sessionFile,
},
(transaction) =>
run({
target,
readEvents: () => readSessionTranscriptEvents(boundScope),
appendMessage: (options) =>
transaction.appendMessage({
...options,
sessionId: params.sessionId,
}),
publishUpdate: async (update) => {
queuedUpdates.push(update ? { ...update } : undefined);
},
}),
);
for (const update of queuedUpdates) {
await publishSessionTranscriptUpdateByIdentity({
...boundScope,
update,
});
}
return result;
}
function projectPublicTarget(target: {
agentId: string;
sessionId: string;
sessionKey: string;
targetKind: SessionTranscriptTarget["targetKind"];
}): SessionTranscriptTarget {
const agentId = normalizeAgentId(target.agentId);
return {
agentId,
memoryKey: formatSessionTranscriptMemoryHitKey({ agentId, sessionId: target.sessionId }),
sessionId: target.sessionId,
sessionKey: target.sessionKey,
targetKind: target.targetKind,
};
}