|
| 1 | +import { isHeartbeatOkResponse, isHeartbeatUserMessage } from "../auto-reply/heartbeat-filter.js"; |
| 2 | +import { HEARTBEAT_PROMPT } from "../auto-reply/heartbeat.js"; |
1 | 3 | import { stripEnvelopeFromMessages } from "./chat-sanitize.js"; |
2 | 4 | import { |
3 | 5 | DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS, |
@@ -31,6 +33,102 @@ type SessionHistoryTranscriptTarget = { |
31 | 33 | sessionFile?: string; |
32 | 34 | }; |
33 | 35 |
|
| 36 | +type RoleContentMessage = { |
| 37 | + role: string; |
| 38 | + content?: unknown; |
| 39 | +}; |
| 40 | + |
| 41 | +function asRoleContentMessage(message: SessionHistoryMessage): RoleContentMessage | null { |
| 42 | + const role = typeof message.role === "string" ? message.role.toLowerCase() : ""; |
| 43 | + if (!role) { |
| 44 | + return null; |
| 45 | + } |
| 46 | + return { |
| 47 | + role, |
| 48 | + ...(message.content !== undefined |
| 49 | + ? { content: message.content } |
| 50 | + : message.text !== undefined |
| 51 | + ? { content: message.text } |
| 52 | + : {}), |
| 53 | + }; |
| 54 | +} |
| 55 | + |
| 56 | +function isEmptyTextOnlyContent(content: unknown): boolean { |
| 57 | + if (typeof content === "string") { |
| 58 | + return content.trim().length === 0; |
| 59 | + } |
| 60 | + if (!Array.isArray(content)) { |
| 61 | + return false; |
| 62 | + } |
| 63 | + if (content.length === 0) { |
| 64 | + return true; |
| 65 | + } |
| 66 | + let sawText = false; |
| 67 | + for (const block of content) { |
| 68 | + if (!block || typeof block !== "object") { |
| 69 | + return false; |
| 70 | + } |
| 71 | + const entry = block as { type?: unknown; text?: unknown }; |
| 72 | + if (entry.type !== "text") { |
| 73 | + return false; |
| 74 | + } |
| 75 | + sawText = true; |
| 76 | + if (typeof entry.text !== "string" || entry.text.trim().length > 0) { |
| 77 | + return false; |
| 78 | + } |
| 79 | + } |
| 80 | + return sawText; |
| 81 | +} |
| 82 | + |
| 83 | +function shouldHideSanitizedHistoryMessage(message: SessionHistoryMessage): boolean { |
| 84 | + const roleContent = asRoleContentMessage(message); |
| 85 | + if (!roleContent) { |
| 86 | + return false; |
| 87 | + } |
| 88 | + if (roleContent.role === "user" && isEmptyTextOnlyContent(message.content ?? message.text)) { |
| 89 | + return true; |
| 90 | + } |
| 91 | + if (isHeartbeatUserMessage(roleContent, HEARTBEAT_PROMPT)) { |
| 92 | + return true; |
| 93 | + } |
| 94 | + return isHeartbeatOkResponse(roleContent); |
| 95 | +} |
| 96 | + |
| 97 | +function filterVisibleSessionHistoryMessages( |
| 98 | + messages: SessionHistoryMessage[], |
| 99 | +): SessionHistoryMessage[] { |
| 100 | + if (messages.length === 0) { |
| 101 | + return messages; |
| 102 | + } |
| 103 | + let changed = false; |
| 104 | + const visible: SessionHistoryMessage[] = []; |
| 105 | + for (let i = 0; i < messages.length; i++) { |
| 106 | + const current = messages[i]; |
| 107 | + if (!current) { |
| 108 | + continue; |
| 109 | + } |
| 110 | + const currentRoleContent = asRoleContentMessage(current); |
| 111 | + const next = messages[i + 1]; |
| 112 | + const nextRoleContent = next ? asRoleContentMessage(next) : null; |
| 113 | + if ( |
| 114 | + currentRoleContent && |
| 115 | + nextRoleContent && |
| 116 | + isHeartbeatUserMessage(currentRoleContent, HEARTBEAT_PROMPT) && |
| 117 | + isHeartbeatOkResponse(nextRoleContent) |
| 118 | + ) { |
| 119 | + changed = true; |
| 120 | + i++; |
| 121 | + continue; |
| 122 | + } |
| 123 | + if (shouldHideSanitizedHistoryMessage(current)) { |
| 124 | + changed = true; |
| 125 | + continue; |
| 126 | + } |
| 127 | + visible.push(current); |
| 128 | + } |
| 129 | + return changed ? visible : messages; |
| 130 | +} |
| 131 | + |
34 | 132 | function resolveCursorSeq(cursor: string | undefined): number | undefined { |
35 | 133 | if (!cursor) { |
36 | 134 | return undefined; |
@@ -100,16 +198,15 @@ export function buildSessionHistorySnapshot(params: { |
100 | 198 | limit?: number; |
101 | 199 | cursor?: string; |
102 | 200 | }): SessionHistorySnapshot { |
103 | | - const history = paginateSessionMessages( |
| 201 | + const visibleMessages = filterVisibleSessionHistoryMessages( |
104 | 202 | toSessionHistoryMessages( |
105 | 203 | sanitizeChatHistoryMessages( |
106 | 204 | stripEnvelopeFromMessages(params.rawMessages), |
107 | 205 | params.maxChars ?? DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS, |
108 | 206 | ), |
109 | 207 | ), |
110 | | - params.limit, |
111 | | - params.cursor, |
112 | 208 | ); |
| 209 | + const history = paginateSessionMessages(visibleMessages, params.limit, params.cursor); |
113 | 210 | const rawHistoryMessages = toSessionHistoryMessages(params.rawMessages); |
114 | 211 | return { |
115 | 212 | history, |
@@ -190,6 +287,9 @@ export class SessionHistorySseState { |
190 | 287 | if (!sanitizedMessage) { |
191 | 288 | return null; |
192 | 289 | } |
| 290 | + if (shouldHideSanitizedHistoryMessage(sanitizedMessage)) { |
| 291 | + return null; |
| 292 | + } |
193 | 293 | const nextMessages = [...this.sentHistory.messages, sanitizedMessage]; |
194 | 294 | this.sentHistory = buildPaginatedSessionHistory({ |
195 | 295 | messages: nextMessages, |
|
0 commit comments