From bdd023d0af3df26341427b5821e332458e3a7fd9 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 20 Jun 2026 16:01:30 +0800 Subject: [PATCH] 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 --- .../artifacts/__tests__/scanner.test.ts | 120 ++++++++++++++++++ src/commands/artifacts/scanner.ts | 97 ++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 src/commands/artifacts/__tests__/scanner.test.ts create mode 100644 src/commands/artifacts/scanner.ts diff --git a/src/commands/artifacts/__tests__/scanner.test.ts b/src/commands/artifacts/__tests__/scanner.test.ts new file mode 100644 index 000000000..b695f9083 --- /dev/null +++ b/src/commands/artifacts/__tests__/scanner.test.ts @@ -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): 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']) + }) +}) diff --git a/src/commands/artifacts/scanner.ts b/src/commands/artifacts/scanner.ts new file mode 100644 index 000000000..911e57d41 --- /dev/null +++ b/src/commands/artifacts/scanner.ts @@ -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 + 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 ?? '' + + 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 + 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 +}