File size: 9,749 Bytes
a1b6014
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
import { describe, expect, it } from 'vitest'

import type { ChatMessage, ChatMessagePart } from '@/lib/chat-messages'
import type { ComposerAttachment } from '@/store/composer'

import {
  attachmentDisplayText,
  attachmentId,
  coalesceToolOnlyAssistants,
  coerceThinkingText,
  createToolMergeCache,
  messageCreatedAt,
  optimisticAttachmentRef,
  toRuntimeMessage
} from './chat-runtime'

const DATA_URL = 'data:image/png;base64,iVBORw0KGgoAAAANS'
const THUMB_URL = 'data:image/png;base64,dGh1bWI='

function attachment(overrides: Partial<ComposerAttachment> & Pick<ComposerAttachment, 'kind'>): ComposerAttachment {
  return { id: 'a', label: 'file.png', ...overrides }
}

describe('optimisticAttachmentRef', () => {
  it('renders an image from its in-hand base64 preview (no @image: path ref)', () => {
    const ref = optimisticAttachmentRef(attachment({ kind: 'image', detail: '/tmp/shot.png', previewUrl: DATA_URL }))

    // The raw data URL flows through extractEmbeddedImages → inline thumbnail,
    // dodging the remote /api/media 403 an @image:<localpath> ref would hit.
    expect(ref).toBe(DATA_URL)
  })

  it('prefers the downscaled thumbnail for the display ref when present', () => {
    const ref = optimisticAttachmentRef(
      attachment({ kind: 'image', detail: '/tmp/shot.png', previewUrl: DATA_URL, thumbnailUrl: THUMB_URL })
    )

    // The bubble is display-only; full bytes are read on demand and for upload.
    expect(ref).toBe(THUMB_URL)
  })

  it('does not render a full path-backed image while its bounded thumbnail is pending', () => {
    expect(optimisticAttachmentRef(attachment({ kind: 'image', detail: '/tmp/shot.png' }))).toBeNull()
  })

  it('does not use a path fallback for a non-data preview url', () => {
    const ref = optimisticAttachmentRef(
      attachment({ kind: 'image', detail: '/tmp/shot.png', previewUrl: 'https://example.com/x.png' })
    )

    expect(ref).toBeNull()
  })

  it('passes non-image attachments straight through to attachmentDisplayText', () => {
    expect(optimisticAttachmentRef(attachment({ kind: 'file', refText: '@file:src/a.ts', previewUrl: DATA_URL }))).toBe(
      '@file:src/a.ts'
    )
  })

  // Session switches / draft restores can leave undefined|null holes in the
  // composer attachments array. AttachmentList already filters them (#49624),
  // but the submit path maps the same array through these helpers — an unguarded
  // hole threw "Cannot read properties of undefined (reading 'refText')",
  // crashing the chat surface (blank pane). The helpers must no-op on holes.
  it('returns null for an undefined attachment instead of throwing', () => {
    expect(() => optimisticAttachmentRef(undefined as unknown as ComposerAttachment)).not.toThrow()
    expect(optimisticAttachmentRef(undefined as unknown as ComposerAttachment)).toBeNull()
  })

  it('returns null for a null attachment instead of throwing', () => {
    expect(optimisticAttachmentRef(null as unknown as ComposerAttachment)).toBeNull()
  })
})

describe('attachmentDisplayText', () => {
  it('returns null for undefined|null instead of reading .kind/.refText on a hole', () => {
    expect(() => attachmentDisplayText(undefined as unknown as ComposerAttachment)).not.toThrow()
    expect(attachmentDisplayText(undefined as unknown as ComposerAttachment)).toBeNull()
    expect(attachmentDisplayText(null as unknown as ComposerAttachment)).toBeNull()
  })

  it('still resolves a normal file ref', () => {
    expect(attachmentDisplayText(attachment({ kind: 'file', refText: '@file:src/a.ts' }))).toBe('@file:src/a.ts')
  })

  it('expands a review attachment into an anchored fenced block', () => {
    const detail = JSON.stringify({
      author: 'teknium1',
      body: 'this cap looks wrong',
      diffHunk: '@@ -1,2 +1,2 @@\n-const CAP = 5\n+const CAP = 50',
      kind: 'review',
      line: 12,
      path: 'src/limits.ts',
      prNumber: 123,
      startLine: null,
      url: 'https://github.com/o/r/pull/123#discussion_r1'
    })

    const block = attachmentDisplayText(attachment({ kind: 'review', detail, refText: '@url:`https://x`' }))

    // The contract: anchor (file:line), author, body, and the hunk all ride.
    expect(block).toContain('review-comment src/limits.ts:12')
    expect(block).toContain('@teknium1')
    expect(block).toContain('this cap looks wrong')
    expect(block).toContain('const CAP = 50')
  })

  it('falls back to the url ref when a review detail is malformed', () => {
    expect(attachmentDisplayText(attachment({ kind: 'review', detail: 'not json', refText: '@url:`https://x`' }))).toBe(
      '@url:`https://x`'
    )
  })
})

