Fix runtime guidance transcript replay

This commit is contained in:
wuyang6
2026-06-12 19:41:26 +08:00
parent 9237d6782a
commit aac6383fe6
2 changed files with 111 additions and 13 deletions
@@ -49,6 +49,7 @@ type ClawSession = {
type ClawStoredMessage = { type ClawStoredMessage = {
role?: string; role?: string;
content?: string; content?: string;
message_id?: string;
name?: string; name?: string;
tool_call_id?: string; tool_call_id?: string;
tool_calls?: ClawStoredToolCall[]; tool_calls?: ClawStoredToolCall[];
@@ -801,7 +802,7 @@ export function toReplayRepository(
: []; : [];
if (!content && !toolParts.length) continue; if (!content && !toolParts.length) continue;
if (content.trimStart().startsWith("<system-reminder>")) continue; if (content.trimStart().startsWith("<system-reminder>")) continue;
const id = `${fallbackSessionId}-${index}`; const id = replayMessageId(fallbackSessionId, message, index);
const shouldShowAssistantText = const shouldShowAssistantText =
message.role !== "assistant" || message.role !== "assistant" ||
!toolParts.length || !toolParts.length ||
@@ -1185,6 +1186,18 @@ function isRunStatusMessage(message?: ClawStoredMessage) {
return message?.metadata?.kind === "run_status"; return message?.metadata?.kind === "run_status";
} }
function replayMessageId(
sessionId: string,
message: ClawStoredMessage,
index: number,
) {
const stableId =
typeof message.message_id === "string" && message.message_id.trim()
? message.message_id.trim()
: String(index);
return `${sessionId}-${stableId}`;
}
function resolvePendingPrompt( function resolvePendingPrompt(
messages: readonly ClawStoredMessage[], messages: readonly ClawStoredMessage[],
runStatus?: ClawRunStatus | null, runStatus?: ClawRunStatus | null,
@@ -1194,16 +1207,12 @@ function resolvePendingPrompt(
? runStatus.pending_prompt.trim() ? runStatus.pending_prompt.trim()
: ""; : "";
if (!pendingPrompt) return null; if (!pendingPrompt) return null;
for (let index = messages.length - 1; index >= 0; index -= 1) { for (const message of messages) {
const message = messages[index]; if (!message || message.role !== "user") continue;
if (!message || message.role === "system" || message.role === "tool")
continue;
const content = cleanStoredContent(message.content ?? "").trim(); const content = cleanStoredContent(message.content ?? "").trim();
if (!content || content.trimStart().startsWith("<system-reminder>")) if (!content || content.trimStart().startsWith("<system-reminder>"))
continue; continue;
return message.role === "user" && content === pendingPrompt if (content === pendingPrompt) return null;
? null
: pendingPrompt;
} }
return pendingPrompt; return pendingPrompt;
} }
+94 -5
View File
@@ -662,14 +662,43 @@ def _sync_agent_display_messages(
if str(row['message_key'] or '') if str(row['message_key'] or '')
} }
next_seq = max(existing.keys(), default=0) + 1 next_seq = max(existing.keys(), default=0) + 1
seq_offset = 0
keep_seqs: set[int] = set()
for seq, row in existing.items():
try:
existing_message = json.loads(row['message_json'])
except (TypeError, json.JSONDecodeError):
existing_message = {}
if isinstance(existing_message, dict) and _is_persistent_display_message(
existing_message
):
keep_seqs.add(seq)
for index, message in enumerate(display_messages, start=1): for index, message in enumerate(display_messages, start=1):
if not isinstance(message, dict): if not isinstance(message, dict):
continue continue
message_json = json.dumps(message, ensure_ascii=False, sort_keys=True) message_json = json.dumps(message, ensure_ascii=False, sort_keys=True)
message_key = _display_message_key(message) message_key = _display_message_key(message)
row = existing.get(index) target_seq = index + seq_offset
row = existing.get(target_seq)
while row is not None:
try:
old_target_message = json.loads(row['message_json'])
except (TypeError, json.JSONDecodeError):
old_target_message = {}
if not isinstance(old_target_message, dict):
old_target_message = {}
if not _is_persistent_display_message(old_target_message):
break
# Display-only messages, such as runtime guidance, are inserted
# outside the model transcript. Later full-session syncs must
# preserve them and shift model messages after them; otherwise
# the UI sees an old pending prompt as a new user branch.
seq_offset += 1
target_seq = index + seq_offset
row = existing.get(target_seq)
keyed_row = existing_by_key.get(message_key) keyed_row = existing_by_key.get(message_key)
if keyed_row is not None and keyed_row['message_json'] == message_json: if keyed_row is not None and keyed_row['message_json'] == message_json:
keep_seqs.add(int(keyed_row['seq']))
continue continue
if keyed_row is not None: if keyed_row is not None:
try: try:
@@ -695,6 +724,14 @@ def _sync_agent_display_messages(
int(keyed_row['seq']), int(keyed_row['seq']),
), ),
) )
updated_row = {
'seq': int(keyed_row['seq']),
'message_key': message_key,
'message_json': message_json,
}
existing[int(keyed_row['seq'])] = updated_row # type: ignore[assignment]
existing_by_key[message_key] = updated_row # type: ignore[assignment]
keep_seqs.add(int(keyed_row['seq']))
continue continue
if row is None: if row is None:
conn.execute( conn.execute(
@@ -706,15 +743,25 @@ def _sync_agent_display_messages(
""", """,
( (
session_id, session_id,
index, target_seq,
message_key, message_key,
str(message.get('role') or ''), str(message.get('role') or ''),
now, now,
message_json, message_json,
), ),
) )
inserted_row = {
'seq': target_seq,
'message_key': message_key,
'message_json': message_json,
}
existing[target_seq] = inserted_row # type: ignore[assignment]
existing_by_key[message_key] = inserted_row # type: ignore[assignment]
next_seq = max(next_seq, target_seq + 1)
keep_seqs.add(target_seq)
continue continue
if row['message_json'] == message_json: if row['message_json'] == message_json:
keep_seqs.add(target_seq)
continue continue
try: try:
old_message = json.loads(row['message_json']) old_message = json.loads(row['message_json'])
@@ -747,11 +794,14 @@ def _sync_agent_display_messages(
message_json, message_json,
), ),
) )
existing_by_key[message_key] = { appended_row = {
'seq': append_seq, 'seq': append_seq,
'message_key': message_key, 'message_key': message_key,
'message_json': message_json, 'message_json': message_json,
} # type: ignore[assignment] }
existing[append_seq] = appended_row # type: ignore[assignment]
existing_by_key[message_key] = appended_row # type: ignore[assignment]
keep_seqs.add(append_seq)
continue continue
conn.execute( conn.execute(
""" """
@@ -768,9 +818,32 @@ def _sync_agent_display_messages(
now, now,
message_json, message_json,
session_id, session_id,
index, target_seq,
), ),
) )
updated_row = {
'seq': target_seq,
'message_key': message_key,
'message_json': message_json,
}
existing[target_seq] = updated_row # type: ignore[assignment]
existing_by_key[message_key] = updated_row # type: ignore[assignment]
keep_seqs.add(target_seq)
stale_seqs = [
seq
for seq, row in existing.items()
if seq not in keep_seqs
and not _is_persistent_display_row(row)
]
if stale_seqs:
conn.execute(
f"""
delete from agent_display_messages
where session_id = ?
and seq in ({','.join('?' for _ in stale_seqs)})
""",
(session_id, *stale_seqs),
)
def _display_message_key(message: JSONDict) -> str: def _display_message_key(message: JSONDict) -> str:
@@ -795,6 +868,22 @@ def _is_replaceable_display_message(message: JSONDict) -> bool:
return metadata.get('kind') in {'run_status'} return metadata.get('kind') in {'run_status'}
def _is_persistent_display_message(message: JSONDict) -> bool:
metadata = message.get('metadata')
return (
isinstance(metadata, dict)
and metadata.get('kind') in {'runtime_guidance_display'}
)
def _is_persistent_display_row(row: sqlite3.Row | dict[str, Any]) -> bool:
try:
message = json.loads(row['message_json'])
except (TypeError, json.JSONDecodeError, KeyError):
return False
return isinstance(message, dict) and _is_persistent_display_message(message)
def _same_role_and_content(left: JSONDict, right: JSONDict) -> bool: def _same_role_and_content(left: JSONDict, right: JSONDict) -> bool:
return ( return (
str(left.get('role') or '') == str(right.get('role') or '') str(left.get('role') or '') == str(right.get('role') or '')