Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions agentex-ui/components/primary-content/prompt-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 });
Expand Down
100 changes: 100 additions & 0 deletions agentex-ui/hooks/use-create-task.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
}

function mockCreatedTask(task: Record<string, unknown>) {
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,
}
);
});
});
3 changes: 3 additions & 0 deletions agentex-ui/hooks/use-create-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export function updateTaskInInfiniteQuery(
type CreateTaskParams = {
agentName: string;
params?: Record<string, unknown>;
task_metadata?: Record<string, unknown>;
};

/**
Expand All @@ -101,13 +102,15 @@ export function useCreateTask({
mutationFn: async ({
agentName,
params,
task_metadata,
}: CreateTaskParams): Promise<Task> => {
const response = await agentRPCNonStreaming(
agentexClient,
{ agentName },
'task/create',
{
params: params ?? {},
task_metadata: task_metadata ?? null,
}
);

Expand Down
47 changes: 46 additions & 1 deletion agentex-ui/lib/task-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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');
});
});
13 changes: 13 additions & 0 deletions agentex-ui/lib/task-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading