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';
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: {
enabled: boolean;
@ -19,6 +28,13 @@ export function shouldWatchTeamAgentRuntime(input: {
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 {
teamName: string;
enabled: boolean;
@ -54,12 +70,38 @@ export function useTeamAgentRuntimeWatcher({
});
if (!shouldWatch) return;
void fetchTeamAgentRuntime(teamName);
const timer = window.setInterval(() => {
void fetchTeamAgentRuntime(teamName);
}, TEAM_AGENT_RUNTIME_REFRESH_MS);
const existingEntry = teamAgentRuntimeWatchEntries.get(teamName);
if (existingEntry) {
existingEntry.refCount += 1;
return () => {
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 () => {
window.clearInterval(timer);
entry.refCount -= 1;
if (entry.refCount <= 0) {
window.clearInterval(entry.timer);
teamAgentRuntimeWatchEntries.delete(teamName);
}
};
}, [
effectiveIsTeamAlive,
@ -70,3 +112,21 @@ export function useTeamAgentRuntimeWatcher({
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, ...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(

View file

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

View file

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

View file

@ -1,4 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import type { Mock } from 'vitest';
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 { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
function createLogger(): LoggerPort & {
info: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
} {
type LoggerMock = LoggerPort & {
info: Mock<LoggerPort['info']>;
warn: Mock<LoggerPort['warn']>;
error: Mock<LoggerPort['error']>;
};
function createLogger(): LoggerMock {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
info: vi.fn<LoggerPort['info']>(),
warn: vi.fn<LoggerPort['warn']>(),
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 { 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 { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
function createLogger(): LoggerPort & {
info: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
} {
type LoggerMock = LoggerPort & {
info: Mock<LoggerPort['info']>;
warn: Mock<LoggerPort['warn']>;
error: Mock<LoggerPort['error']>;
};
function createLogger(): LoggerMock {
return {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
info: vi.fn<LoggerPort['info']>(),
warn: vi.fn<LoggerPort['warn']>(),
error: vi.fn<LoggerPort['error']>(),
};
}

View file

@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { Mock } from 'vitest';
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', () => {
let projectScanner: ProjectScanner;
let mockScan: ReturnType<typeof vi.fn>;
let mockSearchSessions: ReturnType<typeof vi.fn>;
let mockScan: Mock<() => Promise<Project[]>>;
let mockSearchSessions: Mock<
(projectId: string, query: string, maxResults?: number) => Promise<SearchSessionsResult>
>;
beforeEach(() => {
// Create a real ProjectScanner instance
projectScanner = new ProjectScanner();
// Mock the scan() method
mockScan = vi.fn();
mockScan = vi.fn<() => Promise<Project[]>>();
projectScanner.scan = mockScan;
// 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
projectScanner.sessionSearcher = {
searchSessions: mockSearchSessions,

View file

@ -80,6 +80,7 @@ vi.mock('@main/services/runtime/providerAwareCliEnv', () => ({
env: { HOME: '/Users/tester' },
connectionIssues: {},
})),
getProviderStatusStoredCredentialAllowlist: vi.fn(() => undefined),
}));
vi.mock('@main/utils/cliAuthDiagLog', () => ({
@ -542,7 +543,12 @@ describe('CliInstallerService', () => {
it('falls back to the installed launcher path when --version reports unknown', async () => {
allowConsoleLogs();
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)
.mockResolvedValueOnce({ stdout: 'unknown', stderr: '' })
.mockResolvedValueOnce({

View file

@ -25,6 +25,27 @@ import type {
const TASK_COMMENT_FORWARDING_ENV = 'CLAUDE_TEAM_TASK_COMMENT_FORWARDING';
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(
uuid: 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 = (
service as unknown as {
extractLeadSessionTextsFromJsonl: (
@ -4698,7 +4719,7 @@ describe('TeamDataService', () => {
leadName: string,
leadSessionId: string,
maxTexts: number
) => Promise<Array<{ text: string }>>;
) => Promise<InboxMessage[]>;
}
).extractLeadSessionTextsFromJsonl.bind(service);
@ -4730,11 +4751,11 @@ describe('TeamDataService', () => {
leadName: string,
leadSessionId: string,
maxTexts: number
) => Promise<Array<{ text: string }>>;
) => Promise<InboxMessage[]>;
}
).extractLeadAssistantTextsFromJsonlLines.bind(service);
const assistantSpy = vi
.spyOn(service as never, 'extractLeadAssistantTextsFromJsonlLines' as never)
.spyOn(teamDataServicePrivate(service), 'extractLeadAssistantTextsFromJsonlLines')
.mockImplementation(async (...args: unknown[]) => {
const [rawLines, leadName, leadSessionId, maxTexts] = args as [
readonly string[],
@ -4752,7 +4773,7 @@ describe('TeamDataService', () => {
leadName: string,
leadSessionId: string,
maxTexts: number
) => Promise<Array<{ text: string }>>;
) => Promise<InboxMessage[]>;
}
).extractLeadSessionTextsFromJsonl.bind(service);
@ -4782,12 +4803,12 @@ describe('TeamDataService', () => {
leadName: string,
leadSessionId: string,
maxTexts: number
) => Promise<Array<{ text: string }>>;
) => Promise<InboxMessage[]>;
}
).extractLeadAssistantTextsFromJsonlLines.bind(service);
let appended = false;
const assistantSpy = vi
.spyOn(service as never, 'extractLeadAssistantTextsFromJsonlLines' as never)
.spyOn(teamDataServicePrivate(service), 'extractLeadAssistantTextsFromJsonlLines')
.mockImplementation(async (...args: unknown[]) => {
const [rawLines, leadName, leadSessionId, maxTexts] = args as [
readonly string[],
@ -4818,7 +4839,7 @@ describe('TeamDataService', () => {
leadName: string,
leadSessionId: string,
maxTexts: number
) => Promise<Array<{ text: string }>>;
) => Promise<InboxMessage[]>;
}
).extractLeadSessionTextsFromJsonl.bind(service);
@ -4847,7 +4868,7 @@ describe('TeamDataService', () => {
leadName: string,
leadSessionId: string,
maxTexts: number
) => Promise<Array<{ text: string }>>;
) => Promise<InboxMessage[]>;
}
).extractLeadAssistantTextsFromJsonlLines.bind(service);
let releaseFirstInvocation = () => {};
@ -4856,7 +4877,7 @@ describe('TeamDataService', () => {
firstInvocationStartedResolve = resolve;
});
const assistantSpy = vi
.spyOn(service as never, 'extractLeadAssistantTextsFromJsonlLines' as never)
.spyOn(teamDataServicePrivate(service), 'extractLeadAssistantTextsFromJsonlLines')
.mockImplementation(async (...args: unknown[]) => {
const [rawLines, leadName, leadSessionId, maxTexts] = args as [
readonly string[],
@ -4879,7 +4900,7 @@ describe('TeamDataService', () => {
leadName: string,
leadSessionId: string,
maxTexts: number
) => Promise<Array<{ text: string }>>;
) => Promise<InboxMessage[]>;
}
).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 = (
service as unknown as {
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 = (
service as unknown as {
extractLeadSessionTextsFromJsonl: (
@ -4964,7 +4985,7 @@ describe('TeamDataService', () => {
leadName: string,
leadSessionId: string,
maxTexts: number
) => Promise<Array<{ text: string }>>;
) => Promise<InboxMessage[]>;
}
).extractLeadSessionTextsFromJsonl.bind(service);
@ -4988,7 +5009,7 @@ describe('TeamDataService', () => {
]);
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 = (
service as unknown as {
extractLeadSessionTextsFromJsonl: (
@ -4996,7 +5017,7 @@ describe('TeamDataService', () => {
leadName: string,
leadSessionId: string,
maxTexts: number
) => Promise<Array<{ text: string }>>;
) => Promise<InboxMessage[]>;
}
).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 = (
service as unknown as {
extractLeadSessionTextsFromJsonl: (
@ -5044,7 +5065,7 @@ describe('TeamDataService', () => {
leadName: string,
leadSessionId: string,
maxTexts: number
) => Promise<Array<{ text: string }>>;
) => Promise<InboxMessage[]>;
}
).extractLeadSessionTextsFromJsonl.bind(service);
@ -5073,12 +5094,12 @@ describe('TeamDataService', () => {
leadName: string,
leadSessionId: string,
maxTexts: number
) => Promise<Array<{ text: string }>>;
) => Promise<InboxMessage[]>;
}
).extractLeadAssistantTextsFromJsonlLines.bind(service);
let shouldFail = true;
const assistantSpy = vi
.spyOn(service as never, 'extractLeadAssistantTextsFromJsonlLines' as never)
.spyOn(teamDataServicePrivate(service), 'extractLeadAssistantTextsFromJsonlLines')
.mockImplementation(async (...args: unknown[]) => {
const [rawLines, leadName, leadSessionId, maxTexts] = args as [
readonly string[],
@ -5098,7 +5119,7 @@ describe('TeamDataService', () => {
leadName: string,
leadSessionId: string,
maxTexts: number
) => Promise<Array<{ text: string }>>;
) => Promise<InboxMessage[]>;
}
).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(
secondService as never,
'extractLeadAssistantTextsFromJsonlLines' as never
teamDataServicePrivate(secondService),
'extractLeadAssistantTextsFromJsonlLines'
);
const firstExtract = (
firstService as unknown as {
@ -5136,7 +5157,7 @@ describe('TeamDataService', () => {
leadName: string,
leadSessionId: string,
maxTexts: number
) => Promise<Array<{ text: string }>>;
) => Promise<InboxMessage[]>;
}
).extractLeadSessionTextsFromJsonl.bind(firstService);
const secondExtract = (
@ -5146,7 +5167,7 @@ describe('TeamDataService', () => {
leadName: string,
leadSessionId: string,
maxTexts: number
) => Promise<Array<{ text: string }>>;
) => Promise<InboxMessage[]>;
}
).extractLeadSessionTextsFromJsonl.bind(secondService);
@ -5177,10 +5198,10 @@ describe('TeamDataService', () => {
};
(service as unknown as { projectResolver: typeof 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']])
);
vi.spyOn(service as never, 'extractLeadSessionTextsFromJsonl' as never).mockResolvedValue([
vi.spyOn(teamDataServicePrivate(service), 'extractLeadSessionTextsFromJsonl').mockResolvedValue([
{
from: 'fast-lead',
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 =
projectResolver;
vi.spyOn(service as never, 'getLeadSessionJsonlPaths' as never).mockImplementation(
vi.spyOn(teamDataServicePrivate(service), 'getLeadSessionJsonlPaths').mockImplementation(
(...args: unknown[]) => {
const [projectDir] = args as [string];
if (projectDir === '/actual-project') {
@ -5237,7 +5258,7 @@ describe('TeamDataService', () => {
return Promise.resolve(new Map());
}
);
vi.spyOn(service as never, 'extractLeadSessionTextsFromJsonl' as never).mockResolvedValue([
vi.spyOn(teamDataServicePrivate(service), 'extractLeadSessionTextsFromJsonl').mockResolvedValue([
{
from: 'actual-lead',
text: 'Fallback path recovered lead thought from the repaired context.',
@ -5292,7 +5313,7 @@ describe('TeamDataService', () => {
};
(service as unknown as { projectResolver: typeof projectResolver }).projectResolver =
projectResolver;
vi.spyOn(service as never, 'getLeadSessionJsonlPaths' as never).mockImplementation(
vi.spyOn(teamDataServicePrivate(service), 'getLeadSessionJsonlPaths').mockImplementation(
(...args: unknown[]) => {
const [projectDir] = args as [string];
if (projectDir === '/current-project') {
@ -5304,7 +5325,7 @@ describe('TeamDataService', () => {
}
);
const extractSpy = vi
.spyOn(service as never, 'extractLeadSessionTextsFromJsonl' as never)
.spyOn(teamDataServicePrivate(service), 'extractLeadSessionTextsFromJsonl')
.mockResolvedValue([
{
from: 'current-lead',
@ -5366,11 +5387,11 @@ describe('TeamDataService', () => {
(service as unknown as { projectResolver: typeof projectResolver }).projectResolver =
projectResolver;
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-current', '/same-project/lead-current.jsonl']]));
const extractSpy = vi
.spyOn(service as never, 'extractLeadSessionTextsFromJsonl' as never)
.spyOn(teamDataServicePrivate(service), 'extractLeadSessionTextsFromJsonl')
.mockResolvedValue([
{
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',
text: 'Lead summary',
@ -6028,7 +6049,7 @@ describe('TeamDataService', () => {
resolveMembers: resolveMembersSpy,
});
vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockResolvedValue([
vi.spyOn(teamDataServicePrivate(harness.service), 'extractLeadSessionTexts').mockResolvedValue([
{
from: 'team-lead',
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');
throw new Error('lead sync fail');

View file

@ -72,7 +72,12 @@ function createStubbedServiceHarness() {
const service = new TeamMemberRuntimeAdvisoryService(logsFinder as never);
const advisoryByFilePath = new Map<string, MemberRuntimeAdvisory | null>();
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[]) => {
const filePath = String(args[0] ?? '');
if (advisoryByFilePath.has(filePath)) {

View file

@ -1169,11 +1169,14 @@ describe('TeamTaskActivityIntervalService', () => {
describe('resumeActiveIntervalsForMembers (batch)', () => {
it('returns zero changes for an empty members list without scanning the tasks directory', () => {
const service = new TeamTaskActivityIntervalService();
const mutateSpy = vi.spyOn(
const lockSpy = vi.spyOn(
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(
@ -1183,7 +1186,7 @@ describe('TeamTaskActivityIntervalService', () => {
);
expect(result).toEqual({ changedTasks: 0 });
expect(mutateSpy).not.toHaveBeenCalled();
expect(lockSpy).not.toHaveBeenCalled();
});
it('returns zero changes when all member names are blank', () => {
@ -1326,11 +1329,14 @@ describe('TeamTaskActivityIntervalService', () => {
});
const service = new TeamTaskActivityIntervalService();
const mutateSpy = vi.spyOn(
const lockSpy = vi.spyOn(
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(
@ -1339,7 +1345,7 @@ describe('TeamTaskActivityIntervalService', () => {
'2026-05-08T10:20:00.000Z'
);
expect(mutateSpy).toHaveBeenCalledTimes(1);
expect(lockSpy).toHaveBeenCalledTimes(1);
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', {
id: 'work-task',
subject: 'Build',
@ -1490,17 +1496,19 @@ describe('TeamTaskActivityIntervalService', () => {
});
const service = new TeamTaskActivityIntervalService();
const batchSpy = vi.spyOn(service, 'resumeActiveIntervalsForMembers');
const result = service.resumeActiveIntervalsForMember(
'alpha',
'bob',
'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(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')
>('@renderer/components/runtime/ProviderRuntimeBackendSelector');
return {
buildProviderRuntimeBackendSummaryText: actual.buildProviderRuntimeBackendSummaryText,
getProviderRuntimeBackendSummary: actual.getProviderRuntimeBackendSummary,
};
});

View file

@ -341,6 +341,16 @@ describe('ProviderModelBadges', () => {
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', () => {
const models = Array.from(
{ length: 18 },

View file

@ -201,6 +201,19 @@ vi.mock('@renderer/components/ui/tabs', () => ({
}));
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: ({
provider,
onSelect,

View file

@ -1,4 +1,5 @@
import {
getTeamModelBadgeLabel,
getVisibleTeamProviderModels,
isAnthropicOneMillionContextTeamModel,
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', () => {
expect(
getVisibleTeamProviderModels(