Skip to content

Background task cards disappeared from the chat after #70 #104

Closed

Description

@axisrow

Part of #87.

⚠️ Выводы не проверены человеком. Ишью заведено по аудиту, который сделал Claude Code. Перед исправлением достаточно один раз выполнить блок «Как проверить» и сравнить вывод с ожидаемым.

Что не так

Когда фоновая Bash-команда (run_in_background) завершается, Claude Code пишет user-строку <task-notification>…</task-notification> (promptSource: "system"; формат — из сессии 8b65639f, описанной в #70). Чат показывал её карточкой: значок статуса, имя команды, exit code, файл вывода. Рисует карточку только UserChatGroup.tsx, и только из user-групп.

#70 правильно перестал считать такие строки ходами — теперь они попадают в AI-группу. Но у AI-группы нет элемента отображения для них, и карточка пропала:

до #70 (d96b33a) после #70 (41a52e3)
ход лишний «Turn N» не открывается ✓
карточка фоновой задачи есть нет

Ревью #70 решило, что «display-side уже согласован», потому что contentSanitizer вырезает <task-notification>. Но sanitizer вырезает только текст, а карточка рисовалась отдельно — из исходного содержимого.

Как проверить (один раз, ~1 мин)

Из корня репозитория:

cat > /tmp/repro-tasknote.mts <<'EOF'
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const R = process.cwd(); // запускать из корня репозитория
const load = (p: string) => import(`${R}/${p}`);
let t = Date.parse('2026-10-01T07:00:00Z'), n = 0;
const at = (ms = 20_000) => new Date((t += ms)).toISOString();
const base = (x = {}) => ({ uuid: `u${++n}`, parentUuid: null, sessionId: 's', cwd: '/p', isSidechain: false, ...x });
const user = (content: unknown, x = {}, gap?: number) => ({ ...base(x), type: 'user', timestamp: at(gap), message: { role: 'user', content } });
const ai = (block: object, ctx: number, out: number, ids: { rid?: string; mid: string }, x = {}, gap?: number) => ({ ...base(x), type: 'assistant', timestamp: at(gap), ...(ids.rid ? { requestId: ids.rid } : {}), message: { id: ids.mid, role: 'assistant', model: 'claude-x', content: [block], usage: { input_tokens: 0, cache_read_input_tokens: ctx, cache_creation_input_tokens: 0, output_tokens: out } } });
const result = (id: string, gap?: number) => ({ ...base(), type: 'user', timestamp: at(gap), message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: id, content: 'ok' }] } });
const text = (s: string) => ({ type: 'text', text: s });
const tool = (id: string, name: string, input: object) => ({ type: 'tool_use', id, name, input });
const write = (lines: object[]) => { const f = join(mkdtempSync(join(tmpdir(), 'repro-')), 's.jsonl'); writeFileSync(f, lines.map((l) => JSON.stringify(l)).join('\n') + '\n'); return f; };
// фоновая Bash-команда завершилась: Claude Code пишет user-строку <task-notification> (promptSource: system)
const NOTE = '<task-notification>\n<task-id>bppnd267i</task-id>\n<status>completed</status>\n<summary>Background command "Fresh tarball download" completed (exit code 0)</summary>\n</task-notification>';
const f = write([
  user('Запусти сборку'),
  ai(tool('b1', 'Bash', { command: 'curl …', run_in_background: true }), 100_000, 50, { rid: 'q1', mid: 'm1' }), result('b1'),
  ai(text('Запустил в фоне'), 100_000, 50, { rid: 'q2', mid: 'm2' }),
  user(NOTE, { promptSource: 'system', turnOrigin: 'task_notification' }),
  ai(text('Сборка завершилась'), 100_000, 50, { rid: 'q3', mid: 'm3' }),
]);
const jsonl = await load('src/main/utils/jsonl.ts');
const { ChunkBuilder } = await load('src/main/services/analysis/ChunkBuilder.ts');
const { transformChunksToConversation } = await load('src/renderer/utils/groupTransformer.ts');
const { enhanceAIGroup } = await load('src/renderer/utils/aiGroupEnhancer.ts');
const { parseTaskNotifications } = await load('src/shared/utils/contentSanitizer.ts');
const items = transformChunksToConversation(new ChunkBuilder().buildChunks(await jsonl.parseJsonlFile(f), []), [], false).items;
const raw = (c: unknown) => (typeof c === 'string' ? c : '');
// карточку рисует только UserChatGroup (src/renderer/components/chat/UserChatGroup.tsx) — из user-групп
const cardsInUserGroups = items.filter((i: { type: string }) => i.type === 'user')
  .flatMap((i: { group: { message: { content: unknown } } }) => parseTaskNotifications(raw(i.group.message.content))).length;
const aiItems = items.filter((i: { type: string }) => i.type === 'ai')
  .flatMap((i: { group: object }) => enhanceAIGroup(i.group).displayItems.map((d: { type: string }) => d.type));
console.table({ 'user-групп': items.filter((i: { type: string }) => i.type === 'user').length, 'карточек фоновых задач (UserChatGroup)': cardsInUserGroups, 'элементы AI-групп': aiItems.join(', ') });
console.log(cardsInUserGroups === 0 ? 'ВОСПРОИЗВЕДЕНО: уведомление о фоновой задаче нигде не отрисуется (до #70 была карточка «✓ Fresh tarball download · exit 0»)' : 'OK: карточка есть');
EOF
pnpm exec tsx /tmp/repro-tasknote.mts

Правильно: уведомление видно в чате карточкой (а ход при этом не открывается). Сейчас: карточек фоновых задач: 0, у AI-групп нет элемента для уведомления, строка ВОСПРОИЗВЕДЕНО. Для сравнения: на коммите до #70 (git checkout d96b33a) тот же скрипт печатает OK: карточка есть.

Где в коде (гипотеза)

Что сделать

Показывать <task-notification> внутри AI-группы — отдельным элементом отображения или той же карточкой — не возвращая ход.

Acceptance

  • Тест на уровне элементов отображения: сессия с <task-notification> — лишнего хода нет, есть один элемент со статусом и exit code
  • Скриншот карточки в PR

  • Проверено человеком: воспроизводится / не воспроизводится

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions