fix(agent-office): render TaskTab result_data when it's already an object

Old code assumed result_data was a JSON string and ran JSON.parse on it,
falling back to returning the value verbatim on parse error. When the
backend ships result_data as a dict (e.g. compose tasks return
{music_task_id, tracks}), JSON.parse threw, the catch returned the raw
object, and React threw error #31 'Objects are not valid as a React
child' the moment the user expanded the task row.

Extract formatResultData helper: object → JSON.stringify, JSON string
→ parse then pretty-print, plain string → as-is.

Regression tests cover all three input shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 08:38:06 +09:00
parent e6742e06ba
commit 96cc5e7839
2 changed files with 69 additions and 5 deletions

View File

@@ -11,6 +11,22 @@ const STATUS_STYLE = {
rejected: { bg: '#7f1d1d', fg: '#fca5a5' }
};
// result_data는 백엔드에서 dict 또는 string 둘 다 올 수 있다.
// React child로 직접 못 그리는 객체는 stringify, string은 parse 시도 후 pretty,
// 둘 다 안 되면 원본 문자열을 그대로 표시.
function formatResultData(rd) {
if (rd == null) return '';
if (typeof rd === 'object') {
try { return JSON.stringify(rd, null, 2); }
catch { return String(rd); }
}
if (typeof rd === 'string') {
try { return JSON.stringify(JSON.parse(rd), null, 2); }
catch { return rd; }
}
return String(rd);
}
function formatTime(ts) {
if (!ts) return '';
const d = new Date(ts);
@@ -46,10 +62,7 @@ export default function TaskTab({ agentId, refreshTrigger }) {
</div>
{expanded === task.id && task.result_data && (
<pre className="ao-task-result">
{(() => {
try { return JSON.stringify(JSON.parse(task.result_data), null, 2); }
catch { return task.result_data; }
})()}
{formatResultData(task.result_data)}
</pre>
)}
</div>

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import TaskTab from './TaskTab.jsx';
const mockGetAgentTasks = vi.fn();
@@ -33,4 +33,55 @@ describe('TaskTab response shape handling', () => {
render(<TaskTab agentId="music" refreshTrigger={0} />);
await waitFor(() => expect(screen.getByText('No tasks yet')).toBeInTheDocument());
});
it('task 클릭 → result_data가 객체일 때도 stringify되어 안전하게 렌더', async () => {
mockGetAgentTasks.mockResolvedValueOnce({
tasks: [{
id: 't_compose',
task_type: 'compose',
status: 'succeeded',
created_at: '2026-05-18T08:00:00Z',
result_data: { music_task_id: 'abc-123', tracks: [] },
}],
});
render(<TaskTab agentId="music" refreshTrigger={0} />);
const row = await screen.findByText('compose');
fireEvent.click(row.closest('.ao-task-item'));
const pre = await screen.findByText(/music_task_id/);
expect(pre.textContent).toContain('"music_task_id": "abc-123"');
expect(pre.textContent).toContain('"tracks": []');
});
it('task 클릭 → result_data가 JSON 문자열일 때 parse 후 pretty 렌더', async () => {
mockGetAgentTasks.mockResolvedValueOnce({
tasks: [{
id: 't_str',
task_type: 'compose',
status: 'succeeded',
created_at: '2026-05-18T08:00:00Z',
result_data: '{"foo":"bar"}',
}],
});
render(<TaskTab agentId="music" refreshTrigger={0} />);
const row = await screen.findByText('compose');
fireEvent.click(row.closest('.ao-task-item'));
const pre = await screen.findByText(/foo/);
expect(pre.textContent).toContain('"foo": "bar"');
});
it('task 클릭 → result_data가 plain string이면 그대로 표시 (parse 실패 fallback)', async () => {
mockGetAgentTasks.mockResolvedValueOnce({
tasks: [{
id: 't_plain',
task_type: 'fetch_news',
status: 'succeeded',
created_at: '2026-05-18T08:00:00Z',
result_data: 'Just a log line',
}],
});
render(<TaskTab agentId="music" refreshTrigger={0} />);
const row = await screen.findByText('fetch_news');
fireEvent.click(row.closest('.ao-task-item'));
expect(await screen.findByText('Just a log line')).toBeInTheDocument();
});
});