From e998a9a1a4c72c551ba2b70daebbca6d865eb40d Mon Sep 17 00:00:00 2001 From: Jerome Romualdez Date: Tue, 18 Aug 2026 14:03:43 -0400 Subject: [PATCH 1/2] fix(ui): set task_metadata.display_name when creating tasks from the prompt box Since #377 the list-tasks response omits `params`, and the sidebar label resolver reads `task_metadata.display_name` then `task.name`. The prompt-box writer was never updated to match: it still set only `params.description`, which the sidebar can no longer see, so every manually created task rendered as "Unnamed task". Write the label into `task_metadata.display_name` at creation (prompt text, whitespace-collapsed, truncated to 80 chars) and plumb `task_metadata` through useCreateTask, which previously dropped it. `task.name` is left untouched: it is a globally-unique get-or-create idempotency key, and writing prompts into it would silently merge unrelated tasks. Adds a writer/reader contract test so the label source and the label writer can't drift apart silently again. Co-Authored-By: Claude Fable 5 --- .../primary-content/prompt-input.tsx | 2 + agentex-ui/hooks/use-create-task.test.tsx | 100 ++++++++++++++++++ agentex-ui/hooks/use-create-task.ts | 3 + agentex-ui/lib/task-utils.test.ts | 35 +++++- agentex-ui/lib/task-utils.ts | 10 ++ 5 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 agentex-ui/hooks/use-create-task.test.tsx 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..35d45155 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,36 @@ 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('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..ae8492b0 100644 --- a/agentex-ui/lib/task-utils.ts +++ b/agentex-ui/lib/task-utils.ts @@ -9,6 +9,16 @@ 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 { + return prompt.trim().replace(/\s+/g, ' ').slice(0, 80); +} + export function createTaskName( task: TaskListResponse.TaskListResponseItem ): string { From 01bfb4faab8d16ef2fb6926d983d5ebe6fc3d25f Mon Sep 17 00:00:00 2001 From: Jerome Romualdez Date: Tue, 18 Aug 2026 14:26:00 -0400 Subject: [PATCH 2/2] fix(ui): truncate display_name by code points to avoid splitting surrogate pairs A UTF-16 unit-based slice(0, 80) can cut an astral character (e.g. emoji) in half at the boundary, producing a lone surrogate. Postgres rejects lone surrogates in JSONB, so the whole task/create request would fail. Truncate by code points instead, and cover the boundary case in tests. Co-Authored-By: Claude Fable 5 --- agentex-ui/lib/task-utils.test.ts | 12 ++++++++++++ agentex-ui/lib/task-utils.ts | 5 ++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/agentex-ui/lib/task-utils.test.ts b/agentex-ui/lib/task-utils.test.ts index 35d45155..a8c07ffd 100644 --- a/agentex-ui/lib/task-utils.test.ts +++ b/agentex-ui/lib/task-utils.test.ts @@ -73,6 +73,18 @@ describe('deriveTaskDisplayName', () => { 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". diff --git a/agentex-ui/lib/task-utils.ts b/agentex-ui/lib/task-utils.ts index ae8492b0..b3d85378 100644 --- a/agentex-ui/lib/task-utils.ts +++ b/agentex-ui/lib/task-utils.ts @@ -16,7 +16,10 @@ export function isScheduledTask( * a globally-unique get-or-create idempotency key, not a label. */ export function deriveTaskDisplayName(prompt: string): string { - return prompt.trim().replace(/\s+/g, ' ').slice(0, 80); + // 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(