fix(renderer): dedupe runtime watcher and model badges

This commit is contained in:
777genius 2026-06-06 21:20:58 +03:00
parent ea56d15712
commit af8a5fc66e
15 changed files with 257 additions and 91 deletions

View file

@ -5,6 +5,15 @@ import { isTeamProvisioningActive, selectTeamDataForName } from '@renderer/store
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
const TEAM_AGENT_RUNTIME_REFRESH_MS = 10_000; const TEAM_AGENT_RUNTIME_REFRESH_MS = 10_000;
const ACTIVE_TEAM_AGENT_RUNTIME_REFRESH_MS = 5_000;
interface TeamAgentRuntimeWatchEntry {
refCount: number;
timer: number;
inFlight: boolean;
}
const teamAgentRuntimeWatchEntries = new Map<string, TeamAgentRuntimeWatchEntry>();
export function shouldWatchTeamAgentRuntime(input: { export function shouldWatchTeamAgentRuntime(input: {
enabled: boolean; enabled: boolean;
@ -19,6 +28,13 @@ export function shouldWatchTeamAgentRuntime(input: {
return input.leadActivity === 'active' || input.leadActivity === 'idle'; return input.leadActivity === 'active' || input.leadActivity === 'idle';
} }
export function __resetTeamAgentRuntimeWatcherForTests(): void {
for (const entry of teamAgentRuntimeWatchEntries.values()) {
window.clearInterval(entry.timer);
}
teamAgentRuntimeWatchEntries.clear();
}
interface TeamAgentRuntimeWatcherOptions { interface TeamAgentRuntimeWatcherOptions {
teamName: string; teamName: string;
enabled: boolean; enabled: boolean;
@ -54,12 +70,38 @@ export function useTeamAgentRuntimeWatcher({
}); });
if (!shouldWatch) return; if (!shouldWatch) return;
void fetchTeamAgentRuntime(teamName); const existingEntry = teamAgentRuntimeWatchEntries.get(teamName);
const timer = window.setInterval(() => { if (existingEntry) {
void fetchTeamAgentRuntime(teamName); existingEntry.refCount += 1;
}, TEAM_AGENT_RUNTIME_REFRESH_MS);
return () => { return () => {
window.clearInterval(timer); existingEntry.refCount -= 1;
if (existingEntry.refCount <= 0) {
window.clearInterval(existingEntry.timer);
teamAgentRuntimeWatchEntries.delete(teamName);
}
};
}
const refreshIntervalMs =
leadActivity === 'active'
? ACTIVE_TEAM_AGENT_RUNTIME_REFRESH_MS
: TEAM_AGENT_RUNTIME_REFRESH_MS;
const entry: TeamAgentRuntimeWatchEntry = {
refCount: 1,
timer: window.setInterval(() => {
refreshTeamAgentRuntime(teamName, fetchTeamAgentRuntime);
}, refreshIntervalMs),
inFlight: false,
};
teamAgentRuntimeWatchEntries.set(teamName, entry);
refreshTeamAgentRuntime(teamName, fetchTeamAgentRuntime);
return () => {
entry.refCount -= 1;
if (entry.refCount <= 0) {
window.clearInterval(entry.timer);
teamAgentRuntimeWatchEntries.delete(teamName);
}
}; };
}, [ }, [
effectiveIsTeamAlive, effectiveIsTeamAlive,
@ -70,3 +112,21 @@ export function useTeamAgentRuntimeWatcher({
teamName, teamName,
]); ]);
} }
function refreshTeamAgentRuntime(
teamName: string,
fetchTeamAgentRuntime: (teamName: string) => Promise<void>
): void {
const entry = teamAgentRuntimeWatchEntries.get(teamName);
if (!entry || entry.inFlight) return;
entry.inFlight = true;
void fetchTeamAgentRuntime(teamName)
.catch(() => undefined)
.finally(() => {
const latestEntry = teamAgentRuntimeWatchEntries.get(teamName);
if (latestEntry === entry) {
latestEntry.inFlight = false;
}
});
}

View file

