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 {