From 416c4acf04e5cacb38c6daa11b8ffa814e5bc4e8 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 18 Apr 2026 13:09:51 +0300 Subject: [PATCH 1/2] feat(sidebar): improve project task grouping --- .../components/sidebar/GlobalTaskList.tsx | 124 ++++++-- .../sidebar/projectGroupPagination.ts | 89 ++++++ .../components/sidebar/GlobalTaskList.test.ts | 278 ++++++++++++++++++ .../sidebar/projectGroupPagination.test.ts | 75 +++++ 4 files changed, 539 insertions(+), 27 deletions(-) create mode 100644 src/renderer/components/sidebar/projectGroupPagination.ts create mode 100644 test/renderer/components/sidebar/GlobalTaskList.test.ts create mode 100644 test/renderer/components/sidebar/projectGroupPagination.test.ts diff --git a/src/renderer/components/sidebar/GlobalTaskList.tsx b/src/renderer/components/sidebar/GlobalTaskList.tsx index 188bc53f..de860219 100644 --- a/src/renderer/components/sidebar/GlobalTaskList.tsx +++ b/src/renderer/components/sidebar/GlobalTaskList.tsx @@ -33,6 +33,14 @@ import { AnimatedHeightReveal } from '../team/activity/AnimatedHeightReveal'; import { type ComboboxOption } from '../ui/combobox'; import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; +import { + canProjectGroupShowLess, + canProjectGroupShowMore, + getNextProjectGroupVisibleCount, + getPreviousProjectGroupVisibleCount, + getProjectGroupVisibleCount, + syncProjectGroupVisibleCountByKey, +} from './projectGroupPagination'; import { SidebarTaskItem } from './SidebarTaskItem'; import { TaskContextMenu } from './TaskContextMenu'; import { TaskFiltersPopover } from './TaskFiltersPopover'; @@ -207,6 +215,9 @@ export const GlobalTaskList = ({ const [sortPopoverOpen, setSortPopoverOpen] = useState(false); const [showArchived, setShowArchived] = useState(false); const [renamingTaskKey, setRenamingTaskKey] = useState(null); + const [projectRequestedVisibleCountByKey, setProjectRequestedVisibleCountByKey] = useState< + Record + >({}); const searchInputRef = useRef(null); const hasFetchedRef = useRef(false); const readState = useReadStateSnapshot(); @@ -221,21 +232,23 @@ export const GlobalTaskList = ({ return new Set(); } - // First load: seed all known IDs, no animations + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. if (isInitialTaskLoadRef.current) { isInitialTaskLoadRef.current = false; for (const t of globalTasks) { + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. knownTaskIdsRef.current.add(`${t.teamName}:${t.id}`); } return new Set(); } - // Subsequent updates: detect truly new tasks const newIds = new Set(); for (const t of globalTasks) { const key = `${t.teamName}:${t.id}`; + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. if (!knownTaskIdsRef.current.has(key)) { newIds.add(key); + // eslint-disable-next-line react-hooks/refs -- Synchronous diff is required so new rows mount with animate=true. knownTaskIdsRef.current.add(key); } } @@ -326,6 +339,11 @@ export const GlobalTaskList = ({ // Resolve project filter from filters state const selectedProjectPath = filters.projectPath; + const hasArchivedTasks = useMemo( + () => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)), + [globalTasks, taskLocalState] + ); + const effectiveShowArchived = showArchived && hasArchivedTasks; const filtered = useMemo(() => { let result = globalTasks; @@ -345,7 +363,7 @@ export const GlobalTaskList = ({ } result = applySearch(result, searchQuery); // Archive filtering - if (showArchived) { + if (effectiveShowArchived) { result = result.filter((t) => taskLocalState.isArchived(t.teamName, t.id)); } else { result = result.filter((t) => !taskLocalState.isArchived(t.teamName, t.id)); @@ -353,29 +371,16 @@ export const GlobalTaskList = ({ return result; }, [ globalTasks, - filters.projectPath, + selectedProjectPath, filters.statusIds, filters.teamName, filters.readFilter, searchQuery, readState, - showArchived, + effectiveShowArchived, taskLocalState, ]); - // Check if any archived tasks exist (before archive filtering) to conditionally show the toggle - const hasArchivedTasks = useMemo( - () => globalTasks.some((t) => taskLocalState.isArchived(t.teamName, t.id)), - [globalTasks, taskLocalState] - ); - - // Reset showArchived when archive becomes empty - useEffect(() => { - if (showArchived && !hasArchivedTasks) { - setShowArchived(false); - } - }, [showArchived, hasArchivedTasks]); - // Split into pinned and normal (non-pinned) tasks const pinnedTasks = useMemo( () => filtered.filter((t) => taskLocalState.isPinned(t.teamName, t.id)), @@ -400,6 +405,19 @@ export const GlobalTaskList = ({ [projectGroups] ); const timeGroupKeys = useMemo(() => categories.map((c) => c), [categories]); + const projectGroupVisibility = useMemo( + () => + projectGroups.map((group) => ({ + projectKey: group.projectKey, + taskCount: group.tasks.length, + })), + [projectGroups] + ); + const projectVisibleCountByKey = useMemo( + () => + syncProjectGroupVisibleCountByKey(projectRequestedVisibleCountByKey, projectGroupVisibility), + [projectRequestedVisibleCountByKey, projectGroupVisibility] + ); const projectCollapsed = useCollapsedGroups('project', projectGroupKeys); const timeCollapsed = useCollapsedGroups('time', timeGroupKeys); @@ -499,7 +517,7 @@ export const GlobalTaskList = ({ {/* Pinned tasks section */} - {pinnedTasks.length > 0 && !showArchived && ( + {pinnedTasks.length > 0 && !effectiveShowArchived && (
@@ -562,7 +580,7 @@ export const GlobalTaskList = ({ onClick={() => setShowArchived(!showArchived)} className={cn( 'rounded p-0.5 transition-colors', - showArchived + effectiveShowArchived ? 'bg-surface-raised text-text-secondary' : 'text-text-muted hover:text-text-secondary' )} @@ -571,7 +589,7 @@ export const GlobalTaskList = ({ - {showArchived ? 'Hide archived' : 'Show archived'} + {effectiveShowArchived ? 'Hide archived' : 'Show archived'}
@@ -627,14 +645,25 @@ export const GlobalTaskList = ({ if (group.tasks.length === 0) return null; const isGroupCollapsed = projectCollapsed.isCollapsed(group.projectKey); const groupColor = projectColor(group.projectLabel); + const visibleCount = getProjectGroupVisibleCount( + projectVisibleCountByKey[group.projectKey], + group.tasks.length + ); + const visibleTasks = group.tasks.slice(0, visibleCount); + const showMoreVisible = canProjectGroupShowMore(visibleCount, group.tasks.length); + const showLessVisible = canProjectGroupShowLess(visibleCount, group.tasks.length); let lastTeam: string | null = null; return (
{!isGroupCollapsed && - group.tasks.map((task) => { + visibleTasks.map((task) => { const showTeamHeader = task.teamName !== lastTeam; lastTeam = task.teamName; return ( @@ -691,6 +723,44 @@ export const GlobalTaskList = ({
); })} + {!isGroupCollapsed && (showMoreVisible || showLessVisible) && ( +
+ {showMoreVisible && ( + + )} + {showLessVisible && ( + + )} +
+ )}
); })} diff --git a/src/renderer/components/sidebar/projectGroupPagination.ts b/src/renderer/components/sidebar/projectGroupPagination.ts new file mode 100644 index 00000000..58d32732 --- /dev/null +++ b/src/renderer/components/sidebar/projectGroupPagination.ts @@ -0,0 +1,89 @@ +export const PROJECT_GROUP_PAGE_SIZE = 5; + +export interface ProjectGroupVisibilityDescriptor { + projectKey: string; + taskCount: number; +} + +export function getProjectGroupVisibleCount( + visibleCount: number | undefined, + taskCount: number +): number { + if (taskCount <= 0) { + return 0; + } + + const minimumVisibleCount = Math.min(PROJECT_GROUP_PAGE_SIZE, taskCount); + if (visibleCount == null || !Number.isFinite(visibleCount)) { + return minimumVisibleCount; + } + + const normalizedVisibleCount = Math.floor(visibleCount); + return Math.min(taskCount, Math.max(minimumVisibleCount, normalizedVisibleCount)); +} + +export function getNextProjectGroupVisibleCount( + visibleCount: number | undefined, + taskCount: number +): number { + const currentVisibleCount = getProjectGroupVisibleCount(visibleCount, taskCount); + if (currentVisibleCount >= taskCount) { + return currentVisibleCount; + } + return Math.min(taskCount, currentVisibleCount + PROJECT_GROUP_PAGE_SIZE); +} + +export function getPreviousProjectGroupVisibleCount( + visibleCount: number | undefined, + taskCount: number +): number { + const currentVisibleCount = getProjectGroupVisibleCount(visibleCount, taskCount); + const minimumVisibleCount = Math.min(PROJECT_GROUP_PAGE_SIZE, taskCount); + return Math.max(minimumVisibleCount, currentVisibleCount - PROJECT_GROUP_PAGE_SIZE); +} + +export function canProjectGroupShowMore( + visibleCount: number | undefined, + taskCount: number +): boolean { + return getProjectGroupVisibleCount(visibleCount, taskCount) < taskCount; +} + +export function canProjectGroupShowLess( + visibleCount: number | undefined, + taskCount: number +): boolean { + if (taskCount <= PROJECT_GROUP_PAGE_SIZE) { + return false; + } + return getProjectGroupVisibleCount(visibleCount, taskCount) > PROJECT_GROUP_PAGE_SIZE; +} + +export function syncProjectGroupVisibleCountByKey( + previousVisibleCountByKey: Record, + groups: readonly ProjectGroupVisibilityDescriptor[] +): Record { + let changed = false; + const nextVisibleCountByKey: Record = {}; + + for (const group of groups) { + const nextVisibleCount = getProjectGroupVisibleCount( + previousVisibleCountByKey[group.projectKey], + group.taskCount + ); + + if (nextVisibleCount > 0) { + nextVisibleCountByKey[group.projectKey] = nextVisibleCount; + } + + if (previousVisibleCountByKey[group.projectKey] !== nextVisibleCount) { + changed = true; + } + } + + if (Object.keys(previousVisibleCountByKey).length !== Object.keys(nextVisibleCountByKey).length) { + changed = true; + } + + return changed ? nextVisibleCountByKey : previousVisibleCountByKey; +} diff --git a/test/renderer/components/sidebar/GlobalTaskList.test.ts b/test/renderer/components/sidebar/GlobalTaskList.test.ts new file mode 100644 index 00000000..3dcacade --- /dev/null +++ b/test/renderer/components/sidebar/GlobalTaskList.test.ts @@ -0,0 +1,278 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { GlobalTask } from '../../../../src/shared/types'; + +interface StoreState { + globalTasks: GlobalTask[]; + globalTasksLoading: boolean; + globalTasksInitialized: boolean; + fetchAllTasks: ReturnType; + softDeleteTask: ReturnType; + projects: { path: string; name: string; sessions: unknown[]; totalSessions?: number }[]; + viewMode: 'flat' | 'grouped'; + repositoryGroups: { + id: string; + name: string; + totalSessions: number; + worktrees: { path: string }[]; + }[]; + teams: { teamName: string; displayName: string }[]; +} + +const storeState = {} as StoreState; +const toggleCollapsedGroup = vi.fn(); +const taskLocalState = { + isPinned: vi.fn(() => false), + isArchived: vi.fn(() => false), + getRenamedSubject: vi.fn(() => undefined), + togglePin: vi.fn(), + toggleArchive: vi.fn(), + renameTask: vi.fn(), +}; + +vi.mock('../../../../src/renderer/store', () => ({ + useStore: (selector: (state: StoreState) => unknown) => selector(storeState), +})); + +vi.mock('zustand/react/shallow', () => ({ + useShallow: (selector: T) => selector, +})); + +vi.mock('../../../../src/renderer/components/common/ConfirmDialog', () => ({ + confirm: vi.fn(() => Promise.resolve(true)), +})); + +vi.mock('../../../../src/renderer/hooks/useCollapsedGroups', () => ({ + useCollapsedGroups: () => ({ + isCollapsed: () => false, + toggle: toggleCollapsedGroup, + }), +})); + +vi.mock('../../../../src/renderer/hooks/useTaskLocalState', () => ({ + useTaskLocalState: () => taskLocalState, +})); + +vi.mock('../../../../src/renderer/components/team/activity/AnimatedHeightReveal', () => ({ + AnimatedHeightReveal: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), +})); + +vi.mock('../../../../src/renderer/components/sidebar/TaskContextMenu', () => ({ + TaskContextMenu: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), +})); + +vi.mock('../../../../src/renderer/components/sidebar/SidebarTaskItem', () => ({ + SidebarTaskItem: ({ task }: { task: GlobalTask }) => + React.createElement('div', { 'data-testid': 'sidebar-task-item' }, task.subject), +})); + +vi.mock('../../../../src/renderer/components/sidebar/TaskFiltersPopover', () => ({ + TaskFiltersPopover: () => null, +})); + +vi.mock('../../../../src/renderer/components/ui/popover', () => ({ + Popover: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), + PopoverTrigger: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + PopoverContent: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), +})); + +vi.mock('../../../../src/renderer/components/ui/tooltip', () => ({ + Tooltip: ({ children }: React.PropsWithChildren) => React.createElement(React.Fragment, null, children), + TooltipTrigger: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), + TooltipContent: ({ children }: React.PropsWithChildren) => + React.createElement(React.Fragment, null, children), +})); + +vi.mock('lucide-react', () => { + const Icon = (props: React.SVGProps) => React.createElement('svg', props); + return { + Archive: Icon, + ArrowUpDown: Icon, + Check: Icon, + ChevronDown: Icon, + ChevronRight: Icon, + Folder: Icon, + ListTodo: Icon, + Pin: Icon, + Search: Icon, + X: Icon, + }; +}); + +import { GlobalTaskList } from '../../../../src/renderer/components/sidebar/GlobalTaskList'; + +function flushMicrotasks(): Promise { + return Promise.resolve(); +} + +function findButton(host: HTMLElement, label: string): HTMLButtonElement | null { + return Array.from(host.querySelectorAll('button')).find( + (button) => button.textContent?.trim() === label + ) ?? null; +} + +function visibleSubjects(host: HTMLElement): string[] { + return Array.from(host.querySelectorAll('[data-testid="sidebar-task-item"]')).map( + (node) => node.textContent ?? '' + ); +} + +function makeTask(index: number, overrides: Partial = {}): GlobalTask { + const timestamp = String(60 - index).padStart(2, '0'); + return { + id: `task-${index}`, + displayId: `task${index}`, + teamName: 'alpha-team', + teamDisplayName: 'Alpha Team', + subject: `Task ${index}`, + description: '', + status: 'in_progress', + owner: 'alice', + createdAt: `2026-04-18T10:${timestamp}:00.000Z`, + updatedAt: `2026-04-18T10:${timestamp}:00.000Z`, + reviewState: 'none', + reviewNotes: [], + blockedBy: [], + blocks: [], + comments: [], + attachments: [], + workIntervals: [], + kanbanColumnId: null, + projectPath: '/workspace/hookplex', + ...overrides, + } as GlobalTask; +} + +describe('GlobalTaskList project grouping', () => { + beforeEach(() => { + storeState.globalTasks = []; + storeState.globalTasksLoading = false; + storeState.globalTasksInitialized = true; + storeState.fetchAllTasks = vi.fn(() => Promise.resolve(undefined)); + storeState.softDeleteTask = vi.fn(() => Promise.resolve(undefined)); + storeState.projects = []; + storeState.viewMode = 'flat'; + storeState.repositoryGroups = []; + storeState.teams = [{ teamName: 'alpha-team', displayName: 'Alpha Team' }]; + toggleCollapsedGroup.mockReset(); + taskLocalState.isPinned.mockClear(); + taskLocalState.isArchived.mockClear(); + taskLocalState.getRenamedSubject.mockClear(); + taskLocalState.togglePin.mockClear(); + taskLocalState.toggleArchive.mockClear(); + taskLocalState.renameTask.mockClear(); + localStorage.clear(); + localStorage.setItem('sidebarTasksGrouping', 'project'); + }); + + afterEach(() => { + document.body.innerHTML = ''; + vi.unstubAllGlobals(); + }); + + it('shows five tasks first, then expands and collapses with Show more and Show less', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.globalTasks = Array.from({ length: 6 }, (_, index) => makeTask(index + 1)); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(GlobalTaskList)); + await flushMicrotasks(); + }); + + expect(visibleSubjects(host)).toEqual(['Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5']); + expect(findButton(host, 'Show more')).not.toBeNull(); + expect(findButton(host, 'Show less')).toBeNull(); + + await act(async () => { + findButton(host, 'Show more')?.click(); + await flushMicrotasks(); + }); + + expect(visibleSubjects(host)).toEqual(['Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5', 'Task 6']); + expect(findButton(host, 'Show less')).not.toBeNull(); + + await act(async () => { + findButton(host, 'Show less')?.click(); + await flushMicrotasks(); + }); + + expect(visibleSubjects(host)).toEqual(['Task 1', 'Task 2', 'Task 3', 'Task 4', 'Task 5']); + expect(findButton(host, 'Show less')).toBeNull(); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('keeps the hard visible limit when new tasks arrive after expansion', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + storeState.globalTasks = Array.from({ length: 10 }, (_, index) => makeTask(index + 1)); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(GlobalTaskList)); + await flushMicrotasks(); + }); + + await act(async () => { + findButton(host, 'Show more')?.click(); + await flushMicrotasks(); + }); + + expect(visibleSubjects(host)).toHaveLength(10); + expect(findButton(host, 'Show less')).not.toBeNull(); + + storeState.globalTasks = [ + makeTask(0, { + id: 'task-new', + displayId: 'task-new', + subject: 'Task 0', + createdAt: '2026-04-18T11:00:00.000Z', + updatedAt: '2026-04-18T11:00:00.000Z', + }), + ...Array.from({ length: 10 }, (_, index) => makeTask(index + 1)), + ]; + + await act(async () => { + root.render(React.createElement(GlobalTaskList)); + await flushMicrotasks(); + }); + + expect(visibleSubjects(host)).toHaveLength(10); + expect(visibleSubjects(host)).toEqual([ + 'Task 0', + 'Task 1', + 'Task 2', + 'Task 3', + 'Task 4', + 'Task 5', + 'Task 6', + 'Task 7', + 'Task 8', + 'Task 9', + ]); + expect(visibleSubjects(host)).not.toContain('Task 10'); + expect(findButton(host, 'Show more')).not.toBeNull(); + expect(findButton(host, 'Show less')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); +}); diff --git a/test/renderer/components/sidebar/projectGroupPagination.test.ts b/test/renderer/components/sidebar/projectGroupPagination.test.ts new file mode 100644 index 00000000..b9651f04 --- /dev/null +++ b/test/renderer/components/sidebar/projectGroupPagination.test.ts @@ -0,0 +1,75 @@ +import { + PROJECT_GROUP_PAGE_SIZE, + canProjectGroupShowLess, + canProjectGroupShowMore, + getNextProjectGroupVisibleCount, + getPreviousProjectGroupVisibleCount, + getProjectGroupVisibleCount, + syncProjectGroupVisibleCountByKey, +} from '../../../../src/renderer/components/sidebar/projectGroupPagination'; +import { describe, expect, it } from 'vitest'; + +describe('projectGroupPagination', () => { + it('defaults to the first page and respects small groups', () => { + expect(getProjectGroupVisibleCount(undefined, 0)).toBe(0); + expect(getProjectGroupVisibleCount(undefined, 3)).toBe(3); + expect(getProjectGroupVisibleCount(undefined, 12)).toBe(PROJECT_GROUP_PAGE_SIZE); + }); + + it('expands in steps of five and clamps to the group size', () => { + let visibleCount = getProjectGroupVisibleCount(undefined, 17); + expect(visibleCount).toBe(5); + + visibleCount = getNextProjectGroupVisibleCount(visibleCount, 17); + expect(visibleCount).toBe(10); + + visibleCount = getNextProjectGroupVisibleCount(visibleCount, 17); + expect(visibleCount).toBe(15); + + visibleCount = getNextProjectGroupVisibleCount(visibleCount, 17); + expect(visibleCount).toBe(17); + + expect(canProjectGroupShowMore(visibleCount, 17)).toBe(false); + }); + + it('collapses in steps of five and never goes below the first page', () => { + expect(getPreviousProjectGroupVisibleCount(15, 17)).toBe(10); + expect(getPreviousProjectGroupVisibleCount(10, 17)).toBe(5); + expect(getPreviousProjectGroupVisibleCount(5, 17)).toBe(5); + + expect(canProjectGroupShowLess(5, 17)).toBe(false); + expect(canProjectGroupShowLess(10, 17)).toBe(true); + }); + + it('clamps existing counts when the group shrinks and removes missing groups', () => { + const previousVisibleCounts = { + active: 15, + compact: 7, + removed: 10, + }; + + expect( + syncProjectGroupVisibleCountByKey(previousVisibleCounts, [ + { projectKey: 'active', taskCount: 9 }, + { projectKey: 'compact', taskCount: 4 }, + ]) + ).toEqual({ + active: 9, + compact: 4, + }); + }); + + it('returns the same object when nothing changes', () => { + const previousVisibleCounts = { + active: 10, + compact: 4, + }; + + const nextVisibleCounts = syncProjectGroupVisibleCountByKey(previousVisibleCounts, [ + { projectKey: 'active', taskCount: 12 }, + { projectKey: 'compact', taskCount: 4 }, + ]); + + expect(nextVisibleCounts).toBe(previousVisibleCounts); + }); +}); From 82566162fc301a8bfdb1713e597dafd1df7e3782 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 18 Apr 2026 13:11:13 +0300 Subject: [PATCH 2/2] fix(team): restore task log fallback and block launch auto-assignment --- .../services/team/TeamProvisioningService.ts | 24 +- .../stream/BoardTaskLogStreamService.ts | 338 +++++++++++++++++- src/main/types/jsonl.ts | 1 + src/main/types/messages.ts | 4 + src/main/utils/jsonl.ts | 6 + .../team/task-log-stream-fallback-real.jsonl | 10 + .../BoardTaskLogStreamIntegration.test.ts | 268 +++++++++++++- .../TeamProvisioningServicePrompts.test.ts | 8 +- test/main/utils/jsonl.test.ts | 43 +++ 9 files changed, 693 insertions(+), 9 deletions(-) create mode 100644 test/fixtures/team/task-log-stream-fallback-real.jsonl diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 7204faa5..2721ddc1 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1906,6 +1906,7 @@ function buildDeterministicLaunchHydrationPrompt( const userPromptBlock = request.prompt?.trim() ? `\nOriginal user instructions to apply after reconnect is stable:\n${request.prompt.trim()}\n` : ''; + const hasOriginalUserPrompt = Boolean(request.prompt?.trim()); const taskBoardSnapshot = buildTaskBoardSnapshot(tasks); const persistentContext = buildPersistentLeadContext({ teamName: request.teamName, @@ -1919,13 +1920,21 @@ Do NOT call TeamCreate. Do NOT use Agent to spawn or restore teammates. Do NOT start implementation in this turn. Use this turn only to refresh context, review the current board snapshot, and confirm you are ready. -If the user instructions imply new substantial work that is not on the board yet, you MAY create or update board tasks for yourself, but do not begin executing them yet.` +${ + hasOriginalUserPrompt + ? 'Do NOT create or update any new task in this turn - wait for the next normal operating turn before translating those instructions into board work.' + : 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' +}` : `This reconnect/bootstrap step has already been completed deterministically by the runtime. Do NOT call TeamCreate. Do NOT use Agent to spawn or restore teammates. Do NOT repeat the launch summary. -Use this turn only to refresh context, review the current board snapshot, and prepare the next delegation step. -If the user instructions imply new substantial work that is not on the board yet, you MAY create or update team-board tasks and assign owners now, but do NOT start implementation work in this turn. +Use this turn only to refresh context and review the current board snapshot. +${ + hasOriginalUserPrompt + ? 'Do NOT create or assign any new task in this turn - wait for the next normal operating turn before translating those instructions into board work.' + : 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' +} Treat teammates whose bootstrap is still pending as not-yet-available for blocking assignments.`; return `${startLabel} [Deterministic reconnect | Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"] @@ -1952,6 +1961,7 @@ function buildGeminiPostLaunchHydrationPrompt( const userPromptBlock = run.request.prompt?.trim() ? `\nOriginal user instructions to apply now:\n${run.request.prompt.trim()}\n` : ''; + const hasOriginalUserPrompt = Boolean(run.request.prompt?.trim()); const taskBoardSnapshot = buildTaskBoardSnapshot(tasks); const teammateBootstrapSnapshot = members.length ? `Current teammate launch status:\n${members @@ -1980,8 +1990,12 @@ function buildGeminiPostLaunchHydrationPrompt( members, }); const nextStepInstruction = isSolo - ? 'From this point on, use the full operating rules below for all future turns. If the original user instructions describe substantial work that should be tracked, you MAY now create board tasks for yourself, but do not start implementation in this context-refresh turn.' - : 'From this point on, use the full team operating rules below for all future turns. If the original user instructions describe substantial work that should be tracked, you MAY now translate them into board tasks and prepare delegation, but do not start implementation work in this context-refresh turn. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.'; + ? hasOriginalUserPrompt + ? 'From this point on, use the full operating rules below for all future turns. Do NOT create or update any new task in this context-refresh turn - wait for the next normal operating turn before translating those instructions into board work.' + : 'From this point on, use the full operating rules below for all future turns. Do NOT create, assign, or delegate any new task in this context-refresh turn. If the board is empty, stay silent and wait for a fresh user instruction.' + : hasOriginalUserPrompt + ? 'From this point on, use the full team operating rules below for all future turns. Do NOT create or assign any new task in this context-refresh turn - wait for the next normal operating turn before translating those instructions into board work. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.' + : 'From this point on, use the full team operating rules below for all future turns. Do NOT create, assign, or delegate any new task in this context-refresh turn. If the board is empty, stay silent and wait for a fresh user instruction. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.'; return `Gemini launch phase 2 — operating context for team "${run.teamName}". diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index 2a47c4fb..15157c8f 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -1,6 +1,9 @@ import { extractToolCalls, extractToolResults } from '@main/utils/toolExtraction'; +import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection'; +import { TeamTaskReader } from '../../TeamTaskReader'; import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource'; +import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator'; import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder'; import { BoardTaskExactLogDetailSelector } from '../exact/BoardTaskExactLogDetailSelector'; import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser'; @@ -16,12 +19,14 @@ import type { BoardTaskLogParticipant, BoardTaskLogSegment, BoardTaskLogStreamResponse, + TeamTask, } from '@shared/types'; interface StreamSlice { id: string; timestamp: string; filePath: string; + sortOrder?: number; participantKey: string; actor: BoardTaskLogActor; actionCategory?: BoardTaskActivityCategory; @@ -37,6 +42,17 @@ interface MergedMessageAccumulator { toolUseResults: ToolUseResultData[]; } +interface TimeWindow { + startMs: number; + endMs: number | null; +} + +const BOARD_MCP_TOOL_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const; +const INFERRED_WINDOW_GRACE_BEFORE_MS = 30_000; +const INFERRED_WINDOW_GRACE_AFTER_MS = 15_000; +const INFERRED_RECORD_RANGE_BEFORE_MS = 5 * 60_000; +const INFERRED_RECORD_RANGE_AFTER_MS = 60_000; + function emptyResponse(): BoardTaskLogStreamResponse { return { participants: [], @@ -49,6 +65,12 @@ function normalizeMemberName(value: string): string { return value.trim().toLowerCase(); } +function isBoardMcpToolName(toolName: string | undefined): boolean { + if (!toolName) return false; + const normalized = toolName.trim().toLowerCase(); + return BOARD_MCP_TOOL_PREFIXES.some((prefix) => normalized.startsWith(prefix)); +} + function toStreamActor(detail: BoardTaskExactLogDetailCandidate['actor']): BoardTaskLogActor { return { ...(detail.memberName ? { memberName: detail.memberName } : {}), @@ -691,15 +713,319 @@ function buildSegmentId(participantKey: string, slices: StreamSlice[]): string { return `${participantKey}:${first?.id ?? 'start'}:${last?.id ?? 'end'}`; } +function buildToolNameByUseId( + parsedMessagesByFile: Map +): Map { + const toolNameByUseId = new Map(); + + for (const messages of parsedMessagesByFile.values()) { + for (const message of messages) { + for (const toolCall of message.toolCalls) { + toolNameByUseId.set(toolCall.id, toolCall.name); + } + } + } + + return toolNameByUseId; +} + +function buildTaskTimeWindows(task: TeamTask, recordTimestamps: number[]): TimeWindow[] { + const windowsFromIntervals = (Array.isArray(task.workIntervals) ? task.workIntervals : []) + .map((interval) => { + const startedAt = Date.parse(interval.startedAt); + if (!Number.isFinite(startedAt)) { + return null; + } + const completedAt = + typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN; + return { + startMs: startedAt - INFERRED_WINDOW_GRACE_BEFORE_MS, + endMs: Number.isFinite(completedAt) ? completedAt + INFERRED_WINDOW_GRACE_AFTER_MS : null, + }; + }) + .filter((window): window is TimeWindow => window !== null); + + if (windowsFromIntervals.length > 0) { + return windowsFromIntervals; + } + + const createdAtMs = typeof task.createdAt === 'string' ? Date.parse(task.createdAt) : Number.NaN; + const updatedAtMs = typeof task.updatedAt === 'string' ? Date.parse(task.updatedAt) : Number.NaN; + if (Number.isFinite(createdAtMs) || Number.isFinite(updatedAtMs)) { + const startMs = Number.isFinite(createdAtMs) ? createdAtMs : updatedAtMs; + return [ + { + startMs: startMs - INFERRED_WINDOW_GRACE_BEFORE_MS, + endMs: Number.isFinite(updatedAtMs) ? updatedAtMs + INFERRED_WINDOW_GRACE_AFTER_MS : null, + }, + ]; + } + + const finiteRecordTimestamps = recordTimestamps.filter((timestamp) => Number.isFinite(timestamp)); + if (finiteRecordTimestamps.length === 0) { + return []; + } + + return [ + { + startMs: Math.min(...finiteRecordTimestamps) - INFERRED_RECORD_RANGE_BEFORE_MS, + endMs: Math.max(...finiteRecordTimestamps) + INFERRED_RECORD_RANGE_AFTER_MS, + }, + ]; +} + +function isWithinTimeWindows(timestamp: Date, windows: TimeWindow[]): boolean { + const messageTime = timestamp.getTime(); + if (!Number.isFinite(messageTime)) { + return false; + } + if (windows.length === 0) { + return true; + } + + const now = Date.now(); + return windows.some((window) => { + const endMs = window.endMs ?? now; + return messageTime >= window.startMs && messageTime <= endMs; + }); +} + +function collectExplicitMessageIds(records: { source: { messageUuid: string } }[]): Set { + return new Set(records.map((record) => record.source.messageUuid)); +} + +function collectExplicitToolUseIds( + records: { + source: { toolUseId?: string }; + action?: { toolUseId?: string }; + }[] +): Set { + const toolUseIds = new Set(); + + for (const record of records) { + const sourceToolUseId = record.source.toolUseId?.trim(); + if (sourceToolUseId) { + toolUseIds.add(sourceToolUseId); + } + + const actionToolUseId = record.action?.toolUseId?.trim(); + if (actionToolUseId) { + toolUseIds.add(actionToolUseId); + } + } + + return toolUseIds; +} + +function collectAllowedMemberNames( + task: TeamTask, + records: { actor: { memberName?: string } }[] +): Set { + const allowedNames = new Set(); + + if (typeof task.owner === 'string' && task.owner.trim().length > 0) { + allowedNames.add(normalizeMemberName(task.owner)); + } + + for (const record of records) { + if (typeof record.actor.memberName === 'string' && record.actor.memberName.trim().length > 0) { + allowedNames.add(normalizeMemberName(record.actor.memberName)); + } + } + + return allowedNames; +} + +function extractMessageToolUseIds(message: ParsedMessage): Set { + const toolUseIds = new Set(); + + for (const toolCall of message.toolCalls) { + if (typeof toolCall.id === 'string' && toolCall.id.trim().length > 0) { + toolUseIds.add(toolCall.id.trim()); + } + } + + for (const toolResult of message.toolResults) { + if (typeof toolResult.toolUseId === 'string' && toolResult.toolUseId.trim().length > 0) { + toolUseIds.add(toolResult.toolUseId.trim()); + } + } + + if (typeof message.sourceToolUseID === 'string' && message.sourceToolUseID.trim().length > 0) { + toolUseIds.add(message.sourceToolUseID.trim()); + } + + return toolUseIds; +} + +function messageHasNonBoardToolActivity( + message: ParsedMessage, + toolNameByUseId: Map +): boolean { + for (const toolCall of message.toolCalls) { + if (!isBoardMcpToolName(toolCall.name)) { + return true; + } + } + + for (const toolResult of message.toolResults) { + if (!isBoardMcpToolName(toolNameByUseId.get(toolResult.toolUseId))) { + return true; + } + } + + if (message.sourceToolUseID) { + const sourceToolName = toolNameByUseId.get(message.sourceToolUseID); + if (sourceToolName && !isBoardMcpToolName(sourceToolName)) { + return true; + } + } + + return false; +} + +function buildInferredActor(message: ParsedMessage, leadName: string): BoardTaskLogActor | null { + const sessionId = message.sessionId?.trim(); + if (!sessionId) { + return null; + } + + const memberName = + typeof message.agentName === 'string' && message.agentName.trim().length > 0 + ? message.agentName.trim() + : undefined; + + const isLead = + memberName != null && normalizeMemberName(memberName) === normalizeMemberName(leadName); + + return { + ...(memberName ? { memberName } : {}), + role: isLead ? 'lead' : memberName ? 'member' : message.isSidechain ? 'member' : 'unknown', + sessionId, + ...(message.agentId ? { agentId: message.agentId } : {}), + isSidechain: message.isSidechain, + }; +} + +function compareSlices(left: StreamSlice, right: StreamSlice): number { + const leftTs = Date.parse(left.timestamp); + const rightTs = Date.parse(right.timestamp); + if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) { + return leftTs - rightTs; + } + if (left.filePath !== right.filePath) { + return left.filePath.localeCompare(right.filePath); + } + if ((left.sortOrder ?? 0) !== (right.sortOrder ?? 0)) { + return (left.sortOrder ?? 0) - (right.sortOrder ?? 0); + } + return left.id.localeCompare(right.id); +} + export class BoardTaskLogStreamService { constructor( private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), private readonly summarySelector: BoardTaskExactLogSummarySelector = new BoardTaskExactLogSummarySelector(), private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(), private readonly detailSelector: BoardTaskExactLogDetailSelector = new BoardTaskExactLogDetailSelector(), - private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder() + private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(), + private readonly taskReader: TeamTaskReader = new TeamTaskReader(), + private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator() ) {} + private async buildInferredExecutionSlices( + teamName: string, + taskId: string, + records: Awaited>, + parsedMessagesByFile: Map + ): Promise { + if (records.some((record) => record.linkKind === 'execution')) { + return []; + } + + const [activeTasks, deletedTasks, transcriptContext] = await Promise.all([ + this.taskReader.getTasks(teamName), + this.taskReader.getDeletedTasks(teamName), + this.transcriptSourceLocator.getContext(teamName), + ]); + + const task = [...activeTasks, ...deletedTasks].find((candidate) => candidate.id === taskId); + if (!task) { + return []; + } + + const transcriptFiles = transcriptContext?.transcriptFiles ?? []; + const missingFiles = transcriptFiles.filter((filePath) => !parsedMessagesByFile.has(filePath)); + let mergedParsedMessagesByFile = parsedMessagesByFile; + if (missingFiles.length > 0) { + const additionalParsedMessages = await this.strictParser.parseFiles(missingFiles); + mergedParsedMessagesByFile = new Map([ + ...parsedMessagesByFile.entries(), + ...additionalParsedMessages.entries(), + ]); + } + + const toolNameByUseId = buildToolNameByUseId(mergedParsedMessagesByFile); + const recordTimestamps = records.map((record) => Date.parse(record.timestamp)); + const taskTimeWindows = buildTaskTimeWindows(task, recordTimestamps); + if (taskTimeWindows.length === 0) { + return []; + } + + const explicitMessageIds = collectExplicitMessageIds(records); + const explicitToolUseIds = collectExplicitToolUseIds(records); + const allowedMemberNames = collectAllowedMemberNames(task, records); + const leadName = + transcriptContext?.config.members + ?.find((member) => isLeadMemberCheck(member)) + ?.name?.trim() || 'team-lead'; + + const inferredSlices: StreamSlice[] = []; + for (const [filePath, messages] of mergedParsedMessagesByFile.entries()) { + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + if (explicitMessageIds.has(message.uuid)) { + continue; + } + if (!isWithinTimeWindows(message.timestamp, taskTimeWindows)) { + continue; + } + + const actor = buildInferredActor(message, leadName); + if (!actor || !actor.memberName) { + continue; + } + + if ( + allowedMemberNames.size > 0 && + !allowedMemberNames.has(normalizeMemberName(actor.memberName)) + ) { + continue; + } + + const messageToolUseIds = extractMessageToolUseIds(message); + if ([...messageToolUseIds].some((toolUseId) => explicitToolUseIds.has(toolUseId))) { + continue; + } + if (!messageHasNonBoardToolActivity(message, toolNameByUseId)) { + continue; + } + + inferredSlices.push({ + id: `inferred:${filePath}:${message.uuid}`, + timestamp: message.timestamp.toISOString(), + filePath, + sortOrder: index, + participantKey: buildParticipantKey(actor), + actor, + filteredMessages: [message], + }); + } + } + + return inferredSlices.sort(compareSlices); + } + async getTaskLogStream(teamName: string, taskId: string): Promise { if (!isBoardTaskExactLogsReadEnabled()) { return emptyResponse(); @@ -762,6 +1088,7 @@ export class BoardTaskLogStreamService { id: detail.id, timestamp: detail.timestamp, filePath: detail.source.filePath, + sortOrder: detail.source.sourceOrder, participantKey: buildParticipantKey(actor), actor, actionCategory: candidate.actionCategory, @@ -773,7 +1100,14 @@ export class BoardTaskLogStreamService { return emptyResponse(); } - const deNoisedSlices = filterReadOnlySlices(slices); + const inferredExecutionSlices = await this.buildInferredExecutionSlices( + teamName, + taskId, + records, + parsedMessagesByFile + ); + const combinedSlices = [...slices, ...inferredExecutionSlices].sort(compareSlices); + const deNoisedSlices = filterReadOnlySlices(combinedSlices); const namedParticipantSlices = deNoisedSlices.filter((slice) => hasNamedParticipant(slice.actor) diff --git a/src/main/types/jsonl.ts b/src/main/types/jsonl.ts index 6435a707..dc8fb673 100644 --- a/src/main/types/jsonl.ts +++ b/src/main/types/jsonl.ts @@ -130,6 +130,7 @@ interface ConversationalEntry extends BaseEntry { sessionId: string; version: string; gitBranch: string; + agentName?: string; slug?: string; } diff --git a/src/main/types/messages.ts b/src/main/types/messages.ts index 1b496d9c..12d87a2d 100644 --- a/src/main/types/messages.ts +++ b/src/main/types/messages.ts @@ -80,10 +80,14 @@ export interface ParsedMessage { // Metadata /** Current working directory when message was created */ cwd?: string; + /** Root/session identifier from transcript */ + sessionId?: string; /** Git branch context */ gitBranch?: string; /** Agent ID for subagent messages */ agentId?: string; + /** Human-readable agent/member name from transcript */ + agentName?: string; /** Whether this is a sidechain message */ isSidechain: boolean; /** Whether this is a meta message */ diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 3a345d5d..17b249e6 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -240,8 +240,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { let model: string | undefined; let requestId: string | undefined; let cwd: string | undefined; + let sessionId: string | undefined; let gitBranch: string | undefined; let agentId: string | undefined; + let agentName: string | undefined; let isSidechain = false; let isMeta = false; let userType: string | undefined; @@ -255,10 +257,12 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { if (isConversationalEntry(entry)) { // Common properties from ConversationalEntry base cwd = entry.cwd; + sessionId = entry.sessionId; gitBranch = entry.gitBranch; isSidechain = entry.isSidechain ?? false; userType = entry.userType; parentUuid = entry.parentUuid ?? null; + agentName = entry.agentName; // Type-specific properties if (entry.type === 'user') { @@ -298,8 +302,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { model, // Metadata cwd, + sessionId, gitBranch, agentId, + agentName, isSidechain, isMeta, userType, diff --git a/test/fixtures/team/task-log-stream-fallback-real.jsonl b/test/fixtures/team/task-log-stream-fallback-real.jsonl new file mode 100644 index 00000000..63a0bd55 --- /dev/null +++ b/test/fixtures/team/task-log-stream-fallback-real.jsonl @@ -0,0 +1,10 @@ +{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-start-real","timestamp":"2026-04-12T15:36:00.000Z","agentName":"tom","teamName":"beacon-desk-2","requestId":"req-start-real","message":{"id":"msg-a-start-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":5},"content":[{"type":"tool_use","id":"call-task-start-real","name":"mcp__agent-teams__task_start","input":{"teamName":"beacon-desk-2","taskId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"}}]}} +{"parentUuid":"a-start-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-start-real","timestamp":"2026-04-12T15:36:00.120Z","agentName":"tom","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-start-real","sourceToolUseID":"call-task-start-real","toolUseResult":{"toolUseId":"call-task-start-real","content":"{\"id\":\"c414cd52\"}"},"boardTaskLinks":[{"schemaVersion":1,"toolUseId":"call-task-start-real","task":{"ref":"c414cd52-470a-4b51-ae1e-e5250fff95d7","refKind":"canonical","canonicalId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"},"targetRole":"subject","linkKind":"lifecycle","taskArgumentSlot":"taskId","actorContext":{"relation":"idle"}}],"boardTaskToolActions":[{"schemaVersion":1,"toolUseId":"call-task-start-real","canonicalToolName":"task_start"}],"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-task-start-real","content":"ok"}]}} +{"parentUuid":"u-start-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-bash-real","timestamp":"2026-04-12T15:36:14.000Z","agentName":"tom","teamName":"beacon-desk-2","requestId":"req-bash-real","message":{"id":"msg-a-bash-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":12,"output_tokens":7},"content":[{"type":"tool_use","id":"call-bash-real","name":"Bash","input":{"command":"pnpm test --filter signal-ops","description":"Run targeted tests"}}]}} +{"parentUuid":"a-bash-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-bash-real","timestamp":"2026-04-12T15:36:14.250Z","agentName":"tom","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-bash-real","sourceToolUseID":"call-bash-real","toolUseResult":{"toolUseId":"call-bash-real","stdout":"tests ok","stderr":"","exitCode":0,"content":"tests ok"},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-bash-real","content":"tests ok"}]}} +{"parentUuid":"u-bash-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-comment-real","timestamp":"2026-04-12T15:36:30.000Z","agentName":"tom","teamName":"beacon-desk-2","requestId":"req-comment-real","message":{"id":"msg-a-comment-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":11,"output_tokens":6},"content":[{"type":"tool_use","id":"call-comment-real","name":"mcp__agent-teams__task_add_comment","input":{"teamName":"beacon-desk-2","taskId":"c414cd52-470a-4b51-ae1e-e5250fff95d7","text":"Diagnostics complete - passing targeted checks."}}]}} +{"parentUuid":"a-comment-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-comment-real","timestamp":"2026-04-12T15:36:30.150Z","agentName":"tom","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-comment-real","sourceToolUseID":"call-comment-real","toolUseResult":{"toolUseId":"call-comment-real","content":"{\"comment\":{\"id\":\"comment-real-1\",\"text\":\"Diagnostics complete - passing targeted checks.\"}}"},"boardTaskLinks":[{"schemaVersion":1,"toolUseId":"call-comment-real","task":{"ref":"c414cd52-470a-4b51-ae1e-e5250fff95d7","refKind":"canonical","canonicalId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"},"targetRole":"subject","linkKind":"board_action","taskArgumentSlot":"taskId","actorContext":{"relation":"same_task"}}],"boardTaskToolActions":[{"schemaVersion":1,"toolUseId":"call-comment-real","canonicalToolName":"task_add_comment","resultRefs":{"commentId":"comment-real-1"}}],"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-comment-real","content":"{\"comment\":{\"id\":\"comment-real-1\",\"text\":\"Diagnostics complete - passing targeted checks.\"}}"}]}} +{"parentUuid":"u-comment-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-alice-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-bash-alice-real","timestamp":"2026-04-12T15:36:35.000Z","agentName":"alice","teamName":"beacon-desk-2","requestId":"req-bash-alice-real","message":{"id":"msg-a-bash-alice-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":9,"output_tokens":4},"content":[{"type":"tool_use","id":"call-bash-alice-real","name":"Bash","input":{"command":"echo alien","description":"Unrelated command"}}]}} +{"parentUuid":"a-bash-alice-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-alice-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-bash-alice-real","timestamp":"2026-04-12T15:36:35.100Z","agentName":"alice","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-bash-alice-real","sourceToolUseID":"call-bash-alice-real","toolUseResult":{"toolUseId":"call-bash-alice-real","stdout":"alien","stderr":"","exitCode":0,"content":"alien"},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-bash-alice-real","content":"alien"}]}} +{"parentUuid":"u-bash-alice-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-complete-real","timestamp":"2026-04-12T15:36:45.000Z","agentName":"tom","teamName":"beacon-desk-2","requestId":"req-complete-real","message":{"id":"msg-a-complete-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":8,"output_tokens":4},"content":[{"type":"tool_use","id":"call-complete-real","name":"mcp__agent-teams__task_complete","input":{"teamName":"beacon-desk-2","taskId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"}}]}} +{"parentUuid":"a-complete-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-complete-real","timestamp":"2026-04-12T15:36:45.120Z","agentName":"tom","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-complete-real","sourceToolUseID":"call-complete-real","toolUseResult":{"toolUseId":"call-complete-real","content":"{\"id\":\"c414cd52\"}"},"boardTaskLinks":[{"schemaVersion":1,"toolUseId":"call-complete-real","task":{"ref":"c414cd52-470a-4b51-ae1e-e5250fff95d7","refKind":"canonical","canonicalId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"},"targetRole":"subject","linkKind":"lifecycle","taskArgumentSlot":"taskId","actorContext":{"relation":"same_task"}}],"boardTaskToolActions":[{"schemaVersion":1,"toolUseId":"call-complete-real","canonicalToolName":"task_complete"}],"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-complete-real","content":"ok"}]}} diff --git a/test/main/services/team/BoardTaskLogStreamIntegration.test.ts b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts index cc05960e..40355e34 100644 --- a/test/main/services/team/BoardTaskLogStreamIntegration.test.ts +++ b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import path from 'path'; @@ -13,6 +13,10 @@ import type { TeamTask } from '../../../../src/shared/types'; const TEAM_NAME = 'beacon-desk-2'; const TASK_ID = 'c414cd52-470a-4b51-ae1e-e5250fff95d7'; +const REAL_FIXTURE_PATH = path.resolve( + process.cwd(), + 'test/fixtures/team/task-log-stream-fallback-real.jsonl', +); function createTask(overrides: Partial = {}): TeamTask { return { @@ -377,4 +381,266 @@ describe('BoardTaskLogStreamService integration', () => { expect(response.segments).toHaveLength(1); expect(commentResult).toBeUndefined(); }); + + it('falls back to task time-window worker logs when explicit execution links are missing', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-inferred-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + const task = createTask({ + owner: 'tom', + workIntervals: [ + { + startedAt: '2026-04-12T15:36:00.000Z', + completedAt: '2026-04-12T15:40:00.000Z', + }, + ], + }); + + const lines = [ + createAssistantEntry({ + uuid: 'a-start', + timestamp: '2026-04-12T15:36:00.000Z', + requestId: 'req-start', + content: [ + { + type: 'tool_use', + id: 'call-task-start', + name: 'mcp__agent-teams__task_start', + input: { + teamName: TEAM_NAME, + taskId: TASK_ID, + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-start', + timestamp: '2026-04-12T15:36:00.120Z', + sourceToolAssistantUUID: 'a-start', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-task-start', + content: 'ok', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-task-start', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'lifecycle', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'idle', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-task-start', + canonicalToolName: 'task_start', + }, + ], + toolUseResult: { + toolUseId: 'call-task-start', + content: '{"id":"c414cd52"}', + }, + }), + createAssistantEntry({ + uuid: 'a-bash', + timestamp: '2026-04-12T15:36:14.000Z', + requestId: 'req-bash', + content: [ + { + type: 'tool_use', + id: 'call-bash', + name: 'Bash', + input: { + command: 'pnpm test', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-bash', + timestamp: '2026-04-12T15:36:14.300Z', + sourceToolAssistantUUID: 'a-bash', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-bash', + content: 'tests ok', + }, + ], + toolUseResult: { + toolUseId: 'call-bash', + content: 'tests ok', + }, + }), + createAssistantEntry({ + uuid: 'a-complete', + timestamp: '2026-04-12T15:36:30.000Z', + requestId: 'req-complete', + content: [ + { + type: 'tool_use', + id: 'call-complete', + name: 'mcp__agent-teams__task_complete', + input: { + teamName: TEAM_NAME, + taskId: TASK_ID, + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-complete', + timestamp: '2026-04-12T15:36:30.150Z', + sourceToolAssistantUUID: 'a-complete', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-complete', + content: 'ok', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-complete', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'lifecycle', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'same_task', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-complete', + canonicalToolName: 'task_complete', + }, + ], + toolUseResult: { + toolUseId: 'call-complete', + content: '{"id":"c414cd52"}', + }, + }), + ]; + + await writeFile( + transcriptPath, + `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, + 'utf8', + ); + + const recordSource = { + getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task), + }; + const taskReader = { + getTasks: async () => [task], + getDeletedTasks: async () => [] as TeamTask[], + }; + const transcriptSourceLocator = { + getContext: async () => + ({ + transcriptFiles: [transcriptPath], + config: { + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + }) as never, + }; + + const service = new BoardTaskLogStreamService( + recordSource as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + taskReader as never, + transcriptSourceLocator as never, + ); + const response = await service.getTaskLogStream(TEAM_NAME, task.id); + const rawMessages = flattenRawMessages(response); + const toolNames = rawMessages.flatMap((message) => + message.toolCalls.map((toolCall) => toolCall.name), + ); + + expect(response.participants.map((participant) => participant.label)).toEqual(['tom']); + expect(response.defaultFilter).toBe('member:tom'); + expect(toolNames).toContain('Bash'); + expect(toolNames).toContain('mcp__agent-teams__task_complete'); + }); + + it('reads a real-format transcript fixture and surfaces fallback worker logs for the task owner only', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-real-fixture-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + const fixtureText = await readFile(REAL_FIXTURE_PATH, 'utf8'); + await writeFile(transcriptPath, fixtureText, 'utf8'); + + const task = createTask({ + owner: 'tom', + workIntervals: [ + { + startedAt: '2026-04-12T15:36:00.000Z', + completedAt: '2026-04-12T15:40:00.000Z', + }, + ], + }); + + const recordSource = { + getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task), + }; + const taskReader = { + getTasks: async () => [task], + getDeletedTasks: async () => [] as TeamTask[], + }; + const transcriptSourceLocator = { + getContext: async () => + ({ + transcriptFiles: [transcriptPath], + config: { + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + }) as never, + }; + + const service = new BoardTaskLogStreamService( + recordSource as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + taskReader as never, + transcriptSourceLocator as never, + ); + const response = await service.getTaskLogStream(TEAM_NAME, task.id); + const rawMessages = flattenRawMessages(response); + const bashCommands = rawMessages.flatMap((message) => + message.toolCalls + .filter((toolCall) => toolCall.name === 'Bash') + .map((toolCall) => String(toolCall.input.command ?? '')), + ); + + expect(response.participants.map((participant) => participant.label)).toEqual(['tom']); + expect(response.defaultFilter).toBe('member:tom'); + expect(bashCommands).toContain('pnpm test --filter signal-ops'); + expect(bashCommands).not.toContain('echo alien'); + expect(rawMessages.some((message) => message.uuid === 'u-bash-alice-real')).toBe(false); + }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 7eef52f4..597b31bc 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -215,6 +215,9 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => expect(prompt).toContain('This reconnect/bootstrap step has already been completed deterministically by the runtime.'); expect(prompt).toContain('Do NOT start implementation in this turn.'); expect(prompt).toContain('Use this turn only to refresh context, review the current board snapshot, and confirm you are ready.'); + expect(prompt).toContain( + 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' + ); expect(prompt).toContain( 'review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request' ); @@ -473,7 +476,10 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => const prompt = extractPromptFromBootstrapFile(); expect(prompt).toContain('This reconnect/bootstrap step has already been completed deterministically by the runtime.'); expect(prompt).toContain('Do NOT use Agent to spawn or restore teammates.'); - expect(prompt).toContain('Use this turn only to refresh context, review the current board snapshot, and prepare the next delegation step.'); + expect(prompt).toContain('Use this turn only to refresh context and review the current board snapshot.'); + expect(prompt).toContain( + 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' + ); expect(prompt).toContain('DELEGATION-FIRST (behavior rule for ALL future turns):'); expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`); expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`); diff --git a/test/main/utils/jsonl.test.ts b/test/main/utils/jsonl.test.ts index 561de38d..3d458fdf 100644 --- a/test/main/utils/jsonl.test.ts +++ b/test/main/utils/jsonl.test.ts @@ -233,5 +233,48 @@ describe('jsonl', () => { expect(parsed?.uuid).toBe('bom-1'); }); + + it('preserves real transcript metadata needed by task-log fallback selection', () => { + const parsed = parseJsonlLine( + JSON.stringify({ + parentUuid: 'assistant-1', + isSidechain: false, + userType: 'external', + cwd: '/tmp/project', + sessionId: 'session-real-1', + version: '1.0.0', + gitBranch: 'main', + type: 'user', + uuid: 'user-real-1', + timestamp: '2026-04-12T15:36:14.250Z', + agentName: 'tom', + isMeta: true, + sourceToolAssistantUUID: 'assistant-1', + sourceToolUseID: 'call-bash-real', + toolUseResult: { + toolUseId: 'call-bash-real', + stdout: 'tests ok', + exitCode: 0, + }, + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-bash-real', + content: 'tests ok', + }, + ], + }, + }), + ); + + expect(parsed?.sessionId).toBe('session-real-1'); + expect(parsed?.agentName).toBe('tom'); + expect(parsed?.isMeta).toBe(true); + expect(parsed?.sourceToolAssistantUUID).toBe('assistant-1'); + expect(parsed?.sourceToolUseID).toBe('call-bash-real'); + expect(parsed?.toolResults[0]?.toolUseId).toBe('call-bash-real'); + }); }); });