Skip to content

Commit 5085f79

Browse files
jasonczcHAQI
andcommitted
Fix sidebar history merge titles
via [HAQI](https://hapi.run) Co-Authored-By: HAQI <noreply@hapi.run>
1 parent eb7e170 commit 5085f79

7 files changed

Lines changed: 293 additions & 41 deletions

File tree

hub/src/store/messages.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { describe, expect, it } from 'bun:test'
2+
3+
import { Store } from './index'
4+
5+
const uploadPath = '/var/folders/tmp/hapi-blobs/session-abc/1778226295654-image.webp'
6+
7+
function userMessage(text: string, options?: {
8+
sentFrom?: string
9+
attachments?: Array<{ id: string; filename: string; mimeType: string; size: number; path: string; previewUrl?: string }>
10+
}): unknown {
11+
return {
12+
role: 'user',
13+
content: {
14+
type: 'text',
15+
text,
16+
attachments: options?.attachments
17+
},
18+
meta: options?.sentFrom ? { sentFrom: options.sentFrom } : undefined
19+
}
20+
}
21+
22+
function agentMessage(text: string): unknown {
23+
return {
24+
role: 'agent',
25+
content: {
26+
type: 'output',
27+
data: {
28+
type: 'assistant',
29+
message: {
30+
role: 'assistant',
31+
content: [{ type: 'text', text }]
32+
}
33+
}
34+
}
35+
}
36+
}
37+
38+
function getText(content: unknown): string {
39+
const record = content as { content?: { text?: string } }
40+
return record.content?.text ?? ''
41+
}
42+
43+
function createSessionPair(store: Store): { sourceId: string; targetId: string } {
44+
const source = store.sessions.getOrCreateSession('source', { path: '/tmp/source' }, null, 'default')
45+
const target = store.sessions.getOrCreateSession('target', { path: '/tmp/target' }, null, 'default')
46+
return { sourceId: source.id, targetId: target.id }
47+
}
48+
49+
describe('copySessionMessages', () => {
50+
it('skips cli upload-path echoes when canonical attachment message exists', () => {
51+
const store = new Store(':memory:')
52+
const { sourceId, targetId } = createSessionPair(store)
53+
54+
store.messages.addMessage(sourceId, userMessage('please inspect this', {
55+
sentFrom: 'webapp',
56+
attachments: [{
57+
id: 'att-1',
58+
filename: 'image.webp',
59+
mimeType: 'image/webp',
60+
size: 123,
61+
path: uploadPath,
62+
previewUrl: 'data:image/webp;base64,abc'
63+
}]
64+
}))
65+
store.messages.addMessage(sourceId, userMessage(`@${uploadPath}\n\nplease inspect this`, { sentFrom: 'cli' }))
66+
store.messages.addMessage(sourceId, agentMessage('done'))
67+
68+
const result = store.messages.copySessionMessages(sourceId, targetId)
69+
const copied = store.messages.getMessages(targetId)
70+
71+
expect(result.copied).toBe(2)
72+
expect(copied).toHaveLength(2)
73+
expect(getText(copied[0]!.content)).toBe('please inspect this')
74+
expect(copied.map((message) => getText(message.content))).not.toContain(`@${uploadPath}\n\nplease inspect this`)
75+
})
76+
77+
it('redacts orphan cli upload paths instead of copying absolute temp paths', () => {
78+
const store = new Store(':memory:')
79+
const { sourceId, targetId } = createSessionPair(store)
80+
81+
store.messages.addMessage(sourceId, userMessage(`@${uploadPath}\n\nwhat is in this image?`, { sentFrom: 'cli' }))
82+
83+
const result = store.messages.copySessionMessages(sourceId, targetId)
84+
const copied = store.messages.getMessages(targetId)
85+
86+
expect(result.copied).toBe(1)
87+
expect(copied).toHaveLength(1)
88+
expect(getText(copied[0]!.content)).toBe('@[1778226295654-image.webp]\n\nwhat is in this image?')
89+
})
90+
})

hub/src/store/messages.ts

Lines changed: 116 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,105 @@ type DbMessageRow = {
1414
local_id: string | null
1515
}
1616

17+
18+
type JsonRecord = Record<string, unknown>
19+
20+
function isRecord(value: unknown): value is JsonRecord {
21+
return value !== null && typeof value === 'object' && !Array.isArray(value)
22+
}
23+
24+
function getUserMessageText(content: unknown): string | null {
25+
if (!isRecord(content)) return null
26+
if (content.role !== 'user') return null
27+
const inner = content.content
28+
if (!isRecord(inner)) return null
29+
if (inner.type !== 'text') return null
30+
return typeof inner.text === 'string' ? inner.text : null
31+
}
32+
33+
function getSentFrom(content: unknown): string | null {
34+
if (!isRecord(content)) return null
35+
const meta = content.meta
36+
if (!isRecord(meta)) return null
37+
return typeof meta.sentFrom === 'string' ? meta.sentFrom : null
38+
}
39+
40+
function getAttachmentPaths(content: unknown): string[] {
41+
if (!isRecord(content)) return []
42+
if (content.role !== 'user') return []
43+
const inner = content.content
44+
if (!isRecord(inner) || !Array.isArray(inner.attachments)) return []
45+
46+
const paths: string[] = []
47+
for (const item of inner.attachments) {
48+
if (isRecord(item) && typeof item.path === 'string' && item.path.length > 0) {
49+
paths.push(item.path)
50+
}
51+
}
52+
return paths
53+
}
54+
55+
const HAPI_BLOBS_PATH_PATTERN = /@([^\s"'`<>()]*[/\\]hapi-blobs[/\\][^\s"'`<>()]+)/g
56+
57+
function extractHapiBlobReferences(text: string): string[] {
58+
return Array.from(text.matchAll(HAPI_BLOBS_PATH_PATTERN), (match) => match[1] ?? '')
59+
.filter((path) => path.length > 0)
60+
}
61+
62+
function basename(path: string): string {
63+
return path.split(/[/\\]/).filter(Boolean).pop() ?? 'upload'
64+
}
65+
66+
function sanitizeHapiBlobReferences(text: string): string {
67+
return text.replace(HAPI_BLOBS_PATH_PATTERN, (_match, path: string) => `@[${basename(path)}]`)
68+
}
69+
70+
function withUserMessageText(content: unknown, text: string): unknown {
71+
if (!isRecord(content)) return content
72+
const inner = content.content
73+
if (!isRecord(inner)) return content
74+
return {
75+
...content,
76+
content: {
77+
...inner,
78+
text
79+
}
80+
}
81+
}
82+
83+
function buildCopiedMessageRows(rows: DbMessageRow[]): Array<{ row: DbMessageRow; content: string }> {
84+
const canonicalAttachmentPaths = new Set<string>()
85+
for (const row of rows) {
86+
const parsed = safeJsonParse(row.content)
87+
for (const path of getAttachmentPaths(parsed)) {
88+
canonicalAttachmentPaths.add(path)
89+
}
90+
}
91+
92+
const copied: Array<{ row: DbMessageRow; content: string }> = []
93+
for (const row of rows) {
94+
const parsed = safeJsonParse(row.content)
95+
const text = getUserMessageText(parsed)
96+
const hapiBlobRefs = text ? extractHapiBlobReferences(text) : []
97+
98+
if (text && getSentFrom(parsed) === 'cli' && hapiBlobRefs.length > 0) {
99+
if (hapiBlobRefs.some((path) => canonicalAttachmentPaths.has(path))) {
100+
continue
101+
}
102+
103+
copied.push({
104+
row,
105+
content: JSON.stringify(withUserMessageText(parsed, sanitizeHapiBlobReferences(text)))
106+
})
107+
continue
108+
}
109+
110+
copied.push({ row, content: row.content })
111+
}
112+
113+
return copied
114+
}
115+
17116
function toStoredMessage(row: DbMessageRow): StoredMessage {
18117
return {
19118
id: row.id,
@@ -203,27 +302,28 @@ export function copySessionMessages(
203302
).run(oldMaxSeq, toSessionId)
204303
}
205304

206-
const result = db.prepare(`
305+
const sourceRows = db.prepare(
306+
'SELECT * FROM messages WHERE session_id = ? ORDER BY seq ASC'
307+
).all(fromSessionId) as DbMessageRow[]
308+
const copiedRows = buildCopiedMessageRows(sourceRows)
309+
const insert = db.prepare(`
207310
INSERT INTO messages (id, session_id, content, created_at, seq, local_id)
208-
SELECT
209-
lower(hex(randomblob(16))),
210-
@to_session_id,
211-
content,
212-
created_at,
213-
seq,
214-
NULL
215-
FROM messages
216-
WHERE session_id = @from_session_id
217-
ORDER BY seq ASC
218-
`).run({
219-
to_session_id: toSessionId,
220-
from_session_id: fromSessionId
221-
})
311+
VALUES (lower(hex(randomblob(16))), @session_id, @content, @created_at, @seq, NULL)
312+
`)
313+
314+
for (const item of copiedRows) {
315+
insert.run({
316+
session_id: toSessionId,
317+
content: item.content,
318+
created_at: item.row.created_at,
319+
seq: item.row.seq
320+
})
321+
}
222322

223323
rebuildSessionConversationTurns(db, toSessionId)
224324

225325
db.exec('COMMIT')
226-
return { copied: result.changes, oldMaxSeq, newMaxSeq }
326+
return { copied: copiedRows.length, oldMaxSeq, newMaxSeq }
227327
} catch (error) {
228328
db.exec('ROLLBACK')
229329
throw error

hub/src/sync/sessionCache.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,24 @@ import { clampAliveTime } from './aliveTime'
55
import { EventPublisher } from './eventPublisher'
66
import { extractTodoWriteTodosFromMessageContent, TodosSchema } from './todos'
77

8+
const HAPI_BLOBS_METADATA_PATH_PATTERN = /@([^\s"'`<>()]*[/\\]hapi-blobs[/\\][^\s"'`<>()]+)/g
9+
10+
function sanitizeMetadataDisplayText(value: string): string {
11+
return value
12+
.replace(HAPI_BLOBS_METADATA_PATH_PATTERN, '')
13+
.replace(/\s+/g, ' ')
14+
.trim()
15+
}
16+
17+
function sanitizeSummary(value: unknown): unknown | undefined {
18+
if (!value || typeof value !== 'object' || Array.isArray(value)) return value
19+
const summary = value as Record<string, unknown>
20+
if (typeof summary.text !== 'string') return value
21+
const text = sanitizeMetadataDisplayText(summary.text)
22+
if (!text) return undefined
23+
return text === summary.text ? value : { ...summary, text }
24+
}
25+
826
export class SessionCache {
927
private readonly sessions: Map<string, Session> = new Map()
1028
private readonly lastBroadcastAtBySessionId: Map<string, number> = new Map()
@@ -478,17 +496,23 @@ export class SessionCache {
478496
let changed = false
479497

480498
if (typeof oldObj.name === 'string' && typeof newObj.name !== 'string') {
481-
merged.name = oldObj.name
482-
changed = true
499+
const name = sanitizeMetadataDisplayText(oldObj.name)
500+
if (name) {
501+
merged.name = name
502+
changed = true
503+
}
483504
}
484505

485506
const oldSummary = oldObj.summary as { text?: unknown; updatedAt?: unknown } | undefined
486507
const newSummary = newObj.summary as { text?: unknown; updatedAt?: unknown } | undefined
487508
const oldUpdatedAt = typeof oldSummary?.updatedAt === 'number' ? oldSummary.updatedAt : null
488509
const newUpdatedAt = typeof newSummary?.updatedAt === 'number' ? newSummary.updatedAt : null
489510
if (oldUpdatedAt !== null && (newUpdatedAt === null || oldUpdatedAt > newUpdatedAt)) {
490-
merged.summary = oldSummary
491-
changed = true
511+
const summary = sanitizeSummary(oldSummary)
512+
if (summary) {
513+
merged.summary = summary
514+
changed = true
515+
}
492516
}
493517

494518
if (oldObj.worktree && !newObj.worktree) {

hub/src/sync/syncEngine.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ const NOTE_REFRESH_TASK_PREFIX = 'note-refresh:'
9898
const MAX_NOTE_REFRESH_CONTENT_LENGTH = 20_000
9999
const MAX_GROUP_MIRROR_TEXT_LENGTH = 8_000
100100
const GROUP_NOTE_FILE_SYNC_INTERVAL_MS = 3 * 60 * 1000
101+
102+
const HAPI_BLOBS_DISPLAY_PATH_PATTERN = /@([^\s"'`<>()]*[/\\]hapi-blobs[/\\][^\s"'`<>()]+)/g
103+
104+
function sanitizeSessionDisplayText(value: string): string {
105+
return value
106+
.replace(HAPI_BLOBS_DISPLAY_PATH_PATTERN, '')
107+
.replace(/\s+/g, ' ')
108+
.trim()
109+
}
101110
const CODEX_TOOL_PROCESS_EVENT_TYPES = new Set([
102111
'tool-call',
103112
'tool-call-result',
@@ -2054,12 +2063,14 @@ ${note.content}
20542063

20552064
const name = metadata.name?.trim()
20562065
if (name) {
2057-
return name
2066+
const title = sanitizeSessionDisplayText(name)
2067+
if (title) return title
20582068
}
20592069

20602070
const summary = metadata.summary?.text?.trim()
20612071
if (summary) {
2062-
return summary
2072+
const title = sanitizeSessionDisplayText(summary)
2073+
if (title) return title
20632074
}
20642075

20652076
const path = metadata.path?.trim()

web/src/lib/session-title.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { getSessionTitle, sanitizeSessionDisplayText } from '@/lib/session-title'
3+
4+
describe('session-title', () => {
5+
it('removes copied hapi blob paths from display text', () => {
6+
const text = '@/var/folders/tmp/hapi-blobs/session/image.webp\n\n继续检查侧边栏'
7+
expect(sanitizeSessionDisplayText(text)).toBe('继续检查侧边栏')
8+
})
9+
10+
it('falls back when metadata title only contains a hapi blob path', () => {
11+
expect(getSessionTitle({
12+
id: 'session_abcdef',
13+
metadata: {
14+
summary: { text: '@/var/folders/tmp/hapi-blobs/session/image.webp' },
15+
path: '/Users/jasonczc/workspace/haqi'
16+
}
17+
})).toBe('haqi')
18+
})
19+
})

web/src/lib/session-title.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@ import type { Session, SessionSummary } from '@/types/api'
22

33
type SessionLike = Pick<SessionSummary, 'id' | 'metadata'> | Pick<Session, 'id' | 'metadata'>
44

5+
const HAPI_BLOBS_TITLE_PATH_PATTERN = /@([^\s"'`<>()]*[/\\]hapi-blobs[/\\][^\s"'`<>()]+)/g
6+
7+
export function sanitizeSessionDisplayText(value: string): string {
8+
return value
9+
.replace(HAPI_BLOBS_TITLE_PATH_PATTERN, '')
10+
.replace(/\s+/g, ' ')
11+
.trim()
12+
}
13+
14+
function cleanTitleCandidate(value: string | undefined): string | undefined {
15+
const trimmed = value?.trim() ?? ''
16+
if (!trimmed) return undefined
17+
const sanitized = sanitizeSessionDisplayText(trimmed)
18+
return sanitized || undefined
19+
}
20+
521
export function getSessionTitle(
622
session: SessionLike | null | undefined,
723
options?: {
@@ -10,12 +26,12 @@ export function getSessionTitle(
1026
}
1127
): string {
1228
const fallbackIdLength = options?.fallbackIdLength ?? 8
13-
const name = session?.metadata?.name?.trim()
29+
const name = cleanTitleCandidate(session?.metadata?.name)
1430
if (name) {
1531
return name
1632
}
1733

18-
const summaryText = session?.metadata?.summary?.text?.trim()
34+
const summaryText = cleanTitleCandidate(session?.metadata?.summary?.text)
1935
if (summaryText) {
2036
return summaryText
2137
}

0 commit comments

Comments
 (0)