diff --git a/agentex-ui/components/primary-content/prompt-input.tsx b/agentex-ui/components/primary-content/prompt-input.tsx index 81738fd8..253061b0 100644 --- a/agentex-ui/components/primary-content/prompt-input.tsx +++ b/agentex-ui/components/primary-content/prompt-input.tsx @@ -21,6 +21,7 @@ import { } from '@/hooks/use-safe-search-params'; import { useSendMessage } from '@/hooks/use-task-messages'; import { useTask } from '@/hooks/use-tasks'; +import { deriveTaskDisplayName } from '@/lib/task-utils'; import { TaskStatusEnum } from '@/lib/types'; type PromptInputProps = { @@ -139,6 +140,7 @@ export function PromptInput({ prompt, setPrompt }: PromptInputProps) { description: prompt, content: currentPrompt, }, + task_metadata: { display_name: deriveTaskDisplayName(prompt) }, }); currentTaskId = task.id; updateParams({ [SearchParamKey.TASK_ID]: currentTaskId }); diff --git a/agentex-ui/hooks/use-create-task.test.tsx b/agentex-ui/hooks/use-create-task.test.tsx new file mode 100644 index 00000000..3d9abda8 --- /dev/null +++ b/agentex-ui/hooks/use-create-task.test.tsx @@ -0,0 +1,100 @@ +import type { ReactNode } from 'react'; + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, renderHook } from '@testing-library/react'; +import { agentRPCNonStreaming } from 'agentex/lib'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useCreateTask } from './use-create-task'; + +import type AgentexSDK from 'agentex'; + +vi.mock('agentex/lib', () => ({ + agentRPCNonStreaming: vi.fn(), +})); + +function createWrapper(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return ( + {children} + ); + }; +} + +function mockCreatedTask(task: Record) { + vi.mocked(agentRPCNonStreaming).mockResolvedValue({ + jsonrpc: '2.0', + id: 'rpc-1', + result: task, + error: null, + } as never); +} + +describe('useCreateTask', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('forwards task_metadata in the task/create request body', async () => { + mockCreatedTask({ + id: 'task-1', + name: null, + task_metadata: { display_name: 'say hello' }, + }); + const agentexClient = {} as unknown as AgentexSDK; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + const { result } = renderHook(() => useCreateTask({ agentexClient }), { + wrapper: createWrapper(queryClient), + }); + + await act(async () => { + await result.current.mutateAsync({ + agentName: 'my-agent', + params: { description: 'say hello', content: 'say hello' }, + task_metadata: { display_name: 'say hello' }, + }); + }); + + expect(agentRPCNonStreaming).toHaveBeenCalledWith( + agentexClient, + { agentName: 'my-agent' }, + 'task/create', + { + params: { description: 'say hello', content: 'say hello' }, + task_metadata: { display_name: 'say hello' }, + } + ); + }); + + it('sends null task_metadata when none is provided', async () => { + mockCreatedTask({ id: 'task-2', name: null, task_metadata: null }); + const agentexClient = {} as unknown as AgentexSDK; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + const { result } = renderHook(() => useCreateTask({ agentexClient }), { + wrapper: createWrapper(queryClient), + }); + + await act(async () => { + await result.current.mutateAsync({ + agentName: 'my-agent', + params: { description: 'say hello' }, + }); + }); + + expect(agentRPCNonStreaming).toHaveBeenCalledWith( + agentexClient, + { agentName: 'my-agent' }, + 'task/create', + { + params: { description: 'say hello' }, + task_metadata: null, + } + ); + }); +}); diff --git a/agentex-ui/hooks/use-create-task.ts b/agentex-ui/hooks/use-create-task.ts index 2386bab8..d1c33ea0 100644 --- a/agentex-ui/hooks/use-create-task.ts +++ b/agentex-ui/hooks/use-create-task.ts @@ -78,6 +78,7 @@ export function updateTaskInInfiniteQuery( type CreateTaskParams = { agentName: string; params?: Record; + task_metadata?: Record; }; /** @@ -101,6 +102,7 @@ export function useCreateTask({ mutationFn: async ({ agentName, params, + task_metadata, }: CreateTaskParams): Promise => { const response = await agentRPCNonStreaming( agentexClient, @@ -108,6 +110,7 @@ export function useCreateTask({ 'task/create', { params: params ?? {}, + task_metadata: task_metadata ?? null, } ); diff --git a/agentex-ui/lib/task-utils.test.ts b/agentex-ui/lib/task-utils.test.ts index a8a10578..a8c07ffd 100644 --- a/agentex-ui/lib/task-utils.test.ts +++ b/agentex-ui/lib/task-utils.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; -import { createTaskName } from '@/lib/task-utils'; +import { createTaskName, deriveTaskDisplayName } from '@/lib/task-utils'; import type { TaskListResponse } from 'agentex/resources'; @@ -52,3 +52,48 @@ describe('createTaskName', () => { expect(createTaskName(task)).toBe('Unnamed task'); }); }); + +describe('deriveTaskDisplayName', () => { + it('returns a short prompt unchanged', () => { + expect(deriveTaskDisplayName('say hello')).toBe('say hello'); + }); + + it('trims surrounding whitespace', () => { + expect(deriveTaskDisplayName(' say hello \n')).toBe('say hello'); + }); + + it('collapses internal whitespace and newlines to single spaces', () => { + expect(deriveTaskDisplayName('summarize\nthis report\tplease')).toBe( + 'summarize this report please' + ); + }); + + it('truncates to 80 characters', () => { + const prompt = 'a'.repeat(100); + expect(deriveTaskDisplayName(prompt)).toBe('a'.repeat(80)); + }); + + it('keeps an astral character whole at the truncation boundary', () => { + // '😀' is two UTF-16 units; a unit-based slice(0, 80) would cut it in + // half here, leaving a lone surrogate that Postgres JSONB rejects. + const prompt = 'a'.repeat(79) + '😀 rest of the prompt'; + expect(deriveTaskDisplayName(prompt)).toBe('a'.repeat(79) + '😀'); + }); + + it('counts astral characters as single characters when truncating', () => { + const prompt = '😀'.repeat(100); + expect(deriveTaskDisplayName(prompt)).toBe('😀'.repeat(80)); + }); + + it('produces a label createTaskName resolves for a UI-created task', () => { + // Writer/reader contract: a task shaped like the prompt-input writer's + // output (display_name set, name null) must not render as "Unnamed task". + const task = { + id: '123', + name: null, + task_metadata: { display_name: deriveTaskDisplayName('say hello') }, + } as unknown as TaskListResponse.TaskListResponseItem; + + expect(createTaskName(task)).toBe('say hello'); + }); +}); diff --git a/agentex-ui/lib/task-utils.ts b/agentex-ui/lib/task-utils.ts index 1e5a3e23..b3d85378 100644 --- a/agentex-ui/lib/task-utils.ts +++ b/agentex-ui/lib/task-utils.ts @@ -9,6 +9,19 @@ export function isScheduledTask( return typeof scheduleId === 'string' && scheduleId.length > 0; } +/** + * Derives the task_metadata.display_name written at task creation from the + * user's prompt. This is the writer-side counterpart of createTaskName, which + * reads display_name first — do not write the prompt into task.name, which is + * a globally-unique get-or-create idempotency key, not a label. + */ +export function deriveTaskDisplayName(prompt: string): string { + // Truncate by code points, not UTF-16 units: a unit-based slice can split a + // surrogate pair, and Postgres rejects lone surrogates in JSONB, failing the + // whole task/create request. + return Array.from(prompt.trim().replace(/\s+/g, ' ')).slice(0, 80).join(''); +} + export function createTaskName( task: TaskListResponse.TaskListResponseItem ): string {