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 type { CliSessionBinding, SessionEntry } from "../config/sessions.js";
|
||||||
import { normalizeCliSessionReseedReceipt } from "../config/sessions/cli-session-binding.js";
|
import { normalizeCliSessionReseedReceipt } from "../config/sessions/cli-session-binding.js";
|
||||||
import { readErrorName } from "../infra/errors.js";
|
import { readErrorName } from "../infra/errors.js";
|
||||||
|
import { readInterSessionPromptEnvelope } from "../sessions/input-provenance.js";
|
||||||
import { isFailoverError } from "./failover-error.js";
|
import { isFailoverError } from "./failover-error.js";
|
||||||
import type { FailoverReason } from "./failover/signal.js";
|
import type { FailoverReason } from "./failover/signal.js";
|
||||||
export {
|
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(",")}.`;
|
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(["system-prompt"]),
|
||||||
buildCliSessionDriftNote(["prompt-tools"]),
|
buildCliSessionDriftNote(["prompt-tools"]),
|
||||||
buildCliSessionDriftNote(["system-prompt", "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.
|
// 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 {
|
export function stripCliSessionDriftNote(text: string): string {
|
||||||
for (const prefix of CLI_SESSION_DRIFT_NOTE_PREFIXES) {
|
const envelopeLength = readInterSessionPromptEnvelope(text)?.length ?? 0;
|
||||||
if (text.startsWith(prefix)) {
|
const separator = envelopeLength > 0 ? "\n" : "\n\n";
|
||||||
return text.slice(prefix.length);
|
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;
|
return text;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
hashCliImageTurnEntryId,
|
hashCliImageTurnEntryId,
|
||||||
readCliImageTurnContext,
|
readCliImageTurnContext,
|
||||||
} from "../agents/cli-image-turn-correlation.js";
|
} 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 { isOpenClawCliImageCachePath } from "../agents/embedded-agent-runner/run/images.media-refs.js";
|
||||||
import { stripInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js";
|
import { stripInboundMetadata } from "../auto-reply/reply/strip-inbound-meta.js";
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +22,10 @@ import { openNodeSqliteDatabase } from "../infra/node-sqlite.js";
|
|||||||
import { runSqliteImmediateTransactionSync } from "../infra/sqlite-transaction.js";
|
import { runSqliteImmediateTransactionSync } from "../infra/sqlite-transaction.js";
|
||||||
import { isImageMediaFact, readPersistedMediaFacts } from "../media/media-facts.js";
|
import { isImageMediaFact, readPersistedMediaFacts } from "../media/media-facts.js";
|
||||||
import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.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_INSERT_BATCH_ROWS = 65;
|
||||||
const INDEX_ORDINAL_BATCH_ROWS = 256;
|
const INDEX_ORDINAL_BATCH_ROWS = 256;
|
||||||
@@ -58,7 +61,8 @@ function extractComparableText(
|
|||||||
hasCliImageMentions: boolean;
|
hasCliImageMentions: boolean;
|
||||||
cliImageTurnKey?: string;
|
cliImageTurnKey?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
driftNoteText?: string;
|
undecoratedText?: string;
|
||||||
|
routedKey?: string;
|
||||||
} {
|
} {
|
||||||
const parts: string[] = [];
|
const parts: string[] = [];
|
||||||
const text = readStringValue(record.text);
|
const text = readStringValue(record.text);
|
||||||
@@ -100,11 +104,18 @@ function extractComparableText(
|
|||||||
return visible.replace(/\s+/g, " ").trim();
|
return visible.replace(/\s+/g, " ").trim();
|
||||||
};
|
};
|
||||||
const normalized = normalizeText(stripResult.text);
|
const normalized = normalizeText(stripResult.text);
|
||||||
const withoutDriftNote = isClaudeImport ? stripCliSessionDriftNote(rawText) : rawText;
|
const withoutDecorations = isClaudeImport ? stripCliPromptDecorations(rawText) : rawText;
|
||||||
const driftNoteText =
|
const undecoratedText =
|
||||||
withoutDriftNote !== rawText
|
withoutDecorations !== rawText
|
||||||
? normalizeText(stripTrailingCliImageMentions(withoutDriftNote.trim()).text)
|
? normalizeText(stripTrailingCliImageMentions(withoutDecorations.trim()).text)
|
||||||
: undefined;
|
: 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);
|
const storedImageTurnKey = normalizeOptionalString(meta?.cliImageTurnKey);
|
||||||
return {
|
return {
|
||||||
hasCliImageMentions: stripResult.stripped,
|
hasCliImageMentions: stripResult.stripped,
|
||||||
@@ -112,7 +123,8 @@ function extractComparableText(
|
|||||||
? { cliImageTurnKey: storedImageTurnKey ?? readCliImageTurnContext(joined) }
|
? { cliImageTurnKey: storedImageTurnKey ?? readCliImageTurnContext(joined) }
|
||||||
: {}),
|
: {}),
|
||||||
...(normalized ? { text: normalized } : {}),
|
...(normalized ? { text: normalized } : {}),
|
||||||
...(driftNoteText ? { driftNoteText } : {}),
|
...(undecoratedText ? { undecoratedText } : {}),
|
||||||
|
...(routed && routedBody ? { routedKey: JSON.stringify([routed.sender, routedBody]) } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +152,8 @@ type HistoryRow = {
|
|||||||
bytes: number;
|
bytes: number;
|
||||||
role: string | null;
|
role: string | null;
|
||||||
text: string | null;
|
text: string | null;
|
||||||
drift_text: string | null;
|
undecorated_text: string | null;
|
||||||
|
routed_key: string | null;
|
||||||
timestamp: number | null;
|
timestamp: number | null;
|
||||||
external_key: string | null;
|
external_key: string | null;
|
||||||
image_key: string | null;
|
image_key: string | null;
|
||||||
@@ -176,7 +189,7 @@ export class CliSessionHistoryIndex {
|
|||||||
this.db = getNodeSqliteKysely<HistoryDatabase>(this.database);
|
this.db = getNodeSqliteKysely<HistoryDatabase>(this.database);
|
||||||
// Only imported bodies live here; local rows retain their canonical sequence.
|
// 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,
|
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,
|
image_mentions INTEGER NOT NULL, metadata TEXT, consumed INTEGER NOT NULL,
|
||||||
ordinal INTEGER`;
|
ordinal INTEGER`;
|
||||||
// sqlite-allow-raw -- Reconstructible temporary schema; no canonical writes or durability.
|
// 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_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_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_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 match_image ON messages(image_key, consumed, id);
|
||||||
CREATE INDEX message_identity ON messages(message_id);
|
CREATE INDEX message_identity ON messages(message_id);
|
||||||
CREATE INDEX local_sequence ON messages(local_seq);
|
CREATE INDEX local_sequence ON messages(local_seq);
|
||||||
@@ -259,7 +273,8 @@ export class CliSessionHistoryIndex {
|
|||||||
bytes: parameter((row) => row.bytes),
|
bytes: parameter((row) => row.bytes),
|
||||||
role: parameter((row) => row.role),
|
role: parameter((row) => row.role),
|
||||||
text: parameter((row) => row.text),
|
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),
|
timestamp: parameter((row) => row.timestamp),
|
||||||
external_key: parameter((row) => row.external_key),
|
external_key: parameter((row) => row.external_key),
|
||||||
image_key: parameter((row) => row.image_key),
|
image_key: parameter((row) => row.image_key),
|
||||||
@@ -298,8 +313,11 @@ export class CliSessionHistoryIndex {
|
|||||||
bytes: Buffer.byteLength(serialized, "utf8"),
|
bytes: Buffer.byteLength(serialized, "utf8"),
|
||||||
role: role ?? null,
|
role: role ?? null,
|
||||||
text: comparable.text === undefined ? null : JSON.stringify(comparable.text),
|
text: comparable.text === undefined ? null : JSON.stringify(comparable.text),
|
||||||
drift_text:
|
undecorated_text:
|
||||||
comparable.driftNoteText === undefined ? null : JSON.stringify(comparable.driftNoteText),
|
comparable.undecoratedText === undefined
|
||||||
|
? null
|
||||||
|
: JSON.stringify(comparable.undecoratedText),
|
||||||
|
routed_key: comparable.routedKey ?? null,
|
||||||
timestamp: asFiniteNumber(record?.timestamp) ?? null,
|
timestamp: asFiniteNumber(record?.timestamp) ?? null,
|
||||||
external_key: resolveImportedExternalIdentityKey(meta) ?? null,
|
external_key: resolveImportedExternalIdentityKey(meta) ?? null,
|
||||||
image_key:
|
image_key:
|
||||||
@@ -409,7 +427,7 @@ export class CliSessionHistoryIndex {
|
|||||||
.limit(1),
|
.limit(1),
|
||||||
);
|
);
|
||||||
type TextMatch = { role: string; text: string; floor: number; timestamp: number | null };
|
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") =>
|
const create = (time: "any" | "window" | "missing") =>
|
||||||
prepareSqliteQueryTakeFirstSync<TextMatch, Match>(this.database, (parameter) => {
|
prepareSqliteQueryTakeFirstSync<TextMatch, Match>(this.database, (parameter) => {
|
||||||
let query = candidates()
|
let query = candidates()
|
||||||
@@ -419,7 +437,7 @@ export class CliSessionHistoryIndex {
|
|||||||
parameter((row) => row.role),
|
parameter((row) => row.role),
|
||||||
)
|
)
|
||||||
.where(
|
.where(
|
||||||
"text",
|
column,
|
||||||
"=",
|
"=",
|
||||||
parameter((row) => row.text),
|
parameter((row) => row.text),
|
||||||
)
|
)
|
||||||
@@ -453,8 +471,16 @@ export class CliSessionHistoryIndex {
|
|||||||
});
|
});
|
||||||
return { any: create("any"), window: create("window"), missing: create("missing") };
|
return { any: create("any"), window: create("window"), missing: create("missing") };
|
||||||
};
|
};
|
||||||
const withIdentity = textMatchers(true);
|
const matchers = {
|
||||||
const withoutIdentity = textMatchers(false);
|
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">>(
|
const consume = prepareSqliteQuerySync<Pick<HistoryRow, "id" | "metadata" | "external_key">>(
|
||||||
this.database,
|
this.database,
|
||||||
(parameter) =>
|
(parameter) =>
|
||||||
@@ -474,12 +500,13 @@ export class CliSessionHistoryIndex {
|
|||||||
const minimumOrder = (role: string | null, text: string) =>
|
const minimumOrder = (role: string | null, text: string) =>
|
||||||
this.readOrderFloor({ role: role ?? "", text })?.minimum_order ?? 0;
|
this.readOrderFloor({ role: role ?? "", text })?.minimum_order ?? 0;
|
||||||
const advance = (
|
const advance = (
|
||||||
imported: Pick<HistoryRow, "role" | "text" | "drift_text">,
|
imported: Pick<HistoryRow, "role" | "text" | "undecorated_text" | "routed_key">,
|
||||||
matched: Pick<HistoryRow, "id" | "text">,
|
matched: Pick<HistoryRow, "id" | "text">,
|
||||||
) => {
|
) => {
|
||||||
for (const text of new Set([
|
for (const text of new Set([
|
||||||
imported.text,
|
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) {
|
if (text) {
|
||||||
this.advanceOrderFloor({ role: imported.role ?? "", text, minimumOrder: matched.id + 1 });
|
this.advanceOrderFloor({ role: imported.role ?? "", text, minimumOrder: matched.id + 1 });
|
||||||
@@ -499,7 +526,8 @@ export class CliSessionHistoryIndex {
|
|||||||
"bytes",
|
"bytes",
|
||||||
"role",
|
"role",
|
||||||
"text",
|
"text",
|
||||||
"drift_text",
|
"undecorated_text",
|
||||||
|
"routed_key",
|
||||||
"timestamp",
|
"timestamp",
|
||||||
"external_key",
|
"external_key",
|
||||||
"image_key",
|
"image_key",
|
||||||
@@ -524,7 +552,12 @@ export class CliSessionHistoryIndex {
|
|||||||
}
|
}
|
||||||
if (!duplicate && !imported.image_mentions) {
|
if (!duplicate && !imported.image_mentions) {
|
||||||
const importedFloor = imported.text ? minimumOrder(imported.role, imported.text) : 0;
|
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) {
|
if (!text || !imported.role) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -532,7 +565,9 @@ export class CliSessionHistoryIndex {
|
|||||||
text === imported.text
|
text === imported.text
|
||||||
? importedFloor
|
? importedFloor
|
||||||
: Math.max(minimumOrder(imported.role, 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 };
|
const params = { role: imported.role, text, floor, timestamp: imported.timestamp };
|
||||||
duplicate =
|
duplicate =
|
||||||
imported.timestamp === null
|
imported.timestamp === null
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ import {
|
|||||||
getCliSessionBinding,
|
getCliSessionBinding,
|
||||||
normalizeCliSessionReseedReceipt,
|
normalizeCliSessionReseedReceipt,
|
||||||
} from "../config/sessions/cli-session-binding.js";
|
} from "../config/sessions/cli-session-binding.js";
|
||||||
|
import {
|
||||||
|
type InputProvenance,
|
||||||
|
readInterSessionPromptEnvelope,
|
||||||
|
} from "../sessions/input-provenance.js";
|
||||||
import { attachOpenClawTranscriptMeta } from "./session-transcript-readers.js";
|
import { attachOpenClawTranscriptMeta } from "./session-transcript-readers.js";
|
||||||
|
|
||||||
const CLAUDE_CLI_PROVIDER = "claude-cli";
|
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(
|
export function resolveClaudeCliPromptTextCandidates(
|
||||||
entry: ClaudeCliProjectEntry,
|
entry: ClaudeCliProjectEntry,
|
||||||
content: string | unknown[],
|
content: string | unknown[],
|
||||||
@@ -388,11 +411,14 @@ export function parseClaudeCliHistoryEntry(
|
|||||||
: isClaudeCliVisibleHarnessContext(entry)
|
: isClaudeCliVisibleHarnessContext(entry)
|
||||||
? "cli_harness_context"
|
? "cli_harness_context"
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const provenance = sourceTool
|
||||||
|
? { kind: "internal_system", sourceTool }
|
||||||
|
: readClaudeCliInterSessionProvenance(content);
|
||||||
return attachOpenClawTranscriptMeta(
|
return attachOpenClawTranscriptMeta(
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
content,
|
content,
|
||||||
...(sourceTool ? { provenance: { kind: "internal_system", sourceTool } } : {}),
|
...(provenance ? { provenance } : {}),
|
||||||
...(timestamp !== undefined ? { timestamp } : {}),
|
...(timestamp !== undefined ? { timestamp } : {}),
|
||||||
},
|
},
|
||||||
{ ...baseMeta, ...(cliImageTurnKey ? { cliImageTurnKey } : {}) },
|
{ ...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,
|
INTER_SESSION_PROMPT_PREFIX_BASE,
|
||||||
isAgentMediatedCompletionSourceTool,
|
isAgentMediatedCompletionSourceTool,
|
||||||
normalizeInputProvenance,
|
normalizeInputProvenance,
|
||||||
|
readInterSessionPromptEnvelope,
|
||||||
shouldPreserveUserFacingSessionStateForInputProvenance,
|
shouldPreserveUserFacingSessionStateForInputProvenance,
|
||||||
stripInterSessionPromptPrefixForDisplay,
|
stripInterSessionPromptPrefixForDisplay,
|
||||||
} from "./input-provenance.js";
|
} 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", () => {
|
describe("isAgentMediatedCompletionSourceTool", () => {
|
||||||
it.each(["agent_harness_task", "image_generate", "music_generate", "video_generate"])(
|
it.each(["agent_harness_task", "image_generate", "music_generate", "video_generate"])(
|
||||||
"identifies %s as an agent-mediated completion source",
|
"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 {
|
export function stripInterSessionPromptPrefixForDisplay(text: string): string {
|
||||||
const index = text.indexOf(INTER_SESSION_PROMPT_PREFIX_BASE);
|
const index = text.indexOf(INTER_SESSION_PROMPT_PREFIX_BASE);
|
||||||
if (index === -1) {
|
if (index === -1) {
|
||||||
|
|||||||
Reference in new issue
Block a user