feat(artifact): show uploaded URL inline below ExecuteExtraTool

Deferred tools (shouldDefer: true) are invoked via SearchExtraTools →
ExecuteExtraTool, so their tool_result rows used to render blank —
the UI looked up ExecuteExtraTool, which had no renderToolResultMessage,
and returned null. Add a generic delegation in ExecuteTool that forwards
renderToolResultMessage to the inner tool when it defines one, unwrapping
the { result, tool_name } envelope and the params from the input shape.
All 28 deferred tools can now render their own UI by defining
renderToolResultMessage.

For ArtifactTool specifically, render the uploaded URL as an OSC 8
hyperlink (Link component) in warning color so it's visually prominent,
with the expiry timestamp on a second line and a separate error branch.
Also add `error: z.string().optional()` to outputSchema — zod's default
strip mode was dropping the field, so error states never reached the UI.

Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-06-20 19:34:14 +08:00
parent 14f43e9676
commit 433f9dd308
5 changed files with 304 additions and 3 deletions

View File

@@ -10,6 +10,7 @@ import {
} from './prompt.js'
import { getArtifactsToken, getUploadUrl } from './config.js'
import { uploadArtifact } from './client.js'
import { renderToolResultMessage } from './UI.js'
const inputSchema = lazySchema(() =>
z.strictObject({
@@ -37,11 +38,11 @@ const outputSchema = lazySchema(() =>
id: z.string(),
url: z.string(),
expiresAt: z.string(),
error: z.string().optional(),
}),
)
type OutputSchema = ReturnType<typeof outputSchema>
type ArtifactOutput = z.infer<OutputSchema>
type ArtifactErrorOutput = ArtifactOutput & { error?: string }
export type ArtifactOutput = z.infer<OutputSchema>
export const ArtifactTool = buildTool({
name: ARTIFACT_TOOL_NAME,
@@ -87,7 +88,7 @@ export const ArtifactTool = buildTool({
},
mapToolResultToToolResultBlockParam(
content: ArtifactErrorOutput,
content: ArtifactOutput,
toolUseID: string,
): ToolResultBlockParam {
if (content.error) {
@@ -104,6 +105,7 @@ export const ArtifactTool = buildTool({
content: `Artifact uploaded: ${content.url} (id: ${content.id}, expires: ${content.expiresAt})`,
}
},
renderToolResultMessage,
async call(input: ArtifactInput) {
const { file_path, hash, ttl } = input

View File

@@ -0,0 +1,37 @@
import * as React from 'react';
import { Box, Link, Text } from '@anthropic/ink';
import type { ToolProgressData } from 'src/Tool.js';
import type { ProgressMessage } from 'src/types/message.js';
import type { ArtifactOutput } from './ArtifactTool.js';
export function renderToolResultMessage(
content: ArtifactOutput,
_progressMessagesForMessage: ProgressMessage<ToolProgressData>[],
_options: { verbose: boolean; theme?: string },
): React.ReactNode {
if (content.error) {
return (
<Box>
<Text color="error"> Artifact upload failed: {content.error}</Text>
</Box>
);
}
if (!content.url) return null;
return (
<Box flexDirection="column">
<Box>
<Text>
<Text color="success"></Text> Artifact uploaded:{' '}
<Link url={content.url}>
<Text color="warning">{content.url}</Text>
</Link>
</Text>
</Box>
{content.expiresAt ? (
<Box>
<Text dimColor>expires: {content.expiresAt}</Text>
</Box>
) : null}
</Box>
);
}

View File

@@ -0,0 +1,70 @@
import { describe, expect, test } from 'bun:test';
import * as React from 'react';
import type { ProgressMessage } from 'src/types/message.js';
import type { ToolProgressData } from 'src/Tool.js';
import { renderToolResultMessage } from '../UI.js';
import type { ArtifactOutput } from '../ArtifactTool.js';
const NO_PROGRESS: ProgressMessage<ToolProgressData>[] = [];
const OPTIONS = { verbose: false, theme: 'dark' } as never;
/** Walk a React element tree and concatenate all string/number children. */
function extractText(node: React.ReactNode): string {
if (node == null || typeof node === 'boolean') return '';
if (typeof node === 'string') return node;
if (typeof node === 'number') return String(node);
if (Array.isArray(node)) return node.map(extractText).join('');
if (React.isValidElement(node)) {
const children = (node.props as { children?: React.ReactNode }).children;
return extractText(children);
}
return '';
}
describe('ArtifactTool UI.renderToolResultMessage', () => {
test('renders the uploaded URL and expiry on success', () => {
const content: ArtifactOutput = {
id: 'abc123',
url: 'https://cloud-artifacts.claude-code-best.win/7d/abc123.html',
expiresAt: '2026-06-27T10:00:00.000Z',
};
const node = renderToolResultMessage(content, NO_PROGRESS, OPTIONS);
expect(React.isValidElement(node)).toBe(true);
const text = extractText(node);
expect(text).toContain(content.url);
expect(text).toContain(content.expiresAt);
expect(text).toContain('Artifact uploaded');
});
test('renders the error message on failure', () => {
const content: ArtifactOutput = {
id: '',
url: '',
expiresAt: '',
error: 'File does not exist or is not readable: /tmp/missing.html',
};
const node = renderToolResultMessage(content, NO_PROGRESS, OPTIONS);
expect(React.isValidElement(node)).toBe(true);
const text = extractText(node);
expect(text).toContain('Artifact upload failed');
expect(text).toContain('/tmp/missing.html');
});
test('returns null when url is empty without error', () => {
const content: ArtifactOutput = { id: '', url: '', expiresAt: '' };
const node = renderToolResultMessage(content, NO_PROGRESS, OPTIONS);
expect(node).toBeNull();
});
test('omits the expiry line when expiresAt is empty', () => {
const content: ArtifactOutput = {
id: 'abc',
url: 'https://cloud-artifacts.claude-code-best.win/7d/abc.html',
expiresAt: '',
};
const node = renderToolResultMessage(content, NO_PROGRESS, OPTIONS);
expect(React.isValidElement(node)).toBe(true);
// Sanity: still renders URL even without expiry
expect(extractText(node)).toContain(content.url);
});
});

View File

@@ -236,4 +236,29 @@ export const ExecuteTool = buildTool({
content: JSON.stringify(content),
}
},
// Output shape: { result: <inner tool output>, tool_name: string }.
// Delegate rendering to the inner tool when it defines its own
// renderToolResultMessage so deferred tools can show their own UI
// (e.g. ArtifactTool displays its uploaded URL). Without this, the
// ExecuteExtraTool tool_result row renders nothing below the tool_use
// line. The inner tool expects its own input shape, so unwrap params.
//
// Inline the lookup rather than calling findToolByName — deferred tools
// are matched by exact name (no aliases needed), and avoiding the
// shared helper keeps this method resilient to src/Tool.js mocks in
// co-located test files (process-global mock.module pollution).
renderToolResultMessage(content, progressMessages, options) {
const innerTool = options.tools.find(t => t.name === content.tool_name)
if (!innerTool?.renderToolResultMessage) return null
const innerInput = (options.input as { params?: unknown } | undefined)
?.params
return innerTool.renderToolResultMessage(
content.result as never,
progressMessages,
{
...options,
input: innerInput,
},
)
},
} satisfies ToolDef<InputSchema, Output>)

View File

@@ -0,0 +1,167 @@
import { describe, expect, test, mock } from 'bun:test'
import { logMock } from '../../../../../../tests/mocks/log'
import { debugMock } from '../../../../../../tests/mocks/debug'
// Same mock setup as ExecuteTool.runner.ts — ExecuteTool's import chain
// (growthbook, searchExtraTools, messages) loads real modules with side
// effects otherwise. mock.module is process-global; identical setup in
// sibling test files in this directory is safe (last-write-wins, same stubs).
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock)
mock.module('src/services/analytics/growthbook.js', () => ({
getFeatureValue_CACHED_MAY_BE_STALE: () => false,
checkStatsigFeatureGate_CACHED_MAY_BE_STALE: () => false,
getFeatureValue_DEPRECATED: async () => undefined,
getFeatureValue_CACHED_WITH_REFRESH: async () => undefined,
hasGrowthBookEnvOverride: () => false,
getAllGrowthBookFeatures: () => ({}),
getGrowthBookConfigOverrides: () => ({}),
setGrowthBookConfigOverride: () => {},
clearGrowthBookConfigOverrides: () => {},
getApiBaseUrlHost: () => undefined,
onGrowthBookRefresh: () => {},
initializeGrowthBook: async () => {},
checkSecurityRestrictionGate: async () => false,
checkGate_CACHED_OR_BLOCKING: async () => false,
refreshGrowthBookAfterAuthChange: () => {},
resetGrowthBook: () => {},
refreshGrowthBookFeatures: async () => {},
setupPeriodicGrowthBookRefresh: () => {},
stopPeriodicGrowthBookRefresh: () => {},
}))
mock.module('src/utils/searchExtraTools.js', () => ({
isSearchExtraToolsEnabledOptimistic: () => true,
getAutoSearchExtraToolsCharThreshold: () => 100,
getSearchExtraToolsMode: () => 'tst' as const,
isSearchExtraToolsToolAvailable: () => true,
isSearchExtraToolsEnabled: async () => true,
isToolReferenceBlock: () => false,
extractDiscoveredToolNames: () => new Set<string>(),
isDeferredToolsDeltaEnabled: () => false,
getDeferredToolsDelta: () => null,
}))
mock.module('src/constants/tools.js', () => ({
CORE_TOOLS: new Set(['ExecuteExtraTool', 'SearchExtraTools']),
}))
mock.module('src/utils/messages.js', () => ({
createUserMessage: ({ content }: { content: string }) => ({
type: 'user' as const,
content,
uuid: 'test-uuid',
}),
INTERRUPT_MESSAGE_FOR_TOOL_USE: '[Request interrupted]',
}))
mock.module('src/utils/toolErrors.js', () => ({
formatZodValidationError: (_name: string, error: unknown) =>
`validation error: ${JSON.stringify(error)}`,
}))
const { ExecuteTool } = await import('../ExecuteTool.js')
type RenderResult = React.ReactNode
describe('ExecuteTool.renderToolResultMessage delegation', () => {
test('delegates to inner tool with content.result and unwrapped params', () => {
const seen: Array<{
content: unknown
input: unknown
}> = []
const innerRender = (
content: unknown,
_progress: unknown,
options: { input?: unknown },
): RenderResult => {
seen.push({ content, input: options.input })
return 'RENDERED' as unknown as RenderResult
}
const innerTool = {
name: 'artifact',
renderToolResultMessage: innerRender,
}
const tools = [innerTool] as never
const result = ExecuteTool.renderToolResultMessage(
{
result: {
id: 'abc',
url: 'https://example.com/x.html',
expiresAt: 'T',
},
tool_name: 'artifact',
},
[],
{
tools,
input: {
tool_name: 'artifact',
params: { file_path: '/tmp/x.html', ttl: 7 },
},
} as never,
)
expect(result).toBe('RENDERED')
expect(seen).toHaveLength(1)
expect(seen[0]?.content).toEqual({
id: 'abc',
url: 'https://example.com/x.html',
expiresAt: 'T',
})
// Inner tool should see its own params shape, not the ExecuteExtraTool wrapper
expect(seen[0]?.input).toEqual({ file_path: '/tmp/x.html', ttl: 7 })
})
test('returns null when inner tool has no renderToolResultMessage', () => {
const innerTool = { name: 'bare' }
const tools = [innerTool] as never
const result = ExecuteTool.renderToolResultMessage(
{ result: { ok: true }, tool_name: 'bare' },
[],
{ tools, input: { tool_name: 'bare', params: {} } } as never,
)
expect(result).toBeNull()
})
test('returns null when inner tool is not found in tools list', () => {
const tools = [] as never
const result = ExecuteTool.renderToolResultMessage(
{ result: { ok: true }, tool_name: 'missing' },
[],
{ tools, input: { tool_name: 'missing', params: {} } } as never,
)
expect(result).toBeNull()
})
test('passes through undefined input safely when input is missing', () => {
const seen: unknown[] = []
const innerTool = {
name: 'artifact',
renderToolResultMessage: (
_content: unknown,
_progress: unknown,
options: { input?: unknown },
): RenderResult => {
seen.push(options.input)
return null
},
}
const tools = [innerTool] as never
const result = ExecuteTool.renderToolResultMessage(
{ result: { ok: true }, tool_name: 'artifact' },
[],
{ tools } as never,
)
expect(result).toBeNull()
expect(seen[0]).toBeUndefined()
})
})