@ -667,7 +667,17 @@ function getSupplementalVisibleModels(
return models; return models;
} }
return [...models, ...ANTHROPIC_VISIBLE_MODEL_FALLBACKS]; const existingLabels = new Set(
models
.map((model) => getTeamModelBadgeLabel(providerId, model)?.trim().toLowerCase())
.filter((label): label is string => Boolean(label))
);
const supplementalModels = ANTHROPIC_VISIBLE_MODEL_FALLBACKS.filter((model) => {
const label = getTeamModelBadgeLabel(providerId, model)?.trim().toLowerCase();
return !label || !existingLabels.has(label);
});
return [...models, ...supplementalModels];
} }
export function getVisibleTeamProviderModels( export function getVisibleTeamProviderModels(

View file

@ -37,21 +37,21 @@ function createSession(overrides?: {
}); });
const close = overrides?.close ?? vi.fn().mockResolvedValue(undefined); const close = overrides?.close ?? vi.fn().mockResolvedValue(undefined);
const session = { const session: CodexAppServerSession = {
initializeResponse: { initializeResponse: {
userAgent: 'codex-test', userAgent: 'codex-test',
codexHome: '/Users/tester/.codex', codexHome: '/Users/tester/.codex',
platformFamily: 'darwin', platformFamily: 'darwin',
platformOs: 'macos', platformOs: 'macos',
}, },
request, request: request as CodexAppServerSession['request'],
notify: vi.fn().mockResolvedValue(undefined), notify: vi.fn().mockResolvedValue(undefined) as CodexAppServerSession['notify'],
onNotification: vi.fn((listener: (method: string, params: unknown) => void) => { onNotification: vi.fn((listener: (method: string, params: unknown) => void) => {
listeners.add(listener); listeners.add(listener);
return () => listeners.delete(listener); return () => listeners.delete(listener);
}), }) as CodexAppServerSession['onNotification'],
close, close: close as CodexAppServerSession['close'],
} satisfies CodexAppServerSession; };
return { return {
session, session,

View file

@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { Mock } from 'vitest';
import { ListDashboardRecentProjectsUseCase } from '@features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase'; import { ListDashboardRecentProjectsUseCase } from '@features/recent-projects/core/application/use-cases/ListDashboardRecentProjectsUseCase';
@ -33,15 +34,17 @@ function makeCandidate(overrides: Partial<RecentProjectCandidate> = {}): RecentP
}; };
} }
function createLogger(): LoggerPort & { type LoggerMock = LoggerPort & {
info: ReturnType<typeof vi.fn>; info: Mock<LoggerPort['info']>;
warn: ReturnType<typeof vi.fn>; warn: Mock<LoggerPort['warn']>;
error: ReturnType<typeof vi.fn>; error: Mock<LoggerPort['error']>;
} { };
function createLogger(): LoggerMock {
return { return {
info: vi.fn(), info: vi.fn<LoggerPort['info']>(),
warn: vi.fn(), warn: vi.fn<LoggerPort['warn']>(),
error: vi.fn(), error: vi.fn<LoggerPort['error']>(),
}; };
} }

View file

@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import type { Mock } from 'vitest';
import { CodexRecentProjectsSourceAdapter } from '@features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter'; import { CodexRecentProjectsSourceAdapter } from '@features/recent-projects/main/adapters/output/sources/CodexRecentProjectsSourceAdapter';
@ -6,15 +7,17 @@ import type { LoggerPort } from '@features/recent-projects/core/application/port
import type { CodexAppServerClient } from '@features/recent-projects/main/infrastructure/codex/CodexAppServerClient'; import type { CodexAppServerClient } from '@features/recent-projects/main/infrastructure/codex/CodexAppServerClient';
import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver'; import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
function createLogger(): LoggerPort & { type LoggerMock = LoggerPort & {
info: ReturnType<typeof vi.fn>; info: Mock<LoggerPort['info']>;
warn: ReturnType<typeof vi.fn>; warn: Mock<LoggerPort['warn']>;
error: ReturnType<typeof vi.fn>; error: Mock<LoggerPort['error']>;
} { };
function createLogger(): LoggerMock {
return { return {
info: vi.fn(), info: vi.fn<LoggerPort['info']>(),
warn: vi.fn(), warn: vi.fn<LoggerPort['warn']>(),
error: vi.fn(), error: vi.fn<LoggerPort['error']>(),
}; };
} }

View file

@ -4,19 +4,22 @@ import path from 'node:path';
import { CodexSessionFileRecentProjectsSourceAdapter } from '@features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter'; import { CodexSessionFileRecentProjectsSourceAdapter } from '@features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Mock } from 'vitest';
import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort'; import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort';
import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver'; import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
function createLogger(): LoggerPort & { type LoggerMock = LoggerPort & {
info: ReturnType<typeof vi.fn>; info: Mock<LoggerPort['info']>;
warn: ReturnType<typeof vi.fn>; warn: Mock<LoggerPort['warn']>;
error: ReturnType<typeof vi.fn>; error: Mock<LoggerPort['error']>;
} { };
function createLogger(): LoggerMock {
return { return {
info: vi.fn(), info: vi.fn<LoggerPort['info']>(),
warn: vi.fn(), warn: vi.fn<LoggerPort['warn']>(),
error: vi.fn(), error: vi.fn<LoggerPort['error']>(),
}; };
} }

View file