describe('coerceThinkingText', () => {
  it('strips streaming status prefixes from thinking deltas', () => {
    expect(coerceThinkingText("â—‰_â—‰ processing... checking the user's request")).toBe("checking the user's request")
    expect(coerceThinkingText('(¬‿¬) analyzing... reading the file')).toBe('reading the file')
  })

  it('drops empty thinking rewrite placeholder text', () => {
    expect(
      coerceThinkingText(
        "â—‰_â—‰ processing... I don't see any current rewritten thinking or next thinking to process. Could you provide the thinking content you'd like me to rewrite?"
      )
    ).toBe('')
  })
})

describe('attachmentId', () => {
  it('normalizes a trailing slash on a url so a re-attach dedupes (#59305 P2)', () => {
    expect(attachmentId('url', 'https://example.com/a')).toBe(attachmentId('url', 'https://example.com/a/'))
  })

  it('falls back to the trimmed raw value for a malformed url instead of throwing', () => {
    expect(() => attachmentId('url', 'not a url')).not.toThrow()
    expect(attachmentId('url', '  not a url  ')).toBe(attachmentId('url', 'not a url'))
  })

  it('normalizes backslash path separators so a Windows and posix path dedupe', () => {
    expect(attachmentId('file', 'a\\b.ts')).toBe(attachmentId('file', 'a/b.ts'))
  })

  it('normalizes a trailing slash on a folder path', () => {
    expect(attachmentId('folder', 'src/app/')).toBe(attachmentId('folder', 'src/app'))
  })

  it('does not collapse a bare root path to an empty id', () => {
    expect(attachmentId('folder', '/')).toBe('folder:/')
  })

  it('keeps distinct urls distinct', () => {
    expect(attachmentId('url', 'https://example.com/a')).not.toBe(attachmentId('url', 'https://example.com/b'))
  })
})

describe('messageCreatedAt', () => {
  const NOW = Date.UTC(2026, 6, 28, 18, 0, 0)

  it('reads the authoritative Unix-seconds timestamp (not ms)', () => {
    // 1785282262s → July 2026, not the 1970 epoch a *1000-less read would give.
    expect(messageCreatedAt({ timestamp: 1785282262 }, NOW).getFullYear()).toBe(2026)
  })

  it('falls back to now — never digs digits out of the id → "20663d ago" (1970)', () => {
    // The old fallback did `new Date(Number(id.match(/\d+/)))`, so a session-style
    // id like 20260728_184420_05e697 parsed to 20260728 *ms* = Jan 1970, showing
    // as an absurd 20663-day age. A timestamp-less message is freshly created.
    expect(messageCreatedAt({ timestamp: undefined }, NOW).getTime()).toBe(NOW)
  })

  it('treats a zero / non-finite timestamp as absent', () => {
    expect(messageCreatedAt({ timestamp: 0 }, NOW).getTime()).toBe(NOW)
    expect(messageCreatedAt({ timestamp: Number.NaN }, NOW).getTime()).toBe(NOW)
  })
})

describe('toRuntimeMessage timeline metadata', () => {
  it('does not expose a fabricated visible timestamp for timestamp-less history', () => {
    const runtime = toRuntimeMessage({
      id: 'old-message',
      parts: [{ text: 'old', type: 'text' }],
      role: 'assistant'
    })

    expect((runtime.metadata?.custom as { timelineTimestamp?: number }).timelineTimestamp).toBeUndefined()
  })
})

describe('coalesceToolOnlyAssistants toolCallId uniqueness', () => {
  // Regression contract for #87857: two individually-clean assistant rows can
  // share a toolCallId (structural carry-over re-attaching a cached row's tool
  // calls while the same turn also exists as a committed row). Folding them
  // used to manufacture ONE message carrying the id twice — the exact shape
  // that makes assistant-ui's useResources throw and crash-loop the pane.
  const tool = (toolCallId: string): ChatMessagePart =>
    ({ type: 'tool-call', toolCallId, toolName: 'terminal', args: {} as never, argsText: '' }) as ChatMessagePart

  const assistant = (id: string, parts: ChatMessagePart[]): ChatMessage =>
    ({ id, role: 'assistant', parts }) as unknown as ChatMessage

  it('drops the copy the predecessor already carries, keeps the new call', () => {
    const merged = coalesceToolOnlyAssistants(
      [
        assistant('committed-49-assistant', [
          { type: 'text', text: 'working' } as ChatMessagePart,
          tool('call-a'),
          tool('call-b')
        ]),
        assistant('assistant-stream-49', [tool('call-b'), tool('call-c')])
      ],
      createToolMergeCache()
    )

    expect(merged).toHaveLength(1)

    const ids = merged[0].parts
      .filter(part => part.type === 'tool-call')
      .map(part => (part as { toolCallId: string }).toolCallId)

    expect(ids).toEqual(['call-a', 'call-b', 'call-c'])
  })

  it('folds a clean follow-up unchanged', () => {
    const merged = coalesceToolOnlyAssistants(
      [
        assistant('a1', [{ type: 'text', text: 'ok' } as ChatMessagePart, tool('call-a')]),
        assistant('a2', [tool('call-b')])
      ],
      createToolMergeCache()
    )

    expect(merged).toHaveLength(1)

    const ids = merged[0].parts
      .filter(part => part.type === 'tool-call')
      .map(part => (part as { toolCallId: string }).toolCallId)

    expect(ids).toEqual(['call-a', 'call-b'])
  })
})