mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-26 10:13:27 +08:00
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:
7 files changed
+523
-27
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") }]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user