@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Mock } from 'vitest';
import { ProjectScanner } from '../../../src/main/services/discovery/ProjectScanner'; import { ProjectScanner } from '../../../src/main/services/discovery/ProjectScanner';
@ -9,19 +10,24 @@ import type { Project, SearchSessionsResult } from '../../../src/main/types';
*/ */
describe('Global Search - ProjectScanner.searchAllProjects', () => { describe('Global Search - ProjectScanner.searchAllProjects', () => {
let projectScanner: ProjectScanner; let projectScanner: ProjectScanner;
let mockScan: ReturnType<typeof vi.fn>; let mockScan: Mock<() => Promise<Project[]>>;
let mockSearchSessions: ReturnType<typeof vi.fn>; let mockSearchSessions: Mock<
(projectId: string, query: string, maxResults?: number) => Promise<SearchSessionsResult>
>;
beforeEach(() => { beforeEach(() => {
// Create a real ProjectScanner instance // Create a real ProjectScanner instance
projectScanner = new ProjectScanner(); projectScanner = new ProjectScanner();
// Mock the scan() method // Mock the scan() method
mockScan = vi.fn(); mockScan = vi.fn<() => Promise<Project[]>>();
projectScanner.scan = mockScan; projectScanner.scan = mockScan;
// Mock the sessionSearcher.searchSessions() method // Mock the sessionSearcher.searchSessions() method
mockSearchSessions = vi.fn(); mockSearchSessions =
vi.fn<
(projectId: string, query: string, maxResults?: number) => Promise<SearchSessionsResult>
>();
// @ts-expect-error - Accessing private property for testing // @ts-expect-error - Accessing private property for testing
projectScanner.sessionSearcher = { projectScanner.sessionSearcher = {
searchSessions: mockSearchSessions, searchSessions: mockSearchSessions,

View file

@ -80,6 +80,7 @@ vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({
env: { HOME: '/Users/tester' }, env: { HOME: '/Users/tester' },
connectionIssues: {}, connectionIssues: {},
})), })),
getProviderStatusStoredCredentialAllowlist: vi.fn(() => undefined),
})); }));
vi.mock('@main/utils/cliAuthDiagLog', () => ({ vi.mock('@main/utils/cliAuthDiagLog', () => ({
@ -542,7 +543,12 @@ describe('CliInstallerService', () => {
it('falls back to the installed launcher path when --version reports unknown', async () => { it('falls back to the installed launcher path when --version reports unknown', async () => {
allowConsoleLogs(); allowConsoleLogs();
vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/Users/tester/.local/bin/claude'); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/Users/tester/.local/bin/claude');
vi.spyOn(service as never, 'inferInstalledCliVersionFromPath').mockResolvedValue('2.1.101'); vi.spyOn(
service as unknown as {
inferInstalledCliVersionFromPath: (binaryPath: string) => Promise<string | null>;
},
'inferInstalledCliVersionFromPath'
).mockResolvedValue('2.1.101');
vi.mocked(execCli) vi.mocked(execCli)
.mockResolvedValueOnce({ stdout: 'unknown', stderr: '' }) .mockResolvedValueOnce({ stdout: 'unknown', stderr: '' })
.mockResolvedValueOnce({ .mockResolvedValueOnce({

View file

@ -25,6 +25,27 @@ import type {
const TASK_COMMENT_FORWARDING_ENV = 'CLAUDE_TEAM_TASK_COMMENT_FORWARDING'; const TASK_COMMENT_FORWARDING_ENV = 'CLAUDE_TEAM_TASK_COMMENT_FORWARDING';
const tempPaths: string[] = []; const tempPaths: string[] = [];
type TeamDataServicePrivate = {
extractLeadAssistantTextsFromJsonlLines(
rawLines: readonly string[],
leadName: string,
leadSessionId: string,
maxTexts: number
): Promise<InboxMessage[]>;
getLeadSessionJsonlPaths(projectDir: string): Promise<Map<string, string>>;
extractLeadSessionTextsFromJsonl(
jsonlPath: string,
leadName: string,
leadSessionId: string,
maxTexts: number
): Promise<InboxMessage[]>;
extractLeadSessionTexts(teamName: string, config: TeamConfig): Promise<InboxMessage[]>;
};
function teamDataServicePrivate(service: TeamDataService): TeamDataServicePrivate {
return service as unknown as TeamDataServicePrivate;
}
function createLeadAssistantEntry( function createLeadAssistantEntry(
uuid: string, uuid: string,
timestamp: string, timestamp: string,
@ -4690,7 +4711,7 @@ describe('TeamDataService', () => {
), ),
]); ]);
const assistantSpy = vi.spyOn(service as never, 'extractLeadAssistantTextsFromJsonlLines' as never); const assistantSpy = vi.spyOn(teamDataServicePrivate(service), 'extractLeadAssistantTextsFromJsonlLines');
const extract = ( const extract = (
service as unknown as { service as unknown as {
extractLeadSessionTextsFromJsonl: ( extractLeadSessionTextsFromJsonl: (
@ -4698,7 +4719,7 @@ describe('TeamDataService', () => {
leadName: string, leadName: string,
leadSessionId: string, leadSessionId: string,
maxTexts: number maxTexts: number
) => Promise<Array<{ text: string }>>; ) => Promise<InboxMessage[]>;
} }
).extractLeadSessionTextsFromJsonl.bind(service); ).extractLeadSessionTextsFromJsonl.bind(service);
@ -4730,11 +4751,11 @@ describe('TeamDataService', () => {
leadName: string, leadName: string,
leadSessionId: string, leadSessionId: string,
maxTexts: number maxTexts: number
) => Promise<Array<{ text: string }>>; ) => Promise<InboxMessage[]>;
} }
).extractLeadAssistantTextsFromJsonlLines.bind(service); ).extractLeadAssistantTextsFromJsonlLines.bind(service);
const assistantSpy = vi const assistantSpy = vi
.spyOn(service as never, 'extractLeadAssistantTextsFromJsonlLines' as never) .spyOn(teamDataServicePrivate(service), 'extractLeadAssistantTextsFromJsonlLines')
.mockImplementation(async (...args: unknown[]) => { .mockImplementation(async (...args: unknown[]) => {
const [rawLines, leadName, leadSessionId, maxTexts] = args as [ const [rawLines, leadName, leadSessionId, maxTexts] = args as [
readonly string[], readonly string[],
@ -4752,7 +4773,7 @@ describe('TeamDataService', () => {
leadName: string, leadName: string,
leadSessionId: string, leadSessionId: string,
maxTexts: number maxTexts: number
) => Promise<Array<{ text: string }>>; ) => Promise<InboxMessage[]>;
} }
).extractLeadSessionTextsFromJsonl.bind(service); ).extractLeadSessionTextsFromJsonl.bind(service);
@ -4782,12 +4803,12 @@ describe('TeamDataService', () => {
leadName: string, leadName: string,
leadSessionId: string, leadSessionId: string,
maxTexts: number maxTexts: number
) => Promise<Array<{ text: string }>>; ) => Promise<InboxMessage[]>;
} }
).extractLeadAssistantTextsFromJsonlLines.bind(service); ).extractLeadAssistantTextsFromJsonlLines.bind(service);
let appended = false; let appended = false;
const assistantSpy = vi const assistantSpy = vi
.spyOn(service as never, 'extractLeadAssistantTextsFromJsonlLines' as never) .spyOn(teamDataServicePrivate(service), 'extractLeadAssistantTextsFromJsonlLines')
.mockImplementation(async (...args: unknown[]) => { .mockImplementation(async (...args: unknown[]) => {
const [rawLines, leadName, leadSessionId, maxTexts] = args as [ const [rawLines, leadName, leadSessionId, maxTexts] = args as [
readonly string[], readonly string[],
@ -4818,7 +4839,7 @@ describe('TeamDataService', () => {
leadName: string, leadName: string,
leadSessionId: string, leadSessionId: string,
maxTexts: number maxTexts: number
) => Promise<Array<{ text: string }>>; ) => Promise<InboxMessage[]>;
} }
).extractLeadSessionTextsFromJsonl.bind(service); ).extractLeadSessionTextsFromJsonl.bind(service);
@ -4847,7 +4868,7 @@ describe('TeamDataService', () => {
leadName: string, leadName: string,
leadSessionId: string, leadSessionId: string,
maxTexts: number maxTexts: number
) => Promise<Array<{ text: string }>>; ) => Promise<InboxMessage[]>;
} }
).extractLeadAssistantTextsFromJsonlLines.bind(service); ).extractLeadAssistantTextsFromJsonlLines.bind(service);
let releaseFirstInvocation = () => {}; let releaseFirstInvocation = () => {};
@ -4856,7 +4877,7 @@ describe('TeamDataService', () => {
firstInvocationStartedResolve = resolve; firstInvocationStartedResolve = resolve;
}); });
const assistantSpy = vi const assistantSpy = vi
.spyOn(service as never, 'extractLeadAssistantTextsFromJsonlLines' as never) .spyOn(teamDataServicePrivate(service), 'extractLeadAssistantTextsFromJsonlLines')
.mockImplementation(async (...args: unknown[]) => { .mockImplementation(async (...args: unknown[]) => {
const [rawLines, leadName, leadSessionId, maxTexts] = args as [ const [rawLines, leadName, leadSessionId, maxTexts] = args as [
readonly string[], readonly string[],
@ -4879,7 +4900,7 @@ describe('TeamDataService', () => {
leadName: string, leadName: string,
leadSessionId: string, leadSessionId: string,
maxTexts: number maxTexts: number
) => Promise<Array<{ text: string }>>; ) => Promise<InboxMessage[]>;
} }
).extractLeadSessionTextsFromJsonl.bind(service); ).extractLeadSessionTextsFromJsonl.bind(service);
@ -4922,7 +4943,7 @@ describe('TeamDataService', () => {
), ),
]); ]);
const assistantSpy = vi.spyOn(service as never, 'extractLeadAssistantTextsFromJsonlLines' as never); const assistantSpy = vi.spyOn(teamDataServicePrivate(service), 'extractLeadAssistantTextsFromJsonlLines');
const extract = ( const extract = (
service as unknown as { service as unknown as {
extractLeadSessionTextsFromJsonl: ( extractLeadSessionTextsFromJsonl: (
@ -4956,7 +4977,7 @@ describe('TeamDataService', () => {
), ),
]); ]);
const assistantSpy = vi.spyOn(service as never, 'extractLeadAssistantTextsFromJsonlLines' as never); const assistantSpy = vi.spyOn(teamDataServicePrivate(service), 'extractLeadAssistantTextsFromJsonlLines');
const extract = ( const extract = (
service as unknown as { service as unknown as {
extractLeadSessionTextsFromJsonl: ( extractLeadSessionTextsFromJsonl: (
@ -4964,7 +4985,7 @@ describe('TeamDataService', () => {
leadName: string, leadName: string,
leadSessionId: string, leadSessionId: string,
maxTexts: number maxTexts: number
) => Promise<Array<{ text: string }>>; ) => Promise<InboxMessage[]>;
} }
).extractLeadSessionTextsFromJsonl.bind(service); ).extractLeadSessionTextsFromJsonl.bind(service);
@ -4988,7 +5009,7 @@ describe('TeamDataService', () => {
]); ]);
await fs.appendFile(jsonlPath, '{"type":"assistant"', 'utf8'); await fs.appendFile(jsonlPath, '{"type":"assistant"', 'utf8');
const assistantSpy = vi.spyOn(service as never, 'extractLeadAssistantTextsFromJsonlLines' as never); const assistantSpy = vi.spyOn(teamDataServicePrivate(service), 'extractLeadAssistantTextsFromJsonlLines');
const extract = ( const extract = (
service as unknown as { service as unknown as {
extractLeadSessionTextsFromJsonl: ( extractLeadSessionTextsFromJsonl: (
@ -4996,7 +5017,7 @@ describe('TeamDataService', () => {
leadName: string, leadName: string,
leadSessionId: string, leadSessionId: string,
maxTexts: number maxTexts: number
) => Promise<Array<{ text: string }>>; ) => Promise<InboxMessage[]>;
} }
).extractLeadSessionTextsFromJsonl.bind(service); ).extractLeadSessionTextsFromJsonl.bind(service);
@ -5036,7 +5057,7 @@ describe('TeamDataService', () => {
), ),
]); ]);
const assistantSpy = vi.spyOn(service as never, 'extractLeadAssistantTextsFromJsonlLines' as never); const assistantSpy = vi.spyOn(teamDataServicePrivate(service), 'extractLeadAssistantTextsFromJsonlLines');
const extract = ( const extract = (
service as unknown as { service as unknown as {
extractLeadSessionTextsFromJsonl: ( extractLeadSessionTextsFromJsonl: (
@ -5044,7 +5065,7 @@ describe('TeamDataService', () => {
leadName: string, leadName: string,
leadSessionId: string, leadSessionId: string,
maxTexts: number maxTexts: number
) => Promise<Array<{ text: string }>>; ) => Promise<InboxMessage[]>;
} }
).extractLeadSessionTextsFromJsonl.bind(service); ).extractLeadSessionTextsFromJsonl.bind(service);
@ -5073,12 +5094,12 @@ describe('TeamDataService', () => {
leadName: string, leadName: string,
leadSessionId: string, leadSessionId: string,
maxTexts: number maxTexts: number
) => Promise<Array<{ text: string }>>; ) => Promise<InboxMessage[]>;
} }
).extractLeadAssistantTextsFromJsonlLines.bind(service); ).extractLeadAssistantTextsFromJsonlLines.bind(service);
let shouldFail = true; let shouldFail = true;
const assistantSpy = vi const assistantSpy = vi
.spyOn(service as never, 'extractLeadAssistantTextsFromJsonlLines' as never) .spyOn(teamDataServicePrivate(service), 'extractLeadAssistantTextsFromJsonlLines')
.mockImplementation(async (...args: unknown[]) => { .mockImplementation(async (...args: unknown[]) => {
const [rawLines, leadName, leadSessionId, maxTexts] = args as [ const [rawLines, leadName, leadSessionId, maxTexts] = args as [
readonly string[], readonly string[],
@ -5098,7 +5119,7 @@ describe('TeamDataService', () => {
leadName: string, leadName: string,
leadSessionId: string, leadSessionId: string,
maxTexts: number maxTexts: number
) => Promise<Array<{ text: string }>>; ) => Promise<InboxMessage[]>;
} }
).extractLeadSessionTextsFromJsonl.bind(service); ).extractLeadSessionTextsFromJsonl.bind(service);
@ -5124,10 +5145,10 @@ describe('TeamDataService', () => {
), ),
]); ]);
const firstSpy = vi.spyOn(firstService as never, 'extractLeadAssistantTextsFromJsonlLines' as never); const firstSpy = vi.spyOn(teamDataServicePrivate(firstService), 'extractLeadAssistantTextsFromJsonlLines');
const secondSpy = vi.spyOn( const secondSpy = vi.spyOn(
secondService as never, teamDataServicePrivate(secondService),
'extractLeadAssistantTextsFromJsonlLines' as never 'extractLeadAssistantTextsFromJsonlLines'
); );
const firstExtract = ( const firstExtract = (
firstService as unknown as { firstService as unknown as {
@ -5136,7 +5157,7 @@ describe('TeamDataService', () => {
leadName: string, leadName: string,
leadSessionId: string, leadSessionId: string,
maxTexts: number maxTexts: number
) => Promise<Array<{ text: string }>>; ) => Promise<InboxMessage[]>;
} }
).extractLeadSessionTextsFromJsonl.bind(firstService); ).extractLeadSessionTextsFromJsonl.bind(firstService);
const secondExtract = ( const secondExtract = (
@ -5146,7 +5167,7 @@ describe('TeamDataService', () => {
leadName: string, leadName: string,
leadSessionId: string, leadSessionId: string,
maxTexts: number maxTexts: number
) => Promise<Array<{ text: string }>>; ) => Promise<InboxMessage[]>;
} }
).extractLeadSessionTextsFromJsonl.bind(secondService); ).extractLeadSessionTextsFromJsonl.bind(secondService);
@ -5177,10 +5198,10 @@ describe('TeamDataService', () => {
}; };
(service as unknown as { projectResolver: typeof projectResolver }).projectResolver = (service as unknown as { projectResolver: typeof projectResolver }).projectResolver =
projectResolver; projectResolver;
vi.spyOn(service as never, 'getLeadSessionJsonlPaths' as never).mockResolvedValue( vi.spyOn(teamDataServicePrivate(service), 'getLeadSessionJsonlPaths').mockResolvedValue(
new Map([['lead-1', '/fast-project/lead-1.jsonl']]) new Map([['lead-1', '/fast-project/lead-1.jsonl']])
); );
vi.spyOn(service as never, 'extractLeadSessionTextsFromJsonl' as never).mockResolvedValue([ vi.spyOn(teamDataServicePrivate(service), 'extractLeadSessionTextsFromJsonl').mockResolvedValue([
{ {
from: 'fast-lead', from: 'fast-lead',
text: 'Fast path recovered lead thought from the known lead session.', text: 'Fast path recovered lead thought from the known lead session.',
@ -5228,7 +5249,7 @@ describe('TeamDataService', () => {
}; };
(service as unknown as { projectResolver: typeof projectResolver }).projectResolver = (service as unknown as { projectResolver: typeof projectResolver }).projectResolver =
projectResolver; projectResolver;
vi.spyOn(service as never, 'getLeadSessionJsonlPaths' as never).mockImplementation( vi.spyOn(teamDataServicePrivate(service), 'getLeadSessionJsonlPaths').mockImplementation(
(...args: unknown[]) => { (...args: unknown[]) => {
const [projectDir] = args as [string]; const [projectDir] = args as [string];
if (projectDir === '/actual-project') { if (projectDir === '/actual-project') {
@ -5237,7 +5258,7 @@ describe('TeamDataService', () => {
return Promise.resolve(new Map()); return Promise.resolve(new Map());
} }
); );
vi.spyOn(service as never, 'extractLeadSessionTextsFromJsonl' as never).mockResolvedValue([ vi.spyOn(teamDataServicePrivate(service), 'extractLeadSessionTextsFromJsonl').mockResolvedValue([
{ {
from: 'actual-lead', from: 'actual-lead',
text: 'Fallback path recovered lead thought from the repaired context.', text: 'Fallback path recovered lead thought from the repaired context.',
@ -5292,7 +5313,7 @@ describe('TeamDataService', () => {
}; };
(service as unknown as { projectResolver: typeof projectResolver }).projectResolver = (service as unknown as { projectResolver: typeof projectResolver }).projectResolver =
projectResolver; projectResolver;
vi.spyOn(service as never, 'getLeadSessionJsonlPaths' as never).mockImplementation( vi.spyOn(teamDataServicePrivate(service), 'getLeadSessionJsonlPaths').mockImplementation(
(...args: unknown[]) => { (...args: unknown[]) => {
const [projectDir] = args as [string]; const [projectDir] = args as [string];
if (projectDir === '/current-project') { if (projectDir === '/current-project') {
@ -5304,7 +5325,7 @@ describe('TeamDataService', () => {
} }
); );
const extractSpy = vi const extractSpy = vi
.spyOn(service as never, 'extractLeadSessionTextsFromJsonl' as never) .spyOn(teamDataServicePrivate(service), 'extractLeadSessionTextsFromJsonl')
.mockResolvedValue([ .mockResolvedValue([
{ {
from: 'current-lead', from: 'current-lead',
@ -5366,11 +5387,11 @@ describe('TeamDataService', () => {
(service as unknown as { projectResolver: typeof projectResolver }).projectResolver = (service as unknown as { projectResolver: typeof projectResolver }).projectResolver =
projectResolver; projectResolver;
const getPathsSpy = vi const getPathsSpy = vi
.spyOn(service as never, 'getLeadSessionJsonlPaths' as never) .spyOn(teamDataServicePrivate(service), 'getLeadSessionJsonlPaths')
.mockResolvedValueOnce(new Map([['lead-history', '/same-project/lead-history.jsonl']])) .mockResolvedValueOnce(new Map([['lead-history', '/same-project/lead-history.jsonl']]))
.mockResolvedValueOnce(new Map([['lead-current', '/same-project/lead-current.jsonl']])); .mockResolvedValueOnce(new Map([['lead-current', '/same-project/lead-current.jsonl']]));
const extractSpy = vi const extractSpy = vi
.spyOn(service as never, 'extractLeadSessionTextsFromJsonl' as never) .spyOn(teamDataServicePrivate(service), 'extractLeadSessionTextsFromJsonl')
.mockResolvedValue([ .mockResolvedValue([
{ {
from: 'current-lead', from: 'current-lead',
@ -5974,7 +5995,7 @@ describe('TeamDataService', () => {
], ],
}); });
vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockResolvedValue([ vi.spyOn(teamDataServicePrivate(harness.service), 'extractLeadSessionTexts').mockResolvedValue([
{ {
from: 'team-lead', from: 'team-lead',
text: 'Lead summary', text: 'Lead summary',
@ -6028,7 +6049,7 @@ describe('TeamDataService', () => {
resolveMembers: resolveMembersSpy, resolveMembers: resolveMembersSpy,
}); });
vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockResolvedValue([ vi.spyOn(teamDataServicePrivate(harness.service), 'extractLeadSessionTexts').mockResolvedValue([
{ {
from: 'team-lead', from: 'team-lead',
text: 'Lead summary', text: 'Lead summary',
@ -6110,7 +6131,7 @@ describe('TeamDataService', () => {
}, },
}); });
vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation( vi.spyOn(teamDataServicePrivate(harness.service), 'extractLeadSessionTexts').mockImplementation(
() => { () => {
order.push('leadTexts:start'); order.push('leadTexts:start');
throw new Error('lead sync fail'); throw new Error('lead sync fail');

View file

@ -72,7 +72,12 @@ function createStubbedServiceHarness() {
const service = new TeamMemberRuntimeAdvisoryService(logsFinder as never); const service = new TeamMemberRuntimeAdvisoryService(logsFinder as never);
const advisoryByFilePath = new Map<string, MemberRuntimeAdvisory | null>(); const advisoryByFilePath = new Map<string, MemberRuntimeAdvisory | null>();
const readRecentApiRetryAdvisory = vi const readRecentApiRetryAdvisory = vi
.spyOn(service as never, 'readRecentApiRetryAdvisory' as never) .spyOn(
service as unknown as {
readRecentApiRetryAdvisory: (filePath: string) => Promise<MemberRuntimeAdvisory | null>;
},
'readRecentApiRetryAdvisory'
)
.mockImplementation(async (...args: unknown[]) => { .mockImplementation(async (...args: unknown[]) => {
const filePath = String(args[0] ?? ''); const filePath = String(args[0] ?? '');
if (advisoryByFilePath.has(filePath)) { if (advisoryByFilePath.has(filePath)) {

View file

@ -1169,11 +1169,14 @@ describe('TeamTaskActivityIntervalService', () => {
describe('resumeActiveIntervalsForMembers (batch)', () => { describe('resumeActiveIntervalsForMembers (batch)', () => {
it('returns zero changes for an empty members list without scanning the tasks directory', () => { it('returns zero changes for an empty members list without scanning the tasks directory', () => {
const service = new TeamTaskActivityIntervalService(); const service = new TeamTaskActivityIntervalService();
const mutateSpy = vi.spyOn( const lockSpy = vi.spyOn(
service as unknown as { service as unknown as {
mutateTeamTasks: TeamTaskActivityIntervalService['resumeActiveIntervalsForMember']; mutateTeamTasksWithLock: (
teamName: string,
run: () => { changedTasks: number; failed?: boolean }
) => { changedTasks: number; failed?: boolean };
}, },
'mutateTeamTasks' 'mutateTeamTasksWithLock'
); );
const result = service.resumeActiveIntervalsForMembers( const result = service.resumeActiveIntervalsForMembers(
@ -1183,7 +1186,7 @@ describe('TeamTaskActivityIntervalService', () => {
); );
expect(result).toEqual({ changedTasks: 0 }); expect(result).toEqual({ changedTasks: 0 });
expect(mutateSpy).not.toHaveBeenCalled(); expect(lockSpy).not.toHaveBeenCalled();
}); });
it('returns zero changes when all member names are blank', () => { it('returns zero changes when all member names are blank', () => {
@ -1326,11 +1329,14 @@ describe('TeamTaskActivityIntervalService', () => {
}); });
const service = new TeamTaskActivityIntervalService(); const service = new TeamTaskActivityIntervalService();
const mutateSpy = vi.spyOn( const lockSpy = vi.spyOn(
service as unknown as { service as unknown as {
mutateTeamTasks: TeamTaskActivityIntervalService['resumeActiveIntervalsForMember']; mutateTeamTasksWithLock: (
teamName: string,
run: () => { changedTasks: number; failed?: boolean }
) => { changedTasks: number; failed?: boolean };
}, },
'mutateTeamTasks' 'mutateTeamTasksWithLock'
); );
const result = service.resumeActiveIntervalsForMembers( const result = service.resumeActiveIntervalsForMembers(
@ -1339,7 +1345,7 @@ describe('TeamTaskActivityIntervalService', () => {
'2026-05-08T10:20:00.000Z' '2026-05-08T10:20:00.000Z'
); );
expect(mutateSpy).toHaveBeenCalledTimes(1); expect(lockSpy).toHaveBeenCalledTimes(1);
expect(result.changedTasks).toBe(3); expect(result.changedTasks).toBe(3);
}); });
@ -1477,7 +1483,7 @@ describe('TeamTaskActivityIntervalService', () => {
]); ]);
}); });
it('routes single-member resumeActiveIntervalsForMember through the batch implementation', async () => { it('resumes single-member intervals through the member noop-cache path', async () => {
await writeTask('alpha', { await writeTask('alpha', {
id: 'work-task', id: 'work-task',
subject: 'Build', subject: 'Build',
@ -1490,17 +1496,19 @@ describe('TeamTaskActivityIntervalService', () => {
}); });
const service = new TeamTaskActivityIntervalService(); const service = new TeamTaskActivityIntervalService();
const batchSpy = vi.spyOn(service, 'resumeActiveIntervalsForMembers');
const result = service.resumeActiveIntervalsForMember( const result = service.resumeActiveIntervalsForMember(
'alpha', 'alpha',
'bob', 'bob',
'2026-05-08T10:20:00.000Z' '2026-05-08T10:20:00.000Z'
); );
const task = await readTask('alpha', 'work-task');
expect(batchSpy).toHaveBeenCalledTimes(1);
expect(batchSpy).toHaveBeenCalledWith('alpha', ['bob'], '2026-05-08T10:20:00.000Z');
expect(result.changedTasks).toBe(1); expect(result.changedTasks).toBe(1);
expect(task.workIntervals).toEqual([
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
{ startedAt: '2026-05-08T10:20:00.000Z' },
]);
}); });
}); });
}); });

View file

@ -129,6 +129,7 @@ vi.mock('@renderer/components/runtime/ProviderRuntimeBackendSelector', async ()
typeof import('@renderer/components/runtime/ProviderRuntimeBackendSelector') typeof import('@renderer/components/runtime/ProviderRuntimeBackendSelector')
>('@renderer/components/runtime/ProviderRuntimeBackendSelector'); >('@renderer/components/runtime/ProviderRuntimeBackendSelector');
return { return {
buildProviderRuntimeBackendSummaryText: actual.buildProviderRuntimeBackendSummaryText,
getProviderRuntimeBackendSummary: actual.getProviderRuntimeBackendSummary, getProviderRuntimeBackendSummary: actual.getProviderRuntimeBackendSummary,
}; };
}); });

View file

@ -341,6 +341,16 @@ describe('ProviderModelBadges', () => {
expect(host.textContent?.match(/Opus 4\.6/g)).toHaveLength(1); expect(host.textContent?.match(/Opus 4\.6/g)).toHaveLength(1);
}); });
it('does not render duplicate Anthropic Opus 4.8 model badges when the runtime reports the opus alias', () => {
const host = render(<ProviderModelBadges providerId="anthropic" models={['opus']} />);
const renderedModelLabels = Array.from(host.firstElementChild?.children ?? [])
.map((badge) => badge.firstElementChild?.textContent ?? '')
.filter(Boolean);
expect(renderedModelLabels.filter((label) => label === 'Opus 4.8')).toHaveLength(1);
expect(renderedModelLabels).toContain('Opus 4.8 (1M)');
});
it('collapses long model lists and expands them inline without an internal scroll area', () => { it('collapses long model lists and expands them inline without an internal scroll area', () => {
const models = Array.from( const models = Array.from(
{ length: 18 }, { length: 18 },

View file

@ -201,6 +201,19 @@ vi.mock('@renderer/components/ui/tabs', () => ({
})); }));
vi.mock('@renderer/components/runtime/ProviderRuntimeBackendSelector', () => ({ vi.mock('@renderer/components/runtime/ProviderRuntimeBackendSelector', () => ({
buildProviderRuntimeBackendSummaryText: () => ({
auto: 'Auto',
autoCurrently: (backend: string) => `Auto (currently: ${backend})`,
audienceInternal: 'Internal',
states: {
locked: 'Locked',
disabled: 'Disabled',
authRequired: 'Auth required',
runtimeMissing: 'Runtime missing',
degraded: 'Degraded',
unavailable: 'Unavailable',
},
}),
ProviderRuntimeBackendSelector: ({ ProviderRuntimeBackendSelector: ({
provider, provider,
onSelect, onSelect,

View file

@ -1,4 +1,5 @@
import { import {
getTeamModelBadgeLabel,
getVisibleTeamProviderModels, getVisibleTeamProviderModels,
isAnthropicOneMillionContextTeamModel, isAnthropicOneMillionContextTeamModel,
isAnthropicSonnetOneMillionContextTeamModel, isAnthropicSonnetOneMillionContextTeamModel,
@ -44,6 +45,22 @@ describe('teamModelCatalog', () => {
]); ]);
}); });
it('does not add duplicate Anthropic Opus 4.8 fallback badges when the runtime reports the opus alias', () => {
const models = getVisibleTeamProviderModels('anthropic', [
'opus',
'claude-opus-4-6',
'sonnet',
'haiku',
]);
expect(models).toContain('opus');
expect(models).not.toContain('claude-opus-4-8');
expect(models).toContain('claude-opus-4-8[1m]');
const labels = models.map((model) => getTeamModelBadgeLabel('anthropic', model));
expect(labels.filter((label) => label === 'Opus 4.8')).toHaveLength(1);
});
it('orders OpenCode free models before paid models', () => { it('orders OpenCode free models before paid models', () => {
expect( expect(
getVisibleTeamProviderModels( getVisibleTeamProviderModels(