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:
@@ -11,6 +11,22 @@ const STATUS_STYLE = {
|
|||||||
rejected: { bg: '#7f1d1d', fg: '#fca5a5' }
|
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) {
|
function formatTime(ts) {
|
||||||
if (!ts) return '';
|
if (!ts) return '';
|
||||||
const d = new Date(ts);
|
const d = new Date(ts);
|
||||||
@@ -46,10 +62,7 @@ export default function TaskTab({ agentId, refreshTrigger }) {
|
|||||||
</div>
|
</div>
|
||||||
{expanded === task.id && task.result_data && (
|
{expanded === task.id && task.result_data && (
|
||||||
<pre className="ao-task-result">
|
<pre className="ao-task-result">
|
||||||
{(() => {
|
{formatResultData(task.result_data)}
|
||||||
try { return JSON.stringify(JSON.parse(task.result_data), null, 2); }
|
|
||||||
catch { return task.result_data; }
|
|
||||||
})()}
|
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
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';
|
import TaskTab from './TaskTab.jsx';
|
||||||
|
|
||||||
const mockGetAgentTasks = vi.fn();
|
const mockGetAgentTasks = vi.fn();
|
||||||
@@ -33,4 +33,55 @@ describe('TaskTab response shape handling', () => {
|
|||||||
render(<TaskTab agentId="music" refreshTrigger={0} />);
|
render(<TaskTab agentId="music" refreshTrigger={0} />);
|
||||||
await waitFor(() => expect(screen.getByText('No tasks yet')).toBeInTheDocument());
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user