fix(gateway): keep routed CLI prompts off the owner's side in chat history (#163521)

* fix(gateway): keep routed CLI prompts off the owner's side in chat history

Claude CLI imported user rows that carry an inter-session envelope now get
inter_session provenance, and the history merge matches them against the
local row even when a drift note sits under the envelope, the local row
stores the text without the envelope, or queued System: event lines sit
above the prompt.

Literal text still matches first. The envelope-free fallback only matches
rows from the same source session and tool, so equal bodies from different
senders, or from the owner, stay separate rows.

* fix(gateway): only strip System lines with a generated event timestamp

Typed text such as "System: [manual note]" matched the loose prefix check, so the
compare-only view dropped it and the import could absorb an unrelated local turn.
Match the exact timestamp formats the system event formatter writes.

* fix(gateway): retain raw routed prompts until history matching

* test(gateway): assert canonical imported identity metadata

* style(gateway): format routed history regressions

---------

Co-authored-by: Rogério Chaves <rogerio@langwatch.ai>
Co-authored-by: Dallin Romney <dallinromney@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-10 16:44:59 -07:00
1 parent 00daf0f7e3
commit 97e81ce0d5
7 files changed
+523 -27

No files matched your search

+10 -5
View File
@@ -9,6 +9,7 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coe
import type { CliSessionBinding, SessionEntry } from "../config/sessions.js";
import { normalizeCliSessionReseedReceipt } from "../config/sessions/cli-session-binding.js";
import { readErrorName } from "../infra/errors.js";
import { readInterSessionPromptEnvelope } from "../sessions/input-provenance.js";
import { isFailoverError } from "./failover-error.js";
import type { FailoverReason } from "./failover/signal.js";
export {
@@ -193,17 +194,21 @@ export function buildCliSessionDriftNote(reasons: readonly CliSessionContentDrif
return `${CLI_SESSION_DRIFT_NOTE_PREFIX} Follow the current turn's instructions; changed=${reasons.join(",")}.`;
}
const CLI_SESSION_DRIFT_NOTE_PREFIXES = [
const CLI_SESSION_DRIFT_NOTES = [
buildCliSessionDriftNote(["system-prompt"]),
buildCliSessionDriftNote(["prompt-tools"]),
buildCliSessionDriftNote(["system-prompt", "prompt-tools"]),
].map((note) => `${note}\n\n`);
];
// Match only complete notes the producer emits; similar native user text is not context.
// Inter-session prompts move their envelope above the note, joined by single newlines.
export function stripCliSessionDriftNote(text: string): string {
for (const prefix of CLI_SESSION_DRIFT_NOTE_PREFIXES) {
if (text.startsWith(prefix)) {
return text.slice(prefix.length);
const envelopeLength = readInterSessionPromptEnvelope(text)?.length ?? 0;
const separator = envelopeLength > 0 ? "\n" : "\n\n";
for (const note of CLI_SESSION_DRIFT_NOTES) {
const prefix = `${note}${separator}`;
if (text.startsWith(prefix, envelopeLength)) {
return text.slice(0, envelopeLength) + text.slice(envelopeLength + prefix.length);
}
}
return text;
+56 -21
View File
@@ -8,7 +8,6 @@ import {
hashCliImageTurnEntryId,
readCliImageTurnContext,
} from "../agents/cli-image-turn-correlation.js";
import { stripCliSessionDriftNote } from "../agents/cli-session.js";
import { isOpenClawCliImageCachePath } from "../agents/embedded-agent-runner/run/images.media-refs.js";
import { stripInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js";
import {
@@ -23,6 +22,10 @@ import { openNodeSqliteDatabase } from "../infra/node-sqlite.js";
import { runSqliteImmediateTransactionSync } from "../infra/sqlite-transaction.js";
import { isImageMediaFact, readPersistedMediaFacts } from "../media/media-facts.js";
import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js";
import {
readRoutedPromptView,
stripCliPromptDecorations,
} from "./cli-session-history.prompt-text.js";
const INDEX_INSERT_BATCH_ROWS = 65;
const INDEX_ORDINAL_BATCH_ROWS = 256;
@@ -58,7 +61,8 @@ function extractComparableText(
hasCliImageMentions: boolean;
cliImageTurnKey?: string;
text?: string;
driftNoteText?: string;
undecoratedText?: string;
routedKey?: string;
} {
const parts: string[] = [];
const text = readStringValue(record.text);
@@ -100,11 +104,18 @@ function extractComparableText(
return visible.replace(/\s+/g, " ").trim();
};
const normalized = normalizeText(stripResult.text);
const withoutDriftNote = isClaudeImport ? stripCliSessionDriftNote(rawText) : rawText;
const driftNoteText =
withoutDriftNote !== rawText
? normalizeText(stripTrailingCliImageMentions(withoutDriftNote.trim()).text)
const withoutDecorations = isClaudeImport ? stripCliPromptDecorations(rawText) : rawText;
const undecoratedText =
withoutDecorations !== rawText
? normalizeText(stripTrailingCliImageMentions(withoutDecorations.trim()).text)
: undefined;
const routed =
role === "user" ? readRoutedPromptView(record.provenance, joined, isClaudeImport) : undefined;
const routedBody =
routed &&
normalizeText(
isClaudeImport ? stripTrailingCliImageMentions(routed.body.trim()).text : routed.body,
);
const storedImageTurnKey = normalizeOptionalString(meta?.cliImageTurnKey);
return {
hasCliImageMentions: stripResult.stripped,
@@ -112,7 +123,8 @@ function extractComparableText(
? { cliImageTurnKey: storedImageTurnKey ?? readCliImageTurnContext(joined) }
: {}),
...(normalized ? { text: normalized } : {}),
...(driftNoteText ? { driftNoteText } : {}),
...(undecoratedText ? { undecoratedText } : {}),
...(routed && routedBody ? { routedKey: JSON.stringify([routed.sender, routedBody]) } : {}),
};
}
@@ -140,7 +152,8 @@ type HistoryRow = {
bytes: number;
role: string | null;
text: string | null;
drift_text: string | null;
undecorated_text: string | null;
routed_key: string | null;
timestamp: number | null;
external_key: string | null;
image_key: string | null;
@@ -176,7 +189,7 @@ export class CliSessionHistoryIndex {
this.db = getNodeSqliteKysely<HistoryDatabase>(this.database);
// Only imported bodies live here; local rows retain their canonical sequence.
const columns = `id INTEGER PRIMARY KEY, local_seq INTEGER, import_ref INTEGER, message_id TEXT, payload TEXT, bytes INTEGER NOT NULL, role TEXT,
text TEXT, drift_text TEXT, timestamp REAL, external_key TEXT, image_key TEXT,
text TEXT, undecorated_text TEXT, routed_key TEXT, timestamp REAL, external_key TEXT, image_key TEXT,
image_mentions INTEGER NOT NULL, metadata TEXT, consumed INTEGER NOT NULL,
ordinal INTEGER`;
// sqlite-allow-raw -- Reconstructible temporary schema; no canonical writes or durability.
@@ -188,6 +201,7 @@ export class CliSessionHistoryIndex {
CREATE INDEX match_text ON messages(role, text, consumed, id);
CREATE INDEX match_timed_text ON messages(role, text, consumed, id) WHERE timestamp IS NOT NULL;
CREATE INDEX match_undated_text ON messages(role, text, consumed, id) WHERE timestamp IS NULL;
CREATE INDEX match_routed ON messages(role, routed_key, consumed, id);
CREATE INDEX match_image ON messages(image_key, consumed, id);
CREATE INDEX message_identity ON messages(message_id);
CREATE INDEX local_sequence ON messages(local_seq);
@@ -259,7 +273,8 @@ export class CliSessionHistoryIndex {
bytes: parameter((row) => row.bytes),
role: parameter((row) => row.role),
text: parameter((row) => row.text),
drift_text: parameter((row) => row.drift_text),
undecorated_text: parameter((row) => row.undecorated_text),
routed_key: parameter((row) => row.routed_key),
timestamp: parameter((row) => row.timestamp),
external_key: parameter((row) => row.external_key),
image_key: parameter((row) => row.image_key),
@@ -298,8 +313,11 @@ export class CliSessionHistoryIndex {
bytes: Buffer.byteLength(serialized, "utf8"),
role: role ?? null,
text: comparable.text === undefined ? null : JSON.stringify(comparable.text),
drift_text:
comparable.driftNoteText === undefined ? null : JSON.stringify(comparable.driftNoteText),
undecorated_text:
comparable.undecoratedText === undefined
? null
: JSON.stringify(comparable.undecoratedText),
routed_key: comparable.routedKey ?? null,
timestamp: asFiniteNumber(record?.timestamp) ?? null,
external_key: resolveImportedExternalIdentityKey(meta) ?? null,
image_key:
@@ -409,7 +427,7 @@ export class CliSessionHistoryIndex {
.limit(1),
);
type TextMatch = { role: string; text: string; floor: number; timestamp: number | null };
const textMatchers = (external: boolean) => {
const textMatchers = (external: boolean, column: "text" | "routed_key") => {
const create = (time: "any" | "window" | "missing") =>
prepareSqliteQueryTakeFirstSync<TextMatch, Match>(this.database, (parameter) => {
let query = candidates()
@@ -419,7 +437,7 @@ export class CliSessionHistoryIndex {
parameter((row) => row.role),
)
.where(
"text",
column,
"=",
parameter((row) => row.text),
)
@@ -453,8 +471,16 @@ export class CliSessionHistoryIndex {
});
return { any: create("any"), window: create("window"), missing: create("missing") };
};
const withIdentity = textMatchers(true);
const withoutIdentity = textMatchers(false);
const matchers = {
text: {
withIdentity: textMatchers(true, "text"),
withoutIdentity: textMatchers(false, "text"),
},
routed_key: {
withIdentity: textMatchers(true, "routed_key"),
withoutIdentity: textMatchers(false, "routed_key"),
},
};
const consume = prepareSqliteQuerySync<Pick<HistoryRow, "id" | "metadata" | "external_key">>(
this.database,
(parameter) =>
@@ -474,12 +500,13 @@ export class CliSessionHistoryIndex {
const minimumOrder = (role: string | null, text: string) =>
this.readOrderFloor({ role: role ?? "", text })?.minimum_order ?? 0;
const advance = (
imported: Pick<HistoryRow, "role" | "text" | "drift_text">,
imported: Pick<HistoryRow, "role" | "text" | "undecorated_text" | "routed_key">,
matched: Pick<HistoryRow, "id" | "text">,
) => {
for (const text of new Set([
imported.text,
matched.text === imported.text ? imported.text : imported.drift_text,
matched.text === imported.text ? imported.text : imported.undecorated_text,
imported.routed_key,
])) {
if (text) {
this.advanceOrderFloor({ role: imported.role ?? "", text, minimumOrder: matched.id + 1 });
@@ -499,7 +526,8 @@ export class CliSessionHistoryIndex {
"bytes",
"role",
"text",
"drift_text",
"undecorated_text",
"routed_key",
"timestamp",
"external_key",
"image_key",
@@ -524,7 +552,12 @@ export class CliSessionHistoryIndex {
}
if (!duplicate && !imported.image_mentions) {
const importedFloor = imported.text ? minimumOrder(imported.role, imported.text) : 0;
for (const text of [imported.text, imported.drift_text]) {
// Literal text first; a sender-scoped routed body only as the last fallback.
for (const [column, text] of [
["text", imported.text],
["text", imported.undecorated_text],
["routed_key", imported.routed_key],
] as const) {
if (!text || !imported.role) {
continue;
}
@@ -532,7 +565,9 @@ export class CliSessionHistoryIndex {
text === imported.text
? importedFloor
: Math.max(minimumOrder(imported.role, text), importedFloor);
const match = imported.external_key ? withIdentity : withoutIdentity;
const match = imported.external_key
? matchers[column].withIdentity
: matchers[column].withoutIdentity;
const params = { role: imported.role, text, floor, timestamp: imported.timestamp };
duplicate =
imported.timestamp === null
+27 -1
View File
@@ -22,6 +22,10 @@ import {
getCliSessionBinding,
normalizeCliSessionReseedReceipt,
} from "../config/sessions/cli-session-binding.js";
import {
type InputProvenance,
readInterSessionPromptEnvelope,
} from "../sessions/input-provenance.js";
import { attachOpenClawTranscriptMeta } from "./session-transcript-readers.js";
const CLAUDE_CLI_PROVIDER = "claude-cli";
@@ -251,6 +255,25 @@ function isClaudeCliTaskNotification(
);
}
// The native row keeps the routed prompt OpenClaw sent, envelope first. Its
// provenance is the same fact the local transcript row stores structurally.
// Preserve raw text so literal matches win before compare-only decoration removal.
function readClaudeCliInterSessionProvenance(
content: string | unknown[],
): InputProvenance | undefined {
const blockIndex =
typeof content === "string"
? -1
: content.findIndex((item) => isRecord(item) && item.type === "text");
const candidate = blockIndex === -1 ? undefined : content[blockIndex];
const block = isRecord(candidate) ? candidate : undefined;
const text = typeof content === "string" ? content : block?.text;
if (typeof text !== "string") {
return undefined;
}
return readInterSessionPromptEnvelope(text)?.provenance;
}
export function resolveClaudeCliPromptTextCandidates(
entry: ClaudeCliProjectEntry,
content: string | unknown[],
@@ -388,11 +411,14 @@ export function parseClaudeCliHistoryEntry(
: isClaudeCliVisibleHarnessContext(entry)
? "cli_harness_context"
: undefined;
const provenance = sourceTool
? { kind: "internal_system", sourceTool }
: readClaudeCliInterSessionProvenance(content);
return attachOpenClawTranscriptMeta(
{
role: "user",
content,
...(sourceTool ? { provenance: { kind: "internal_system", sourceTool } } : {}),
...(provenance ? { provenance } : {}),
...(timestamp !== undefined ? { timestamp } : {}),
},
{ ...baseMeta, ...(cliImageTurnKey ? { cliImageTurnKey } : {}) },
@@ -0,0 +1,57 @@
// Compare-only text views for matching imported CLI prompts to local rows.
import { stripCliSessionDriftNote } from "../agents/cli-session.js";
import {
normalizeInputProvenance,
readInterSessionPromptEnvelope,
} from "../sessions/input-provenance.js";
// Some local inter-session rows store the routed text without the envelope the
// CLI received. Equal bodies only count as one message when they came from the
// same sender, so the view keeps the source next to the body.
// CLI prompts can carry OpenClaw context around the envelope and below it.
export function readRoutedPromptView(
provenanceValue: unknown,
rawText: string,
isCliPrompt: boolean,
): { sender: string; body: string } | undefined {
const text = isCliPrompt ? stripCliPromptDecorations(rawText) : rawText;
const envelope = readInterSessionPromptEnvelope(text);
const provenance = normalizeInputProvenance(provenanceValue) ?? envelope?.provenance;
if (provenance?.kind !== "inter_session") {
return undefined;
}
return {
sender: JSON.stringify([provenance.sourceSessionKey ?? null, provenance.sourceTool ?? null]),
body: isCliPrompt
? stripCliPromptDecorations(text.slice(envelope?.length ?? 0))
: text.slice(envelope?.length ?? 0),
};
}
// Queued system events reach the CLI as a block of `System:` lines above the
// prompt, separated by a blank line. The local row stores only the prompt.
// Each queued event starts with the bracketed timestamp that
// drainFormattedSystemEvents writes (UTC, zoned, or `unknown-time`), so a block
// without one is text the sender typed.
const SYSTEM_EVENT_TIMESTAMP_LINE =
/^System: \[(?:\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z|\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?: [^\s\]]+)?|unknown-time)\] /u;
function stripLeadingSystemEventLines(text: string): string {
const lines = text.replace(/^\n+/u, "").split("\n");
let end = 0;
let hasEvent = false;
while (end < lines.length && (lines[end] === "System:" || lines[end]?.startsWith("System: "))) {
hasEvent ||= SYSTEM_EVENT_TIMESTAMP_LINE.test(lines[end] ?? "");
end += 1;
}
if (!hasEvent || (end < lines.length && lines[end] !== "")) {
return text;
}
return lines.slice(end).join("\n");
}
// Compare-only view of an imported prompt without the context OpenClaw added
// around the user's text before handing it to the CLI.
export function stripCliPromptDecorations(text: string): string {
return stripLeadingSystemEventLines(stripCliSessionDriftNote(text));
}
@@ -0,0 +1,289 @@
import fs from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { buildCliSessionDriftNote } from "../agents/cli-session.js";
import {
buildInterSessionPromptContext,
type InputProvenance,
} from "../sessions/input-provenance.js";
import {
claudeUser,
cliMeta,
mergeImportedChatHistoryMessages,
user,
withClaudeProjectsDir,
} from "./cli-session-history.test-support.js";
const DRIFT_NOTE = buildCliSessionDriftNote(["system-prompt", "prompt-tools"]);
function writeClaudeEntries(filePath: string, entries: readonly Record<string, unknown>[]) {
return fs.writeFile(filePath, entries.map((entry) => JSON.stringify(entry)).join("\n"), "utf-8");
}
describe("routed CLI prompts in chat history", () => {
it("records inter-session provenance from the routed prompt envelope", async () => {
await withClaudeProjectsDir(async ({ filePath, readMessages }) => {
const envelope = buildInterSessionPromptContext({
kind: "inter_session",
sourceSessionKey: "agent:main:main",
sourceChannel: "webchat",
sourceTool: "sessions_send",
}).text;
await writeClaudeEntries(filePath, [
claudeUser(`${envelope}\n${DRIFT_NOTE}\nPlease check the build.`, {
uuid: "routed-1",
}),
claudeUser([{ type: "text", text: `${envelope}\nBlock body.` }], { uuid: "routed-2" }),
claudeUser(
`[Inter-session message] sourceTool=sessions_send isUser=false\nTyped by hand.`,
{
uuid: "look-alike-1",
},
),
]);
const messages = await readMessages();
const provenance = {
kind: "inter_session",
sourceSessionKey: "agent:main:main",
sourceChannel: "webchat",
sourceTool: "sessions_send",
};
expect(messages).toMatchObject([
{
role: "user",
content: `${envelope}\n${DRIFT_NOTE}\nPlease check the build.`,
provenance,
},
{ role: "user", content: [{ type: "text", text: `${envelope}\nBlock body.` }], provenance },
{ role: "user" },
]);
expect(messages[2]?.provenance).toBeUndefined();
});
});
it.each(["string", "text block"])(
"matches literal routed %s content before removing generated-looking decorations",
async (shape) => {
await withClaudeProjectsDir(async ({ filePath, readMessages, sessionId }) => {
const provenance: InputProvenance = {
kind: "inter_session",
sourceSessionKey: "agent:ops:main",
sourceTool: "sessions_send",
};
const envelope = buildInterSessionPromptContext(provenance).text;
const body = `${envelope}\nPlease check the build.`;
const raw = `${envelope}\n${DRIFT_NOTE}\nPlease check the build.`;
const content = shape === "string" ? raw : [{ type: "text", text: raw }];
const plain = { ...user(body), provenance };
const literal = { ...user(content), provenance };
await writeClaudeEntries(filePath, [claudeUser(content, { uuid: "routed-literal" })]);
const imported = await readMessages();
const merged = mergeImportedChatHistoryMessages({
localMessages: [plain, literal],
importedMessages: imported,
});
expect(merged).toEqual([
plain,
{ ...literal, __openclaw: cliMeta("routed-literal", sessionId) },
]);
expect(imported[0]?.content).toEqual(content);
});
},
);
it("dedupes routed prompts whose drift note sits under the inter-session envelope", () => {
const envelope = buildInterSessionPromptContext({
kind: "inter_session",
sourceSessionKey: "agent:main:main",
sourceTool: "sessions_send",
}).text;
const provenance = {
kind: "inter_session",
sourceSessionKey: "agent:main:main",
sourceTool: "sessions_send",
};
const routed = { ...user(`${envelope}\nPlease check the build.`, 1_000), provenance };
const settled = {
...user("[Subagent Context] Every subagent has settled.", 2_000),
provenance: { kind: "inter_session", sourceTool: "subagent_settle" },
};
const importedRouted = user(
`${envelope}\n${DRIFT_NOTE}\nPlease check the build.`,
1_001,
cliMeta("routed"),
);
const settleEnvelope = buildInterSessionPromptContext({
kind: "inter_session",
sourceTool: "subagent_settle",
}).text;
const importedSettled = user(
`${settleEnvelope}\n[Subagent Context] Every subagent has settled.`,
2_001,
cliMeta("settled"),
);
const merged = mergeImportedChatHistoryMessages({
localMessages: [routed, settled],
importedMessages: [importedRouted, importedSettled],
});
expect(merged).toEqual([
{ ...routed, __openclaw: cliMeta("routed") },
{ ...settled, __openclaw: cliMeta("settled") },
]);
});
it("keeps equal routed bodies from different source sessions apart", () => {
const routedFrom = (sourceSessionKey: string) => ({
envelope: buildInterSessionPromptContext({
kind: "inter_session",
sourceSessionKey,
sourceTool: "sessions_send",
}).text,
provenance: { kind: "inter_session", sourceSessionKey, sourceTool: "sessions_send" },
});
const ops = routedFrom("agent:ops:main");
const finance = routedFrom("agent:finance:main");
const localWithEnvelope = {
...user(`${ops.envelope}\ndone`, 1_000),
provenance: ops.provenance,
};
const localBodyOnly = { ...user("done", 1_000_000), provenance: ops.provenance };
const importedFinance = {
...user(`${finance.envelope}\ndone`, 1_001, cliMeta("finance-1")),
provenance: finance.provenance,
};
const importedFinanceLater = {
...user(`${finance.envelope}\ndone`, 1_000_001, cliMeta("finance-2")),
provenance: finance.provenance,
};
const importedOps = {
...user(`${ops.envelope}\ndone`, 1_000_002, cliMeta("ops-2")),
provenance: ops.provenance,
};
expect(
mergeImportedChatHistoryMessages({
localMessages: [localWithEnvelope, localBodyOnly],
importedMessages: [importedFinance, importedFinanceLater, importedOps],
}),
).toEqual([
localWithEnvelope,
importedFinance,
{ ...localBodyOnly, __openclaw: cliMeta("ops-2") },
importedFinanceLater,
]);
});
it("keeps an owner message apart from a routed message with the same body", () => {
const provenance: InputProvenance = {
kind: "inter_session",
sourceSessionKey: "agent:ops:main",
sourceTool: "sessions_send",
};
const envelope = buildInterSessionPromptContext(provenance).text;
const owner = user("done", 1_000);
const importedRouted = {
...user(`${envelope}\ndone`, 1_001, cliMeta("routed")),
provenance,
};
expect(
mergeImportedChatHistoryMessages({
localMessages: [owner],
importedMessages: [importedRouted],
}),
).toEqual([owner, importedRouted]);
});
it("dedupes routed prompts carrying queued system event lines", () => {
const provenance: InputProvenance = {
kind: "inter_session",
sourceSessionKey: "agent:ops:main",
sourceTool: "sessions_send",
};
const envelope = buildInterSessionPromptContext(provenance).text;
const local = { ...user("what changed?", 1_000), provenance };
const imported = {
...user(
`${envelope}\nSystem: [2026-09-30 17:16:38 UTC] Gateway connected.\n\nwhat changed?`,
1_001,
cliMeta("routed-events"),
),
provenance,
};
expect(
mergeImportedChatHistoryMessages({ localMessages: [local], importedMessages: [imported] }),
).toEqual([{ ...local, __openclaw: cliMeta("routed-events") }]);
});
it("dedupes a body-only routed row against an import with a drift note", () => {
const provenance: InputProvenance = {
kind: "inter_session",
sourceSessionKey: "agent:ops:main",
sourceTool: "sessions_send",
};
const envelope = buildInterSessionPromptContext(provenance).text;
const local = { ...user("Please check the build.", 1_000), provenance };
const imported = user(
`${envelope}\n${DRIFT_NOTE}\nPlease check the build.`,
1_001,
cliMeta("routed-drift"),
);
expect(
mergeImportedChatHistoryMessages({ localMessages: [local], importedMessages: [imported] }),
).toEqual([{ ...local, __openclaw: cliMeta("routed-drift") }]);
});
it("keeps typed System lines as part of the prompt", () => {
const local = user("what changed?", 1_000);
const imported = user("System: review the logs\n\nwhat changed?", 1_001, cliMeta("typed"));
expect(
mergeImportedChatHistoryMessages({ localMessages: [local], importedMessages: [imported] }),
).toEqual([local, imported]);
});
it("keeps typed bracketed System lines apart from an unrelated local turn", () => {
const local = user("done", 1_000);
const imported = user("System: [manual note]\n\ndone", 1_001, cliMeta("bracketed"));
expect(
mergeImportedChatHistoryMessages({ localMessages: [local], importedMessages: [imported] }),
).toEqual([local, imported]);
});
it("strips queued system event lines with UTC and unknown timestamps", () => {
const local = user("done", 1_000);
const imported = user(
"System: [2026-09-30T17:16:38Z] Gateway connected.\nSystem: [unknown-time] Node paired.\n\ndone",
1_001,
cliMeta("utc-events"),
);
expect(
mergeImportedChatHistoryMessages({ localMessages: [local], importedMessages: [imported] }),
).toEqual([{ ...local, __openclaw: cliMeta("utc-events") }]);
});
it("dedupes prompts carrying queued system event lines", () => {
const localMessage = user("what changed?", 1_000);
const importedMessage = user(
`${DRIFT_NOTE}\n\nSystem: [2026-09-30 17:16:38 UTC] Gateway connected.\nSystem: second line\n\nwhat changed?`,
1_001,
cliMeta("system-events"),
);
expect(
mergeImportedChatHistoryMessages({
localMessages: [localMessage],
importedMessages: [importedMessage],
}),
).toEqual([{ ...localMessage, __openclaw: cliMeta("system-events") }]);
});
});
+35
View File
@@ -5,6 +5,7 @@ import {
INTER_SESSION_PROMPT_PREFIX_BASE,
isAgentMediatedCompletionSourceTool,
normalizeInputProvenance,
readInterSessionPromptEnvelope,
shouldPreserveUserFacingSessionStateForInputProvenance,
stripInterSessionPromptPrefixForDisplay,
} from "./input-provenance.js";
@@ -113,6 +114,40 @@ describe("inter-session body whitespace", () => {
});
});
describe("readInterSessionPromptEnvelope", () => {
it("reads source provenance from a generated envelope", () => {
const text = annotateInterSessionPromptText("body", {
kind: "inter_session",
sourceSessionKey: "agent:main:main",
sourceChannel: "webchat",
sourceTool: "sessions_send",
});
const envelope = readInterSessionPromptEnvelope(text);
expect(envelope?.provenance).toEqual({
kind: "inter_session",
sourceSessionKey: "agent:main:main",
sourceChannel: "webchat",
sourceTool: "sessions_send",
});
expect(text.slice(envelope?.length)).toBe("body");
});
it("ignores headers without the generated explanation or with unknown fields", () => {
expect(
readInterSessionPromptEnvelope(`${INTER_SESSION_PROMPT_PREFIX_BASE} isUser=false\nhello`),
).toBeUndefined();
const generated = annotateInterSessionPromptText("x", { kind: "inter_session" });
expect(
readInterSessionPromptEnvelope(
generated.replace("isUser=false", "sourceRole=user isUser=false"),
),
).toBeUndefined();
expect(readInterSessionPromptEnvelope(`quoted ${generated}`)).toBeUndefined();
});
});
describe("isAgentMediatedCompletionSourceTool", () => {
it.each(["agent_harness_task", "image_generate", "music_generate", "video_generate"])(
"identifies %s as an agent-mediated completion source",
+49
View File
@@ -205,6 +205,55 @@ export function buildInterSessionPromptContext(inputProvenance: InputProvenance
};
}
const INTER_SESSION_PROMPT_HEADER_FIELDS: ReadonlyMap<
string,
"sourceSessionKey" | "sourceChannel" | "sourceTool"
> = new Map([
["sourceSession", "sourceSessionKey"],
["sourceChannel", "sourceChannel"],
["sourceTool", "sourceTool"],
]);
/**
* Reads the generated envelope at the start of prompt text. Only the exact
* header plus explanation counts, so ordinary text that mentions the prefix
* stays authored by its sender.
*/
export function readInterSessionPromptEnvelope(
text: string,
): { provenance: InputProvenance; length: number } | undefined {
if (!text.startsWith(`${INTER_SESSION_PROMPT_PREFIX_BASE} `)) {
return undefined;
}
const headerEnd = text.indexOf("\n");
if (headerEnd === -1) {
return undefined;
}
const explanationEnd = headerEnd + 1 + INTER_SESSION_PROMPT_EXPLANATION.length;
if (text.slice(headerEnd + 1, explanationEnd) !== INTER_SESSION_PROMPT_EXPLANATION) {
return undefined;
}
const details = text
.slice(INTER_SESSION_PROMPT_PREFIX_BASE.length, headerEnd)
.trim()
.split(/\s+/u);
if (details.at(-1) !== "isUser=false") {
return undefined;
}
const provenance: InputProvenance = { kind: "inter_session" };
for (const detail of details.slice(0, -1)) {
const separator = detail.indexOf("=");
const field = INTER_SESSION_PROMPT_HEADER_FIELDS.get(detail.slice(0, separator));
const value = normalizeOptionalString(detail.slice(separator + 1));
if (separator <= 0 || !field || !value) {
return undefined;
}
provenance[field] = value;
}
const length = text.startsWith("\n", explanationEnd) ? explanationEnd + 1 : explanationEnd;
return { provenance, length };
}
export function stripInterSessionPromptPrefixForDisplay(text: string): string {
const index = text.indexOf(INTER_SESSION_PROMPT_PREFIX_BASE);
if (index === -1) {