mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-20 15:25:50 +00:00
feat(artifact): add extractArtifacts message scanner
Scans Message[] for artifact tool_use/tool_result pairs, parses URL/id/expires from the upload response string, and returns ArtifactInfo[] newest-first. Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
120
src/commands/artifacts/__tests__/scanner.test.ts
Normal file
120
src/commands/artifacts/__tests__/scanner.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { extractArtifacts } from '../scanner.js'
|
||||
import type { Message } from 'src/types/message.js'
|
||||
|
||||
function assistantToolUse(id: string, input: Record<string, unknown>): Message {
|
||||
return {
|
||||
type: 'assistant',
|
||||
uuid: crypto.randomUUID(),
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use' as const, id, name: 'artifact', input }],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function userToolResult(id: string, content: string, isError = false): Message {
|
||||
return {
|
||||
type: 'user',
|
||||
uuid: crypto.randomUUID(),
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result' as const,
|
||||
tool_use_id: id,
|
||||
content,
|
||||
is_error: isError,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('extractArtifacts', () => {
|
||||
test('returns empty list when no artifact tool_use messages', () => {
|
||||
expect(extractArtifacts([])).toEqual([])
|
||||
expect(
|
||||
extractArtifacts([
|
||||
{
|
||||
type: 'user',
|
||||
uuid: crypto.randomUUID(),
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text' as const, text: 'hi' }],
|
||||
},
|
||||
},
|
||||
]),
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('pairs a successful tool_use with its tool_result and returns parsed fields', () => {
|
||||
const messages: Message[] = [
|
||||
assistantToolUse('tu1', { file_path: '/tmp/report.html', ttl: 7 }),
|
||||
userToolResult(
|
||||
'tu1',
|
||||
'Artifact uploaded: https://x.test/7d/abc.html (id: abc, expires: 2026-06-27T10:00:00.000Z)',
|
||||
),
|
||||
]
|
||||
|
||||
const result = extractArtifacts(messages)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({
|
||||
filePath: '/tmp/report.html',
|
||||
hash: 'abc',
|
||||
url: 'https://x.test/7d/abc.html',
|
||||
expiresAt: '2026-06-27T10:00:00.000Z',
|
||||
basename: 'report.html',
|
||||
isError: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('skips artifact tool_use without a matching tool_result', () => {
|
||||
const messages: Message[] = [
|
||||
assistantToolUse('tu1', { file_path: '/tmp/report.html', ttl: 7 }),
|
||||
]
|
||||
|
||||
expect(extractArtifacts(messages)).toEqual([])
|
||||
})
|
||||
|
||||
test('keeps error results with isError=true and no parsed fields', () => {
|
||||
const messages: Message[] = [
|
||||
assistantToolUse('tu1', { file_path: '/tmp/missing.html', ttl: 7 }),
|
||||
userToolResult(
|
||||
'tu1',
|
||||
'File does not exist or is not readable: /tmp/missing.html',
|
||||
true,
|
||||
),
|
||||
]
|
||||
|
||||
const result = extractArtifacts(messages)
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({
|
||||
filePath: '/tmp/missing.html',
|
||||
basename: 'missing.html',
|
||||
isError: true,
|
||||
})
|
||||
expect(result[0].url).toBeUndefined()
|
||||
})
|
||||
|
||||
test('orders newest first (last in conversation appears at top)', () => {
|
||||
const messages: Message[] = [
|
||||
assistantToolUse('tu1', { file_path: '/tmp/a.html', ttl: 7 }),
|
||||
userToolResult(
|
||||
'tu1',
|
||||
'Artifact uploaded: https://x.test/7d/a.html (id: a, expires: 2026-06-27T10:00:00.000Z)',
|
||||
),
|
||||
assistantToolUse('tu2', { file_path: '/tmp/b.html', ttl: 7 }),
|
||||
userToolResult(
|
||||
'tu2',
|
||||
'Artifact uploaded: https://x.test/7d/b.html (id: b, expires: 2026-06-27T10:00:00.000Z)',
|
||||
),
|
||||
]
|
||||
|
||||
const result = extractArtifacts(messages)
|
||||
|
||||
expect(result.map(r => r.basename)).toEqual(['b.html', 'a.html'])
|
||||
})
|
||||
})
|
||||
97
src/commands/artifacts/scanner.ts
Normal file
97
src/commands/artifacts/scanner.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { basename } from 'path'
|
||||
import type { Message } from 'src/types/message.js'
|
||||
|
||||
export type ArtifactInfo = {
|
||||
toolUseId: string
|
||||
filePath: string
|
||||
basename: string
|
||||
hash?: string
|
||||
url?: string
|
||||
expiresAt?: string
|
||||
rawContent: string
|
||||
isError: boolean
|
||||
}
|
||||
|
||||
const URL_REGEX = /https?:\/\/\S+\.html\b/
|
||||
const ID_REGEX = /\bid:\s*([A-Za-z0-9_-]+)/
|
||||
const EXPIRES_REGEX = /\bexpires:\s*([0-9T:.Z+-]+)/
|
||||
|
||||
export function extractArtifacts(messages: Message[]): ArtifactInfo[] {
|
||||
const results: ArtifactInfo[] = []
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.type !== 'assistant') continue
|
||||
const content = message.message?.content
|
||||
if (!Array.isArray(content)) continue
|
||||
|
||||
for (const block of content) {
|
||||
if (typeof block !== 'object' || block === null) continue
|
||||
if (!('type' in block)) continue
|
||||
const b = block as Record<string, unknown>
|
||||
if (b.type !== 'tool_use') continue
|
||||
if (b.name !== 'artifact') continue
|
||||
|
||||
const toolUseId = b.id as string
|
||||
const input = b.input as { file_path?: string } | undefined
|
||||
const filePath = input?.file_path ?? '<unknown>'
|
||||
|
||||
const resultBlock = findToolResult(messages, toolUseId)
|
||||
if (!resultBlock) continue
|
||||
|
||||
const rawContent =
|
||||
typeof resultBlock.content === 'string'
|
||||
? resultBlock.content
|
||||
: Array.isArray(resultBlock.content)
|
||||
? resultBlock.content
|
||||
.map(c =>
|
||||
typeof c === 'string'
|
||||
? c
|
||||
: 'text' in c
|
||||
? (c as { text: string }).text
|
||||
: '',
|
||||
)
|
||||
.join('')
|
||||
: ''
|
||||
|
||||
const isError = resultBlock.is_error === true
|
||||
const urlMatch = rawContent.match(URL_REGEX)
|
||||
const idMatch = rawContent.match(ID_REGEX)
|
||||
const expiresMatch = rawContent.match(EXPIRES_REGEX)
|
||||
|
||||
results.push({
|
||||
toolUseId,
|
||||
filePath,
|
||||
basename: basename(filePath),
|
||||
hash: idMatch?.[1],
|
||||
url: urlMatch?.[0],
|
||||
expiresAt: expiresMatch?.[1],
|
||||
rawContent,
|
||||
isError,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// newest first
|
||||
return results.reverse()
|
||||
}
|
||||
|
||||
function findToolResult(
|
||||
messages: Message[],
|
||||
toolUseId: string,
|
||||
): { content: unknown; is_error?: boolean } | null {
|
||||
for (const message of messages) {
|
||||
if (message.type !== 'user') continue
|
||||
const content = message.message?.content
|
||||
if (!Array.isArray(content)) continue
|
||||
|
||||
for (const block of content) {
|
||||
if (typeof block !== 'object' || block === null) continue
|
||||
if (!('type' in block)) continue
|
||||
const b = block as Record<string, unknown>
|
||||
if (b.type !== 'tool_result') continue
|
||||
if (b.tool_use_id !== toolUseId) continue
|
||||
return { content: b.content, is_error: b.is_error as boolean | undefined }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user