merge(main): refresh realtime continuity repair

* commit '81e392b7ff43bcdb7a3be50a99adf65c3b0141b5':
  fix(channels): preserve filters in offline status diagnostics (#117570)
  perf(gateway): separate startup maintenance timing (#117577)
  refactor(tests): deduplicate database-first guard fixtures (#117594)
  fix(cli): align sessions cleanup label summary by visible width (#117459)
  perf(gateway): skip empty session reconciliation (#117589)
  refactor(ui): consolidate cron form control rendering (#117576)
  fix(memory): recover restored session freshness (#117548)
  fix(ui): preserve Talk transcript surrogate bounds
  fix(ui): harden Talk transcript marker bounds
  test(ui): cover bounded realtime Talk entries
  fix(ui): bound realtime Talk conversation text
This commit is contained in:
Vincent Koc
2026-08-02 03:59:46 +08:00
18 changed files with 1839 additions and 2064 deletions
@@ -106,6 +106,12 @@ describe("memory session sync state", () => {
mtimeMs: 250,
size: 20,
},
{
absPath: "/tmp/sessions/rolled-back.jsonl",
path: "sessions/rolled-back.jsonl",
mtimeMs: 150,
size: 20,
},
{
absPath: "/tmp/sessions/resized.jsonl",
path: "sessions/resized.jsonl",
@@ -124,6 +130,7 @@ describe("memory session sync state", () => {
{ path: "sessions/sub-ms-newer.jsonl", hash: "hash-sub-ms", mtime: 100.25, size: 10 },
{ path: "sessions/invalidated.jsonl", hash: "", mtime: 200, size: 20 },
{ path: "sessions/newer.jsonl", hash: "hash-newer", mtime: 200, size: 20 },
{ path: "sessions/rolled-back.jsonl", hash: "hash-rolled-back", mtime: 200, size: 20 },
{ path: "sessions/resized.jsonl", hash: "hash-resized", mtime: 300, size: 30 },
],
});
@@ -132,6 +139,7 @@ describe("memory session sync state", () => {
"/tmp/sessions/sub-ms-newer.jsonl",
"/tmp/sessions/invalidated.jsonl",
"/tmp/sessions/newer.jsonl",
"/tmp/sessions/rolled-back.jsonl",
"/tmp/sessions/resized.jsonl",
"/tmp/sessions/missing.jsonl",
]);
@@ -26,7 +26,9 @@ export function resolveMemorySessionStartupDirtyFiles(params: {
dirtyFiles.push(file.absPath);
continue;
}
if (file.size !== indexedSize || file.mtimeMs > indexedMtimeMs) {
// File mtimes and SQLite session updatedAt values can move backward after
// restore/reset. The downstream content-hash gate suppresses unchanged rewrites.
if (file.size !== indexedSize || file.mtimeMs !== indexedMtimeMs) {
dirtyFiles.push(file.absPath);
}
}
@@ -174,6 +174,29 @@ export abstract class MemoryManagerSourceSyncOps extends MemoryManagerSessionSyn
const deleteChunksByPathAndSource = this.db.prepare(
`DELETE FROM memory_index_chunks WHERE path = ? AND source = ?`,
);
const updateUnchangedSessionSourceMetadata = this.db.prepare(
`UPDATE memory_index_sources
SET mtime = ?, size = ?
WHERE path = ? AND source = 'sessions' AND hash = ?`,
);
const refreshUnchangedSessionSourceMetadata = (entry: MemoryIndexEntry): boolean => {
// Hash equality preserves chunks and embeddings; only converge the source
// fingerprint so restored sessions do not repeat catch-up on every startup.
return (
updateUnchangedSessionSourceMetadata.run(entry.mtimeMs, entry.size, entry.path, entry.hash)
.changes === 1
);
};
const canSkipUnchangedSessionEntry = (
entry: MemoryIndexEntry,
absPath: string,
existingHash: string | undefined,
): boolean => {
if (params.needsFullReindex || existingHash !== entry.hash) {
return false;
}
return !this.sessionsDirtyFiles.has(absPath) || refreshUnchangedSessionSourceMetadata(entry);
};
const deleteFtsRowsByPathAndSource =
this.fts.enabled && this.fts.available
? this.db.prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ?`)
@@ -340,7 +363,7 @@ export abstract class MemoryManagerSourceSyncOps extends MemoryManagerSessionSyn
path: entry.path,
existingHashes,
});
if (!params.needsFullReindex && existingHash === entry.hash) {
if (canSkipUnchangedSessionEntry(entry, absPath, existingHash)) {
if (params.progress) {
params.progress.completed += 1;
params.progress.report({
@@ -412,7 +435,7 @@ export abstract class MemoryManagerSourceSyncOps extends MemoryManagerSessionSyn
path: entry.path,
existingHashes,
});
if (!params.needsFullReindex && existingHash === entry.hash) {
if (canSkipUnchangedSessionEntry(entry, absPath, existingHash)) {
if (params.progress) {
params.progress.completed += 1;
params.progress.report({
@@ -2,13 +2,16 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { DatabaseSync } from "node:sqlite";
import {
resolveSessionTranscriptsDirForAgent,
type OpenClawConfig,
type ResolvedMemorySearchConfig,
} from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
import { statSessionEntrySync } from "openclaw/plugin-sdk/memory-core-host-engine-qmd";
import {
buildSessionEntry,
statSessionEntrySync,
} from "openclaw/plugin-sdk/memory-core-host-engine-qmd";
import {
MEMORY_CHUNKING_VERSION,
type MemorySource,
@@ -64,8 +67,43 @@ type MemorySessionTranscriptUpdate = {
const originalStartupStateDir = process.env.OPENCLAW_STATE_DIR;
const originalStartupConfigPath = process.env.OPENCLAW_CONFIG_PATH;
let transcriptUpdateListener: ((update: MemorySessionTranscriptUpdate) => void) | undefined;
const startupHarnessDatabases = new Set<DatabaseSync>();
type SourceStateRow = { path: string; hash: string; mtime: number; size: number };
function createStartupHarnessDatabase(sourceRows: SourceStateRow[]): DatabaseSync {
const db = new DatabaseSync(":memory:");
db.exec(`
CREATE TABLE memory_index_sources (
path TEXT NOT NULL,
source TEXT NOT NULL,
hash TEXT NOT NULL,
mtime REAL NOT NULL,
size INTEGER NOT NULL,
UNIQUE(path, source)
);
CREATE TABLE memory_index_chunks (
id TEXT PRIMARY KEY,
path TEXT NOT NULL,
source TEXT NOT NULL,
model TEXT NOT NULL
);
CREATE TABLE memory_index_source_update_audit (path TEXT NOT NULL);
CREATE TRIGGER memory_index_source_update_audit_trigger
AFTER UPDATE ON memory_index_sources
BEGIN
INSERT INTO memory_index_source_update_audit (path) VALUES (NEW.path);
END;
`);
const insert = db.prepare(
`INSERT INTO memory_index_sources (path, source, hash, mtime, size) VALUES (?, 'sessions', ?, ?, ?)`,
);
for (const row of sourceRows) {
insert.run(row.path, row.hash, row.mtime, row.size);
}
startupHarnessDatabases.add(db);
return db;
}
function setStartupStateDir(stateDir: string): void {
Reflect.set(process.env, "OPENCLAW_STATE_DIR", stateDir);
}
@@ -148,16 +186,37 @@ class SessionStartupCatchupHarness extends MemoryManagerSyncOps {
sourceRows: SourceStateRow[],
private readonly indexSessionUpdates = false,
private readonly subscribeToRealEvents = false,
private readonly deferSessionIndex = false,
database?: DatabaseSync,
) {
super();
this.sources.add("sessions");
this.db = {
prepare: () => ({
all: () => sourceRows,
get: () => undefined,
run: () => undefined,
}),
} as unknown as DatabaseSync;
this.db = database ?? createStartupHarnessDatabase(sourceRows);
}
restartForStartup(): SessionStartupCatchupHarness {
return new SessionStartupCatchupHarness(
[],
this.indexSessionUpdates,
false,
this.deferSessionIndex,
this.db,
);
}
getIndexedSourceState(pathname: string): SourceStateRow | undefined {
return this.db
.prepare(
`SELECT path, hash, mtime, size FROM memory_index_sources WHERE path = ? AND source = 'sessions'`,
)
.get(pathname) as SourceStateRow | undefined;
}
getSourceMetadataUpdateCount(): number {
const row = this.db
.prepare(`SELECT COUNT(*) AS count FROM memory_index_source_update_audit`)
.get() as { count: number };
return row.count;
}
async catchUp(): Promise<string[]> {
@@ -172,6 +231,13 @@ class SessionStartupCatchupHarness extends MemoryManagerSyncOps {
await this.runSync(params);
}
async runArchiveSyncForTest(): Promise<void> {
await this.syncArchiveFiles({
needsFullReindex: false,
deferIndex: this.deferSessionIndex,
});
}
getDirtyArchiveFiles(): string[] {
return Array.from(this.sessionsDirtyFiles);
}
@@ -273,7 +339,10 @@ class SessionStartupCatchupHarness extends MemoryManagerSyncOps {
protected async sync(params?: MemorySyncParams): Promise<void> {
this.syncCalls.push(params ?? {});
this.pendingSyncWork = this.indexSessionUpdates
? this.syncArchiveFiles({ needsFullReindex: false }).then(() => undefined)
? this.syncArchiveFiles({
needsFullReindex: false,
deferIndex: this.deferSessionIndex,
}).then(() => undefined)
: Promise.resolve();
await this.pendingSyncWork;
}
@@ -333,20 +402,29 @@ describe("session startup catch-up", () => {
restoreStartupEnv();
clearRuntimeConfigSnapshot();
clearConfigCache();
for (const database of startupHarnessDatabases) {
database.close();
}
startupHarnessDatabases.clear();
closeOpenClawAgentDatabasesForTest();
await fs.rm(stateDir, { recursive: true, force: true });
});
async function writeSessionFile(
name: string,
content = "startup catchup",
timestamp?: string,
): Promise<{ filePath: string; size: number; mtimeMs: number }> {
const sessionsDir = resolveSessionTranscriptsDirForAgent("main");
await fs.mkdir(sessionsDir, { recursive: true });
const filePath = path.join(sessionsDir, name);
await fs.writeFile(
filePath,
JSON.stringify({ type: "message", message: { role: "user", content: "startup catchup" } }) +
"\n",
JSON.stringify({
type: "message",
...(timestamp ? { timestamp } : {}),
message: { role: "user", content },
}) + "\n",
"utf-8",
);
const stat = await fs.stat(filePath);
@@ -533,6 +611,169 @@ describe("session startup catch-up", () => {
expect(harness.syncCalls).toEqual([]);
});
it("indexes a same-size file transcript whose mtime rolled back", async () => {
const archiveName = "thread.jsonl.deleted.2026-08-01T10-00-00.000Z";
const original = await writeSessionFile(archiveName, "version before");
const originalEntry = await buildSessionEntry(original.filePath);
if (!originalEntry) {
throw new Error("expected original file transcript entry");
}
const replacement = await writeSessionFile(archiveName, "version after!");
expect(replacement.size).toBe(original.size);
const rolledBackMtime = new Date(Math.max(1, original.mtimeMs - 60_000));
await fs.utimes(replacement.filePath, rolledBackMtime, rolledBackMtime);
const rolledBack = await fs.stat(replacement.filePath);
expect(rolledBack.mtimeMs).toBeLessThan(original.mtimeMs);
const harness = new SessionStartupCatchupHarness(
[
{
path: originalEntry.path,
hash: originalEntry.hash,
mtime: original.mtimeMs,
size: original.size,
},
],
true,
);
await expect(harness.catchUp()).resolves.toEqual([replacement.filePath]);
await harness.waitForSessionSync();
expect(harness.syncCalls).toEqual([{ reason: "session-startup-catchup" }]);
expect(harness.indexedPaths).toEqual([`sessions/main/${archiveName}`]);
expect(harness.indexedContents).toEqual(["User: version after!"]);
});
it("converges an unchanged file mtime rollback after direct session sync", async () => {
const archiveName = "thread.jsonl.deleted.2026-08-01T11-00-00.000Z";
const messageTimestamp = "2026-08-01T10:30:00.000Z";
const original = await writeSessionFile(archiveName, "unchanged content", messageTimestamp);
const originalEntry = await buildSessionEntry(original.filePath);
if (!originalEntry) {
throw new Error("expected original file transcript entry");
}
const rolledBackMtime = new Date(Math.max(1, original.mtimeMs - 60_000));
await fs.utimes(original.filePath, rolledBackMtime, rolledBackMtime);
const restoredEntry = await buildSessionEntry(original.filePath);
if (!restoredEntry) {
throw new Error("expected restored file transcript entry");
}
expect(restoredEntry.hash).toBe(originalEntry.hash);
const harness = new SessionStartupCatchupHarness(
[
{
path: originalEntry.path,
hash: originalEntry.hash,
mtime: original.mtimeMs,
size: original.size,
},
],
true,
);
await expect(harness.catchUp()).resolves.toEqual([original.filePath]);
await harness.waitForSessionSync();
expect(harness.indexedPaths).toEqual([]);
expect(harness.getIndexedSourceState(originalEntry.path)).toEqual({
path: originalEntry.path,
hash: originalEntry.hash,
mtime: restoredEntry.mtimeMs,
size: restoredEntry.size,
});
expect(harness.getSourceMetadataUpdateCount()).toBe(1);
const restarted = harness.restartForStartup();
await expect(restarted.catchUp()).resolves.toEqual([]);
expect(restarted.syncCalls).toEqual([]);
expect(restarted.indexedPaths).toEqual([]);
});
it("indexes a SQLite transcript whose updatedAt rolled back", async () => {
const session = await writeSqliteSession({
content: "SQLite rollback",
updatedAt: 10,
});
const state = statSessionEntrySync(session.sessionKey, {
agentId: "main",
sessionId: session.sessionId,
storePath: session.storePath,
sessionKey: session.sessionKey,
updatedAtMs: 10,
});
if (!state) {
throw new Error("expected SQLite transcript state");
}
const harness = new SessionStartupCatchupHarness(
[
{
path: state.path,
hash: "previous-hash",
mtime: 20,
size: state.size,
},
],
true,
);
await expect(harness.catchUp()).resolves.toEqual([session.sessionKey]);
await harness.waitForSessionSync();
expect(harness.syncCalls).toEqual([{ reason: "session-startup-catchup" }]);
expect(harness.indexedPaths).toEqual([session.corpusPath]);
expect(harness.indexedContents).toEqual(["User: SQLite rollback"]);
});
it("converges an unchanged SQLite updatedAt rollback after deferred session sync", async () => {
const session = await writeSqliteSession({ updatedAt: 10 });
const entry = await buildSessionEntry(session.sessionKey, {
agentId: "main",
sessionId: session.sessionId,
storePath: session.storePath,
sessionKey: session.sessionKey,
updatedAtMs: 10,
sessionKind: "interactive",
});
if (!entry) {
throw new Error("expected SQLite transcript entry");
}
const harness = new SessionStartupCatchupHarness(
[
{
path: entry.path,
hash: entry.hash,
mtime: 20,
size: entry.size,
},
],
true,
false,
true,
);
await expect(harness.catchUp()).resolves.toEqual([session.sessionKey]);
await harness.waitForSessionSync();
expect(harness.syncCalls).toEqual([{ reason: "session-startup-catchup" }]);
expect(harness.indexedPaths).toEqual([]);
expect(harness.indexedContents).toEqual([]);
expect(harness.getIndexedSourceState(entry.path)).toEqual({
path: entry.path,
hash: entry.hash,
mtime: entry.mtimeMs,
size: entry.size,
});
expect(harness.getSourceMetadataUpdateCount()).toBe(1);
const restarted = harness.restartForStartup();
await expect(restarted.catchUp()).resolves.toEqual([]);
expect(restarted.syncCalls).toEqual([]);
expect(restarted.indexedPaths).toEqual([]);
await restarted.runArchiveSyncForTest();
expect(restarted.getSourceMetadataUpdateCount()).toBe(1);
});
it("does not fall back to full session sync when identity targets normalize away", async () => {
await writeSessionFile("thread.jsonl");
const harness = new SessionStartupCatchupHarness([]);
@@ -11,6 +11,10 @@ vi.mock("../channels/plugins/index.js", () => ({
listChannelPlugins: () => activeChannelPlugins,
getLoadedChannelPlugin: (id: string) => activeChannelPlugins.find((plugin) => plugin.id === id),
getChannelPlugin: (id: string) => activeChannelPlugins.find((plugin) => plugin.id === id),
normalizeChannelId: (value: string) => {
const normalized = value.trim().toLowerCase();
return normalized === "wa" || normalized === "whatsapp" ? "whatsapp" : null;
},
}));
vi.mock("../channels/plugins/read-only.js", () => ({
@@ -25,12 +29,18 @@ async function formatLocalStatusSummary(
cfg: unknown,
options?: {
sourceConfig?: unknown;
channel?: string;
},
) {
const lines = await formatConfigChannelsStatusLines(
cfg as never,
{ mode: "local" },
options?.sourceConfig ? { sourceConfig: options.sourceConfig as never } : undefined,
options
? {
...(options.sourceConfig ? { sourceConfig: options.sourceConfig as never } : {}),
...(options.channel !== undefined ? { channel: options.channel } : {}),
}
: undefined,
);
return lines.join("\n");
}
@@ -193,6 +203,68 @@ function requireReadOnlyPluginListCall(): unknown[] {
}
describe("config-only channels status output", () => {
it.each([
{
label: "unregistered external channels",
channel: "token-only",
included: ["TokenOnly"],
excluded: ["WhatsApp"],
},
{
label: "case-insensitive external channels",
channel: "TOKEN-ONLY",
included: ["TokenOnly"],
excluded: ["WhatsApp"],
},
{
label: "bundled channel aliases",
channel: "wa",
included: ["WhatsApp"],
excluded: ["TokenOnly"],
},
{
label: "unknown channels",
channel: "missing-channel",
included: [],
excluded: ["TokenOnly", "WhatsApp"],
},
{
label: "no channel filter",
channel: undefined,
included: ["TokenOnly", "WhatsApp"],
excluded: [],
},
{
label: "blank channel filters",
channel: " ",
included: ["TokenOnly", "WhatsApp"],
excluded: [],
},
])(
"preserves exact config-only status filtering for $label",
async ({ channel, included, excluded }) => {
activeChannelPlugins.splice(
0,
activeChannelPlugins.length,
makeUnavailableTokenPlugin(),
makeIndeterminateLinkPlugin(),
);
const summary = await formatLocalStatusSummary(
{ channels: { "token-only": {}, whatsapp: {} } },
{ channel },
);
expect(summary).toContain("Gateway not reachable; showing config-only status.");
for (const label of included) {
expect(summary).toContain(label);
}
for (const label of excluded) {
expect(summary).not.toContain(label);
}
},
);
it("uses setup fallback plugins so configured external channels can be shown", async () => {
registerSingleTestPlugin("token-only", makeUnavailableTokenPlugin());
listReadOnlyChannelPluginsForConfig.mockClear();
@@ -1,4 +1,5 @@
// Config-only channel status formatter used when the gateway is unreachable.
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { formatDocsLink } from "../../../packages/terminal-core/src/links.js";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import {
@@ -68,7 +69,9 @@ export async function formatConfigChannelsStatusLines(
});
const sourceConfig = opts?.sourceConfig ?? cfg;
const requestedChannel = opts?.channel ? normalizeChannelId(opts.channel) : null;
const requestedChannel = opts?.channel
? (normalizeChannelId(opts.channel) ?? normalizeOptionalLowercaseString(opts.channel))
: null;
const plugins = listReadOnlyChannelPluginsForConfig(cfg, {
activationSourceConfig: sourceConfig,
includeSetupFallbackPlugins: true,
+71
View File
@@ -1,5 +1,6 @@
// Sessions cleanup tests cover stale session cleanup and runtime output.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { visibleWidth } from "../../packages/terminal-core/src/ansi.js";
import type { SessionEntry } from "../config/sessions.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -643,6 +644,76 @@ describe("sessionsCleanupCommand", () => {
expectLogsToInclude(logs, "Total: 3 kept, 4 pruned");
});
it("aligns the label summary columns for emoji and CJK labels", async () => {
mocks.enforceSessionDiskBudget.mockResolvedValue(null);
mocks.runSessionsCleanup.mockResolvedValue({
mode: "warn",
previewResults: [
{
summary: {
agentId: "main",
storePath: "/resolved/sessions.json",
mode: "warn",
dryRun: true,
beforeCount: 2,
afterCount: 2,
missing: 0,
dmScopeRetired: 0,
pruned: 0,
capped: 0,
unreferencedArtifacts: {
scannedFiles: 0,
removedFiles: 0,
freedBytes: 0,
olderThanMs: 604800000,
},
diskBudget: null,
wouldMutate: true,
},
beforeStore: {
emojiKept: {
sessionId: "emoji-kept",
updatedAt: 2,
model: "test:opus",
label: "🔥修复",
},
plainKept: {
sessionId: "plain-kept",
updatedAt: 1,
model: "test:opus",
label: "plain",
},
},
missingKeys: new Set<string>(),
staleKeys: new Set<string>(),
cappedKeys: new Set<string>(),
budgetEvictedKeys: new Set<string>(),
dmScopeRetiredKeys: new Set<string>(),
},
],
appliedSummaries: [],
});
const { runtime, logs } = makeRuntime();
await sessionsCleanupCommand(
{
dryRun: true,
},
runtime,
);
expectLogsToInclude(logs, "Summary by Label:");
const summaryLogs = logs.slice(logs.indexOf("Summary by Label:") + 1);
const emojiLine = summaryLogs.find((line) => line.includes("🔥修复"));
const plainLine = summaryLogs.find((line) => line.includes("plain"));
expect(emojiLine).toBeDefined();
expect(plainLine).toBeDefined();
// "🔥修复" is 6 visible columns (wide emoji + 2 CJK) but only 5 UTF-16 code
// units; padding by code-unit length would shift the counts column left.
const keptColumn = (line: string) => visibleWidth(line.slice(0, line.indexOf("1 kept")));
expect(keptColumn(emojiLine ?? "")).toBe(keptColumn(plainLine ?? ""));
});
it("returns grouped JSON for --all-agents dry-runs", async () => {
mocks.resolveSessionStoreTargets.mockReturnValue([
{ agentId: "main", storePath: "/resolved/main-sessions.json" },
+5 -4
View File
@@ -4,6 +4,7 @@
* It can delegate cleanup to a live gateway or run local store maintenance,
* with dry-run tables that explain every planned pruning action.
*/
import { visibleWidth } from "../../packages/terminal-core/src/ansi.js";
import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js";
import { isRich, theme } from "../../packages/terminal-core/src/theme.js";
import { getRuntimeConfig } from "../config/config.js";
@@ -129,15 +130,15 @@ function renderLabelSummaries(params: {
if (summaries.length === 0) {
return;
}
const labelPad = Math.max(...summaries.map((summary) => summary.label.length));
const labelPad = Math.max(...summaries.map((summary) => visibleWidth(summary.label)));
const totalKept = summaries.reduce((total, summary) => total + summary.kept, 0);
const totalPruned = summaries.reduce((total, summary) => total + summary.pruned, 0);
params.runtime.log("");
params.runtime.log("Summary by Label:");
for (const summary of summaries) {
params.runtime.log(
`${summary.label.padEnd(labelPad)} ${summary.kept} kept, ${summary.pruned} pruned`,
);
const remaining = labelPad - visibleWidth(summary.label);
const paddedLabel = remaining > 0 ? `${summary.label}${" ".repeat(remaining)}` : summary.label;
params.runtime.log(`${paddedLabel} ${summary.kept} kept, ${summary.pruned} pruned`);
}
params.runtime.log(`Total: ${totalKept} kept, ${totalPruned} pruned`);
}
+10 -2
View File
@@ -475,12 +475,20 @@ export async function prepareGatewayServerBootstrap(input: {
const workerModule = await loadWorkerEnvironmentStartupModule();
return await workerModule.loadGatewayWorkerEnvironmentStartupState();
});
const { prepareGatewayPluginBootstrap } = await loadStartupPluginsModule();
const { prepareGatewayPluginBootstrap, runGatewayStartupMaintenance } =
await loadStartupPluginsModule();
await startupTrace.measure("startup.maintenance", () =>
runGatewayStartupMaintenance({
cfgAtStart,
startupRuntimeConfig,
minimalTestGateway,
log,
}),
);
const pluginBootstrap = await startupTrace.measure("plugins.bootstrap", () =>
prepareGatewayPluginBootstrap({
cfgAtStart,
activationSourceConfig: startupActivationSourceConfig,
startupRuntimeConfig,
pluginMetadataSnapshot: startupConfigLoad.pluginMetadataSnapshot,
workerProviderIds: workerEnvironmentStartup?.durableProviderIds ?? [],
minimalTestGateway,
+102 -3
View File
@@ -108,6 +108,12 @@ const listAmbientOnlyConfiguredChannelIds = vi.hoisted(() =>
vi.fn((_params: unknown) => [] as string[]),
);
const runStartupSessionMigration = vi.hoisted(() => vi.fn(async (_params: unknown) => undefined));
const migrateLegacyDevicePairingStore = vi.hoisted(() =>
vi.fn(async (_params: unknown) => undefined),
);
const migrateLegacyNodePairingStore = vi.hoisted(() =>
vi.fn(async (_params: unknown) => undefined),
);
vi.mock("../agents/agent-scope.js", () => ({
resolveAgentWorkspaceDir: () => "/workspace",
resolveDefaultAgentId: () => "default",
@@ -130,6 +136,14 @@ vi.mock("../infra/openclaw-root.js", () => ({
resolveOpenClawPackageRootSync: (params: unknown) => resolveOpenClawPackageRootSync(params),
}));
vi.mock("../infra/device-pairing-migration.js", () => ({
migrateLegacyDevicePairingStore: (params: unknown) => migrateLegacyDevicePairingStore(params),
}));
vi.mock("../infra/node-pairing-migration.js", () => ({
migrateLegacyNodePairingStore: (params: unknown) => migrateLegacyNodePairingStore(params),
}));
vi.mock("../plugins/channel-presence-policy.js", () => ({
listAmbientOnlyConfiguredChannelIds: (params: unknown) =>
listAmbientOnlyConfiguredChannelIds(params),
@@ -222,7 +236,6 @@ async function prepareBootstrapWithRuntimeConfig(
return await prepareGatewayPluginBootstrap({
cfgAtStart: cfg,
startupRuntimeConfig: cfg,
minimalTestGateway: false,
log,
...options,
@@ -246,6 +259,83 @@ function expectStartupPluginLoad(params: {
expect(startupInput.suppressPluginInfoLogs).toBe(params.suppressPluginInfoLogs);
}
describe("runGatewayStartupMaintenance", () => {
beforeEach(() => {
runChannelPluginStartupMaintenance.mockClear();
runStartupSessionMigration.mockClear();
migrateLegacyDevicePairingStore.mockClear();
migrateLegacyNodePairingStore.mockClear();
});
it("runs channel, session, and ordered pairing maintenance for a normal gateway", async () => {
const log = createLog();
const { runGatewayStartupMaintenance } = await import("./server-startup-plugins.js");
await runGatewayStartupMaintenance({
cfgAtStart: {},
startupRuntimeConfig: {},
minimalTestGateway: false,
log,
});
expect(runChannelPluginStartupMaintenance).toHaveBeenCalledWith({
cfg: {},
env: process.env,
log,
});
expect(runStartupSessionMigration).toHaveBeenCalledWith({
cfg: {},
env: process.env,
log,
});
expect(migrateLegacyDevicePairingStore).toHaveBeenCalledWith({ log });
expect(migrateLegacyNodePairingStore).toHaveBeenCalledWith({ log });
const deviceMigrationOrder = migrateLegacyDevicePairingStore.mock.invocationCallOrder[0];
const nodeMigrationOrder = migrateLegacyNodePairingStore.mock.invocationCallOrder[0];
expect(deviceMigrationOrder).toBeDefined();
expect(nodeMigrationOrder).toBeDefined();
expect(deviceMigrationOrder!).toBeLessThan(nodeMigrationOrder!);
});
it("skips maintenance for a minimal gateway without channel config", async () => {
const { runGatewayStartupMaintenance } = await import("./server-startup-plugins.js");
await runGatewayStartupMaintenance({
cfgAtStart: {},
startupRuntimeConfig: {},
minimalTestGateway: true,
log: createLog(),
});
expect(runChannelPluginStartupMaintenance).not.toHaveBeenCalled();
expect(runStartupSessionMigration).not.toHaveBeenCalled();
expect(migrateLegacyDevicePairingStore).not.toHaveBeenCalled();
expect(migrateLegacyNodePairingStore).not.toHaveBeenCalled();
});
it("runs only channel maintenance for a minimal gateway with recovered channel config", async () => {
const log = createLog();
const recoveredConfig = slackConfig();
const { runGatewayStartupMaintenance } = await import("./server-startup-plugins.js");
await runGatewayStartupMaintenance({
cfgAtStart: {},
startupRuntimeConfig: recoveredConfig,
minimalTestGateway: true,
log,
});
expect(runChannelPluginStartupMaintenance).toHaveBeenCalledWith({
cfg: recoveredConfig,
env: process.env,
log,
});
expect(runStartupSessionMigration).not.toHaveBeenCalled();
expect(migrateLegacyDevicePairingStore).not.toHaveBeenCalled();
expect(migrateLegacyNodePairingStore).not.toHaveBeenCalled();
});
});
describe("prepareGatewayPluginBootstrap startup plugins", () => {
beforeEach(() => {
applyPluginAutoEnable.mockClear();
@@ -263,7 +353,18 @@ describe("prepareGatewayPluginBootstrap startup plugins", () => {
resolveOpenClawPackageRootSync.mockClear().mockReturnValue("/package");
runChannelPluginStartupMaintenance.mockClear();
runStartupSessionMigration.mockClear();
migrateLegacyDevicePairingStore.mockClear();
migrateLegacyNodePairingStore.mockClear();
});
it("does not run startup maintenance", async () => {
await prepareBootstrapWithRuntimeConfig({});
expect(runChannelPluginStartupMaintenance).not.toHaveBeenCalled();
expect(runStartupSessionMigration).not.toHaveBeenCalled();
expect(migrateLegacyDevicePairingStore).not.toHaveBeenCalled();
expect(migrateLegacyNodePairingStore).not.toHaveBeenCalled();
});
it("derives startup activation from source config instead of runtime plugin defaults", async () => {
const sourceConfig = {
channels: {
@@ -328,7 +429,6 @@ describe("prepareGatewayPluginBootstrap startup plugins", () => {
await prepareGatewayPluginBootstrap({
cfgAtStart: runtimeConfig,
activationSourceConfig: sourceConfig,
startupRuntimeConfig: runtimeConfig,
pluginMetadataSnapshot,
minimalTestGateway: false,
log,
@@ -435,7 +535,6 @@ describe("prepareGatewayPluginBootstrap startup plugins", () => {
const { prepareGatewayPluginBootstrap } = await import("./server-startup-plugins.js");
const result = await prepareGatewayPluginBootstrap({
cfgAtStart: { channels: {} },
startupRuntimeConfig: { channels: {} },
minimalTestGateway: false,
ambientEnvTriggers: "suppress",
log,
+18 -12
View File
@@ -1,5 +1,4 @@
// Gateway plugin startup bootstrap.
// Runs startup maintenance, loads plugin runtime, and prepares advertised methods.
// Gateway plugin startup bootstrap and adjacent startup maintenance.
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { initSubagentRegistry } from "../agents/subagent-registry.js";
import type { AmbientEnvTriggerPolicy } from "../channels/config-presence.js";
@@ -45,20 +44,13 @@ export function resolveGatewayStartupMaintenanceConfig(params: {
: params.cfgAtStart;
}
/** Builds plugin startup state and gateway method lists before the server binds. */
export async function prepareGatewayPluginBootstrap(params: {
/** Runs channel, session, and pairing maintenance before plugin bootstrap. */
export async function runGatewayStartupMaintenance(params: {
cfgAtStart: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
startupRuntimeConfig: OpenClawConfig;
pluginMetadataSnapshot?: PluginMetadataSnapshot;
workerProviderIds?: readonly string[];
minimalTestGateway: boolean;
log: GatewayPluginBootstrapLog;
loadRuntimePlugins?: boolean;
loadSetupRuntimePlugins?: boolean;
ambientEnvTriggers?: AmbientEnvTriggerPolicy;
}) {
const activationSourceConfig = params.activationSourceConfig ?? params.cfgAtStart;
}): Promise<void> {
const startupMaintenanceConfig = resolveGatewayStartupMaintenanceConfig({
cfgAtStart: params.cfgAtStart,
startupRuntimeConfig: params.startupRuntimeConfig,
@@ -110,7 +102,21 @@ export async function prepareGatewayPluginBootstrap(params: {
}
await Promise.all(startupTasks);
}
}
/** Builds plugin startup state and gateway method lists before the server binds. */
export async function prepareGatewayPluginBootstrap(params: {
cfgAtStart: OpenClawConfig;
activationSourceConfig?: OpenClawConfig;
pluginMetadataSnapshot?: PluginMetadataSnapshot;
workerProviderIds?: readonly string[];
minimalTestGateway: boolean;
log: GatewayPluginBootstrapLog;
loadRuntimePlugins?: boolean;
loadSetupRuntimePlugins?: boolean;
ambientEnvTriggers?: AmbientEnvTriggerPolicy;
}) {
const activationSourceConfig = params.activationSourceConfig ?? params.cfgAtStart;
initSubagentRegistry();
// Activation uses the pre-runtime source so auto-enable policy cannot be skewed by
@@ -1,7 +1,11 @@
/**
* Gateway startup session migration tests.
*/
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { resolveOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.paths.js";
import { runStartupSessionMigration } from "./server-startup-session-migration.js";
type StartupMigrationDeps = NonNullable<Parameters<typeof runStartupSessionMigration>[0]["deps"]>;
@@ -14,6 +18,7 @@ type RunDoctorSessionSqlite = NonNullable<StartupMigrationDeps["runDoctorSession
type ReconcileSessionTranscriptIndexes = NonNullable<
StartupMigrationDeps["reconcileSessionTranscriptIndexes"]
>;
type SessionSqliteDatabaseExists = NonNullable<StartupMigrationDeps["sessionSqliteDatabaseExists"]>;
function makeLog() {
return {
@@ -50,9 +55,16 @@ function makeDeps(
.mockResolvedValue(0),
runDoctorSessionSqlite,
reconcileSessionTranscriptIndexes,
sessionSqliteDatabaseExists: vi.fn<SessionSqliteDatabaseExists>().mockReturnValue(true),
};
}
function useDefaultDatabaseExists(deps: ReturnType<typeof makeDeps>): StartupMigrationDeps {
const defaultDeps: StartupMigrationDeps = { ...deps };
delete defaultDeps.sessionSqliteDatabaseExists;
return defaultDeps;
}
function firstLogMessage(log: ReturnType<typeof vi.fn>, label: string): string {
const [message] = log.mock.calls[0] ?? [];
if (typeof message !== "string") {
@@ -231,6 +243,105 @@ describe("runStartupSessionMigration", () => {
);
});
it("skips transcript reconciliation when configured agents have no SQLite database", async () => {
const log = makeLog();
const migrate = vi.fn<MigrateSessionKeys>().mockResolvedValue({ changes: [], warnings: [] });
const reconcile = vi
.fn<ReconcileSessionTranscriptIndexes>()
.mockResolvedValue({ reconciledSessions: 0 });
const deps = makeDeps(migrate, 0, makeSessionSqliteImport(), reconcile);
const defaultDeps = useDefaultDatabaseExists(deps);
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-startup-"));
const env = { OPENCLAW_STATE_DIR: stateDir };
try {
await runStartupSessionMigration({
cfg: {
agents: { defaults: {}, list: [{ id: "main" }, { id: "ops" }] },
session: {},
} as Parameters<typeof runStartupSessionMigration>[0]["cfg"],
env,
log,
deps: defaultDeps,
});
expect(reconcile).not.toHaveBeenCalled();
expect(fs.existsSync(resolveOpenClawAgentSqlitePath({ agentId: "main", env }))).toBe(false);
expect(fs.existsSync(resolveOpenClawAgentSqlitePath({ agentId: "ops", env }))).toBe(false);
} finally {
fs.rmSync(stateDir, { force: true, recursive: true });
}
});
it("reconciles configured agents with an existing SQLite database", async () => {
const log = makeLog();
const migrate = vi.fn<MigrateSessionKeys>().mockResolvedValue({ changes: [], warnings: [] });
const reconcile = vi
.fn<ReconcileSessionTranscriptIndexes>()
.mockResolvedValue({ reconciledSessions: 1 });
const deps = makeDeps(migrate, 0, makeSessionSqliteImport(), reconcile);
const defaultDeps = useDefaultDatabaseExists(deps);
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-startup-"));
const env = { OPENCLAW_STATE_DIR: stateDir };
const databasePath = resolveOpenClawAgentSqlitePath({ agentId: "ops", env });
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
fs.writeFileSync(databasePath, "");
try {
await runStartupSessionMigration({
cfg: {
agents: { defaults: {}, list: [{ id: "main" }, { id: "ops" }] },
session: {},
} as Parameters<typeof runStartupSessionMigration>[0]["cfg"],
env,
log,
deps: defaultDeps,
});
expect(reconcile).toHaveBeenCalledOnce();
expect(reconcile).toHaveBeenCalledWith({ agentId: "ops", env });
} finally {
fs.rmSync(stateDir, { force: true, recursive: true });
}
});
it("reconciles a SQLite database created by the startup import", async () => {
const log = makeLog();
const migrate = vi.fn<MigrateSessionKeys>().mockResolvedValue({ changes: [], warnings: [] });
const events: string[] = [];
const importSessionSqlite = makeSessionSqliteImport();
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-startup-"));
const env = { OPENCLAW_STATE_DIR: stateDir };
const databasePath = resolveOpenClawAgentSqlitePath({ agentId: "main", env });
const runDoctorSessionSqlite = vi.fn<RunDoctorSessionSqlite>().mockImplementation(async () => {
events.push("import");
fs.mkdirSync(path.dirname(databasePath), { recursive: true });
fs.writeFileSync(databasePath, "");
return await importSessionSqlite({
allAgents: true,
cfg: makeCfg(),
env,
mode: "import",
});
});
const reconcile = vi
.fn<ReconcileSessionTranscriptIndexes>()
.mockResolvedValue({ reconciledSessions: 1 });
const deps = makeDeps(migrate, 0, runDoctorSessionSqlite, reconcile);
const defaultDeps = useDefaultDatabaseExists(deps);
const cfg = makeCfg();
reconcile.mockImplementation(async () => {
events.push("reconcile");
return { reconciledSessions: 1 };
});
try {
await runStartupSessionMigration({ cfg, env, log, deps: defaultDeps });
expect(events).toEqual(["import", "reconcile"]);
expect(reconcile).toHaveBeenCalledWith({ agentId: "main", env });
} finally {
fs.rmSync(stateDir, { force: true, recursive: true });
}
});
it("warns without blocking when hot legacy session SQLite import reports legacy file issues", async () => {
const log = makeLog();
const migrate = vi.fn<MigrateSessionKeys>().mockResolvedValue({ changes: [], warnings: [] });
@@ -1,3 +1,4 @@
import fs from "node:fs";
import { listAgentIds } from "../agents/agent-scope.js";
import {
isSessionSqliteMigrationWarning,
@@ -10,6 +11,7 @@ import {
type SessionStartupMigrationLogger,
} from "../config/sessions/startup-migration.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveOpenClawAgentSqlitePath } from "../state/openclaw-agent-db.paths.js";
type SessionSqliteStartupImportRunner = (params: {
allAgents: true;
@@ -28,10 +30,16 @@ type SessionSqliteStartupFailureReportWriter = (
params: { reason: string },
) => { jsonPath: string; markdownPath: string };
type SessionSqliteDatabaseExists = (params: {
agentId: string;
env?: NodeJS.ProcessEnv;
}) => boolean;
type SessionMigrationDeps = Parameters<typeof runSessionStartupMigration>[0]["deps"] & {
reconcileSessionTranscriptIndexes?: typeof import("../config/sessions/session-transcript-reconcile.js").reconcileSessionTranscriptIndexes;
restoreSessionSqliteMigrationRun?: SessionSqliteStartupRestoreRunner;
runDoctorSessionSqlite?: SessionSqliteStartupImportRunner;
sessionSqliteDatabaseExists?: SessionSqliteDatabaseExists;
writeSessionSqliteMigrationFailureReports?: SessionSqliteStartupFailureReportWriter;
};
@@ -58,12 +66,27 @@ async function reconcileStartupSessionTranscriptIndexes(params: {
log: SessionStartupMigrationLogger;
deps?: SessionMigrationDeps;
}): Promise<void> {
const databaseExists =
params.deps?.sessionSqliteDatabaseExists ??
((input: Parameters<SessionSqliteDatabaseExists>[0]) =>
fs.existsSync(resolveOpenClawAgentSqlitePath(input)));
const agentIds = listAgentIds(params.cfg).filter((agentId) =>
databaseExists({
agentId,
...(params.env ? { env: params.env } : {}),
}),
);
if (agentIds.length === 0) {
// No durable session rows can need projection repair when the agent DB is absent.
// Avoid creating and schema-registering one empty DB per configured agent at startup.
return;
}
const reconcile =
params.deps?.reconcileSessionTranscriptIndexes ??
(await import("../config/sessions/session-transcript-reconcile.js"))
.reconcileSessionTranscriptIndexes;
let reconciledSessions = 0;
for (const agentId of listAgentIds(params.cfg)) {
for (const agentId of agentIds) {
const result = await reconcile({
agentId,
...(params.env ? { env: params.env } : {}),
File diff suppressed because it is too large Load Diff
@@ -128,6 +128,137 @@ describe("realtime Talk conversation", () => {
]);
});
it("bounds streamed assistant delta growth while retaining useful context", () => {
let state = createRealtimeTalkConversationState();
const opening = "Opening context stays visible. ";
state = updateRealtimeTalkConversation(state, {
role: "assistant",
text: `${opening}${"a".repeat(7_900)}`,
final: false,
nowMs: 1,
});
state = updateRealtimeTalkConversation(state, {
role: "assistant",
text: "b".repeat(500),
final: false,
nowMs: 2,
});
state = updateRealtimeTalkConversation(state, {
role: "assistant",
text: `${"c".repeat(500)}NEWEST`,
final: false,
nowMs: 3,
});
expect(state.entries[0]?.text.length).toBeLessThanOrEqual(8_000);
expect(state.entries[0]?.text.startsWith(opening)).toBe(true);
expect(state.entries[0]?.text).toContain("\n…\n");
expect(state.entries[0]?.text.split("\n…\n")).toHaveLength(2);
expect(state.entries[0]?.text.endsWith("NEWEST")).toBe(true);
});
it("replaces a bounded assistant stream with the authoritative final transcript", () => {
let state = createRealtimeTalkConversationState();
const opening = "Original opening context. ";
state = updateRealtimeTalkConversation(state, {
role: "assistant",
text: `${opening}${"draft ".repeat(1_600)}`,
final: false,
nowMs: 1,
});
expect(state.entries[0]?.text).toContain("\n…\n");
state = updateRealtimeTalkConversation(state, {
role: "assistant",
text: `${opening}corrected ${"final ".repeat(1_600)}DONE`,
final: true,
nowMs: 2,
});
expect(state.entries[0]?.text.length).toBeLessThanOrEqual(8_000);
expect(state.entries[0]?.text.startsWith(`${opening}corrected `)).toBe(true);
expect(state.entries[0]?.text).not.toContain("draft ");
expect(state.entries[0]?.text.endsWith("DONE")).toBe(true);
expect(state.entries[0]?.isStreaming).toBe(false);
});
it("does not expose dangling surrogates at a bounded transcript edge", () => {
let state = createRealtimeTalkConversationState();
const transcript = `${"a".repeat(8_000)}🚀${"b".repeat(7_740)}`;
state = updateRealtimeTalkConversation(state, {
role: "assistant",
text: transcript,
final: true,
nowMs: 1,
});
const text = state.entries[0]?.text ?? "";
expect(text.length).toBeLessThanOrEqual(8_000);
expect(text).not.toMatch(
/(?:[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF])/,
);
});
it("does not trust a natural truncation marker outside the bounded prefix", () => {
let state = createRealtimeTalkConversationState();
state = updateRealtimeTalkConversation(state, {
role: "assistant",
text: `${"a".repeat(7_998)}\n…\n${"b".repeat(500)}NEWEST`,
final: true,
nowMs: 1,
});
expect(state.entries[0]?.text.length).toBeLessThanOrEqual(8_000);
expect(state.entries[0]?.text.startsWith("a".repeat(256))).toBe(true);
expect(state.entries[0]?.text.endsWith("NEWEST")).toBe(true);
});
it.each([255, 256])(
"does not retain a lone high surrogate before a natural marker at offset %i",
(markerOffset) => {
let state = createRealtimeTalkConversationState();
const retainedText = "a".repeat(markerOffset - 1);
state = updateRealtimeTalkConversation(state, {
role: "assistant",
text: `${retainedText}\uD800\n…\n${"b".repeat(8_000)}NEWEST`,
final: true,
nowMs: 1,
});
const text = state.entries[0]?.text ?? "";
expect(text.length).toBeLessThanOrEqual(8_000);
expect(text.startsWith(`${retainedText}\n…\n`)).toBe(true);
expect(text.endsWith("NEWEST")).toBe(true);
expect(text).not.toMatch(
/(?:[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF])/,
);
},
);
it.each(["user", "assistant"] as const)(
"bounds oversized final %s entries while retaining the newest text",
(role) => {
let state = createRealtimeTalkConversationState();
state = updateRealtimeTalkConversation(state, {
role,
text: `Useful opening. ${"x".repeat(9_000)}NEWEST`,
final: true,
nowMs: 1,
});
expect(state.entries[0]?.text.length).toBeLessThanOrEqual(8_000);
expect(state.entries[0]?.text.startsWith("Useful opening. ")).toBe(true);
expect(state.entries[0]?.text.endsWith("NEWEST")).toBe(true);
expect(state.entries[0]?.isStreaming).toBe(false);
},
);
it("keeps alternating realtime turns as separate bubbles", () => {
let state = createRealtimeTalkConversationState();
@@ -1,4 +1,6 @@
// Control UI chat module implements realtime talk conversation behavior.
import { sliceUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
type RealtimeTalkConversationRole = "user" | "assistant";
export type RealtimeTalkConversationEntry = {
@@ -25,6 +27,9 @@ type RealtimeTalkTranscriptUpdate = {
};
const MAX_CONVERSATION_ENTRIES = 60;
const MAX_CONVERSATION_ENTRY_CHARS = 8_000;
const CONVERSATION_ENTRY_PREFIX_CHARS = 256;
const CONVERSATION_ENTRY_TRUNCATION_MARKER = "\n…\n";
const USER_FINAL_REWRITE_GRACE_MS = 1_500;
export function createRealtimeTalkConversationState(): RealtimeTalkConversationState {
@@ -96,7 +101,12 @@ function upsertRealtimeConversationEntry(
const id = `rt-${state.nextEntryId}`;
const entries = [
...state.entries,
{ id, role, text: text.trimStart(), isStreaming: !isFinal },
{
id,
role,
text: boundRealtimeConversationText(text.trimStart()),
isStreaming: !isFinal,
},
].slice(-MAX_CONVERSATION_ENTRIES);
return rememberRealtimeConversationEntry(
{ ...state, entries, nextEntryId: state.nextEntryId + 1 },
@@ -115,10 +125,11 @@ function upsertRealtimeConversationEntry(
if (!entry) {
return upsertRealtimeConversationEntry(state, role, null, text, isFinal, nowMs);
}
const updatedText =
const mergedText =
role === "assistant"
? mergeAssistantTranscriptText(entry.text, text, isFinal)
: mergeRealtimeTranscriptText(entry.text, text, isFinal);
const updatedText = boundRealtimeConversationText(mergedText);
const entries =
entry.text === updatedText && entry.isStreaming === !isFinal
? state.entries
@@ -263,6 +274,27 @@ function mergeRealtimeTranscriptText(existing: string, incoming: string, isFinal
return `${existing}${separator}${suffix}`;
}
function boundRealtimeConversationText(text: string): string {
if (text.length <= MAX_CONVERSATION_ENTRY_CHARS) {
return text;
}
// Keep the opening context for late full-final replacement detection and
// the newest tail for the visible conversation. Reuse the original prefix
// so repeated streaming deltas do not move the truncation boundary.
const markerIndex = text.indexOf(CONVERSATION_ENTRY_TRUNCATION_MARKER);
const hasBoundedPrefix =
markerIndex >= CONVERSATION_ENTRY_PREFIX_CHARS - 1 &&
markerIndex <= CONVERSATION_ENTRY_PREFIX_CHARS;
const prefixEnd = hasBoundedPrefix ? markerIndex : CONVERSATION_ENTRY_PREFIX_CHARS;
// A natural marker can follow malformed provider text ending in a lone high
// surrogate. Keep that code unit out of the retained truncation boundary.
const prefix = sliceUtf16Safe(text, 0, prefixEnd).replace(/[\uD800-\uDBFF]$/, "");
const tailChars =
MAX_CONVERSATION_ENTRY_CHARS - prefix.length - CONVERSATION_ENTRY_TRUNCATION_MARKER.length;
const tail = sliceUtf16Safe(text, -tailChars);
return `${prefix}${CONVERSATION_ENTRY_TRUNCATION_MARKER}${tail}`;
}
function looksLikeTranscriptReplacement(existing: string, incoming: string): boolean {
const existingWords = transcriptWords(existing);
const incomingWords = transcriptWords(incoming);
+25 -6
View File
@@ -602,19 +602,38 @@ describe("cron view editor", () => {
expect(onClosePanel).toHaveBeenCalledTimes(1);
});
it("wires form changes from prompt and name inputs", () => {
it("wires shared text and select controls without changing their field ownership", () => {
const onFormChange = vi.fn();
const container = renderView({ createOpen: true, onFormChange });
const container = renderView({
createOpen: true,
channels: ["telegram"],
channelMeta: [{ id: "telegram", label: "", detailLabel: "Telegram" }],
channelLabels: { telegram: "Telegram fallback" },
form: { ...DEFAULT_CRON_FORM, scheduleKind: "cron", failureAlertMode: "custom" },
onFormChange,
});
const prompt = getElement(container, "#cron-payload-text", HTMLTextAreaElement);
prompt.value = "do the thing";
prompt.dispatchEvent(new Event("input", { bubbles: true }));
expect(onFormChange).toHaveBeenCalledWith({ payloadText: "do the thing" });
const name = getElement(container, "#cron-name", HTMLInputElement);
name.value = "Thing";
name.dispatchEvent(new Event("input", { bubbles: true }));
expect(onFormChange).toHaveBeenCalledWith({ name: "Thing" });
for (const field of ["name", "sessionKey", "deliveryAccountId", "payloadModel"] as const) {
const id = `cron-${field.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`;
const input = getElement(container, `#${id}`, HTMLInputElement);
if (field === "sessionKey" || field === "deliveryAccountId") {
expect(input.placeholder).toBe(field === "sessionKey" ? "agent:main:main" : "default");
}
input.value = field;
input.dispatchEvent(new Event("input", { bubbles: true }));
expect(onFormChange).toHaveBeenLastCalledWith({ [field]: field });
}
const channel = getElement(container, "#cron-failure-alert-channel", HTMLSelectElement);
channel.value = "telegram";
expect(channel.selectedOptions[0]?.textContent).toBe("Telegram fallback");
channel.dispatchEvent(new Event("change", { bubbles: true }));
expect(onFormChange).toHaveBeenLastCalledWith({ failureAlertChannel: "telegram" });
});
it("switches schedule inputs by segmented kind and wires kind changes", () => {
+373 -674
View File
File diff suppressed because it is too large Load Diff