diff --git a/packages/harness-adapter/src/usage.ts b/packages/harness-adapter/src/usage.ts index c4c45abf..093efffc 100644 --- a/packages/harness-adapter/src/usage.ts +++ b/packages/harness-adapter/src/usage.ts @@ -113,18 +113,12 @@ export function parseHostUsage(value: unknown): HostUsage { if (hasContextWindow && value.contextWindowTokens === 0) { throw new Error("Harness Usage 'contextWindowTokens' must be greater than zero"); } - if ( - value.planFiveHourResetsAtUnix !== undefined && - value.planFiveHourUsedPercent === undefined - ) { + if (value.planFiveHourResetsAtUnix !== undefined && value.planFiveHourUsedPercent === undefined) { throw new Error( "Harness Usage 'planFiveHourResetsAtUnix' must be provided with 'planFiveHourUsedPercent'", ); } - if ( - value.planSevenDayResetsAtUnix !== undefined && - value.planSevenDayUsedPercent === undefined - ) { + if (value.planSevenDayResetsAtUnix !== undefined && value.planSevenDayUsedPercent === undefined) { throw new Error( "Harness Usage 'planSevenDayResetsAtUnix' must be provided with 'planSevenDayUsedPercent'", ); diff --git a/packages/host-runtime/test/app-server-host.test.ts b/packages/host-runtime/test/app-server-host.test.ts index ba8ca8e1..397becb5 100644 --- a/packages/host-runtime/test/app-server-host.test.ts +++ b/packages/host-runtime/test/app-server-host.test.ts @@ -2208,9 +2208,7 @@ describe("AppServerHost HarnessAdapter projection", () => { method: "codexhost/thread/usage/inspect", params: { threadId: claudeThreadId }, }); - await expect( - fixture.collector.waitFor((message) => requestId(message, 72)), - ).resolves.toEqual({ + await expect(fixture.collector.waitFor((message) => requestId(message, 72))).resolves.toEqual({ id: 72, result: { threadId: claudeThreadId, diff --git a/packages/renderer-extension/src/renderer-binding-probe.ts b/packages/renderer-extension/src/renderer-binding-probe.ts index 5940fac3..d71c2fb1 100644 --- a/packages/renderer-extension/src/renderer-binding-probe.ts +++ b/packages/renderer-extension/src/renderer-binding-probe.ts @@ -90,10 +90,43 @@ const externalAgents: readonly ExternalRendererAgent[] = [ "grok", ]; type HarnessAvailability = Partial>; +type HarnessAvailabilityErrors = Record; + +function isRetryableHarnessAvailability( + availability: RendererAgentAvailability | undefined, + error: CodexhostError | undefined, +): boolean { + return ( + availability !== undefined && + availability !== "ready" && + availability !== "notInstalled" && + error?.retryable === true + ); +} + +export function retryableHarnessAvailabilityAgents( + availability: HarnessAvailability, + errors: HarnessAvailabilityErrors, +): ExternalRendererAgent[] { + return externalAgents.filter((agent) => + isRetryableHarnessAvailability(availability[agent], errors[agent]), + ); +} + +export function passiveHarnessAvailabilityAgents( + availability: HarnessAvailability, + errors: HarnessAvailabilityErrors, +): ExternalRendererAgent[] { + return externalAgents.filter( + (agent) => + availability[agent] === "checking" || + isRetryableHarnessAvailability(availability[agent], errors[agent]), + ); +} interface HostHarnessAvailabilityState { availability: HarnessAvailability; - errors: Record; + errors: HarnessAvailabilityErrors; requestGeneration: number; request: { client: RendererModelClient; promise: Promise } | null; retryTimer: number | null; @@ -1581,6 +1614,7 @@ export function installRendererBindingProbe( hostId: string, refresh = false, retry = false, + force = false, ): Promise { const state = hostHarnessAvailabilityState(hostId); if (!retry) resetHarnessAvailabilityRetry(hostId); @@ -1590,12 +1624,18 @@ export function installRendererBindingProbe( return Promise.resolve(); } if (state.request?.client === client) return state.request.promise; - state.availability = Object.fromEntries( - externalAgents.map((agent) => [ - agent, - state.availability[agent] === "ready" ? "ready" : "checking", - ]), - ) as HarnessAvailability; + const agentsToInspect = force + ? externalAgents + : passiveHarnessAvailabilityAgents(state.availability, state.errors); + if (agentsToInspect.length === 0) { + resetHarnessAvailabilityRetry(hostId); + return Promise.resolve(); + } + const nextAvailability = { ...state.availability }; + for (const agent of agentsToInspect) { + if (nextAvailability[agent] !== "ready") nextAvailability[agent] = "checking"; + } + state.availability = nextAvailability; if (hostId === activeAvailabilityHostId) { publishConnectionStatus(); for (const mounted of mountedByComposer.values()) renderMounted(mounted); @@ -1603,7 +1643,7 @@ export function installRendererBindingProbe( const generation = ++state.requestGeneration; const promise = (async () => { await Promise.all( - externalAgents.map(async (agent) => { + agentsToInspect.map(async (agent) => { let status: RendererAgentAvailability = "error"; let nextError: CodexhostError | undefined; try { @@ -1670,7 +1710,7 @@ export function installRendererBindingProbe( }), ); if (generation !== state.requestGeneration || disposed) return; - if (externalAgents.every((agent) => state.availability[agent] === "ready")) { + if (retryableHarnessAvailabilityAgents(state.availability, state.errors).length === 0) { resetHarnessAvailabilityRetry(hostId); } else { scheduleHarnessAvailabilityRetry(hostId); @@ -1761,7 +1801,7 @@ export function installRendererBindingProbe( }, refresh(): Promise { for (const hostId of harnessAvailabilityByHost.keys()) { - void refreshHarnessAvailabilityForHost(hostId, true); + void refreshHarnessAvailabilityForHost(hostId, true, false, true); } return Promise.resolve(); }, diff --git a/packages/renderer-extension/src/renderer-settings-lifecycle.ts b/packages/renderer-extension/src/renderer-settings-lifecycle.ts index ebe10b91..e8076c7e 100644 --- a/packages/renderer-extension/src/renderer-settings-lifecycle.ts +++ b/packages/renderer-extension/src/renderer-settings-lifecycle.ts @@ -164,6 +164,8 @@ export function installRendererSettingsLifecycle( clearUpdateRetry(); retryUpdateClient = client; updateRetryAttempt = 0; + } else if (updateRetryTimer !== null) { + return; } checkedUpdateClient = client; const generation = ++updateCheckGeneration; diff --git a/packages/renderer-extension/src/renderer-usage-control.ts b/packages/renderer-extension/src/renderer-usage-control.ts index 2ec26ab0..af28ab65 100644 --- a/packages/renderer-extension/src/renderer-usage-control.ts +++ b/packages/renderer-extension/src/renderer-usage-control.ts @@ -129,8 +129,7 @@ export function applyRendererPopoverChrome(popover: HTMLElement): void { popover.style.borderRadius = "14px"; popover.style.backgroundColor = "color-mix(in srgb, Canvas 92%, CanvasText 8%)"; popover.style.color = "CanvasText"; - popover.style.boxShadow = - "0 20px 45px rgba(0, 0, 0, 0.35), 0 2px 10px rgba(0, 0, 0, 0.22)"; + popover.style.boxShadow = "0 20px 45px rgba(0, 0, 0, 0.35), 0 2px 10px rgba(0, 0, 0, 0.22)"; } function addDetailRow(parent: HTMLElement, label: string, value: string): void { diff --git a/packages/renderer-extension/test/renderer-binding-probe.test.ts b/packages/renderer-extension/test/renderer-binding-probe.test.ts index fc124dde..e1b1a7d8 100644 --- a/packages/renderer-extension/test/renderer-binding-probe.test.ts +++ b/packages/renderer-extension/test/renderer-binding-probe.test.ts @@ -15,7 +15,9 @@ import { isComposerModelWriteAllowed, isOwnershipSubmissionBlocked, lateConversationTargetResolution, + passiveHarnessAvailabilityAgents, restoredThreadOwnership, + retryableHarnessAvailabilityAgents, rendererUsageRefreshDelay, shouldApplyDraftAgentCarrier, shouldPersistNewThreadConfigurationSelection, @@ -44,6 +46,132 @@ import { } from "../src/renderer-usage-control.js"; describe("Renderer Composer DOM behavior", () => { + it("does not re-probe ready Agents when an optional Harness is not installed", () => { + expect( + retryableHarnessAvailabilityAgents( + { + pi: "ready", + "claude-code": "ready", + "deepseek-harness": "notInstalled", + grok: "ready", + }, + { + pi: undefined, + "claude-code": undefined, + "deepseek-harness": { + code: "notInstalled", + message: "DeepSeek Harness is not installed", + retryable: false, + }, + grok: undefined, + }, + ), + ).toEqual([]); + + expect( + retryableHarnessAvailabilityAgents( + { + pi: "ready", + "claude-code": "ready", + "deepseek-harness": "error", + grok: "ready", + }, + { + pi: undefined, + "claude-code": undefined, + "deepseek-harness": { + code: "internalError", + message: "Remote request manager is temporarily unavailable", + retryable: true, + }, + grok: undefined, + }, + ), + ).toEqual(["deepseek-harness"]); + + expect( + retryableHarnessAvailabilityAgents( + { + pi: "ready", + "claude-code": "ready", + "deepseek-harness": "unavailable", + grok: "ready", + }, + { + pi: undefined, + "claude-code": undefined, + "deepseek-harness": { + code: "unavailable", + message: "DeepSeek Harness is temporarily unavailable", + retryable: true, + }, + grok: undefined, + }, + ), + ).toEqual(["deepseek-harness"]); + }); + + it("keeps terminal Harness availability stable across passive focus refreshes", () => { + expect( + passiveHarnessAvailabilityAgents( + { + pi: "checking", + "claude-code": "checking", + "deepseek-harness": "checking", + grok: "checking", + }, + { + pi: undefined, + "claude-code": undefined, + "deepseek-harness": undefined, + grok: undefined, + }, + ), + ).toEqual(["pi", "claude-code", "deepseek-harness", "grok"]); + + expect( + passiveHarnessAvailabilityAgents( + { + pi: "ready", + "claude-code": "ready", + "deepseek-harness": "notInstalled", + grok: "ready", + }, + { + pi: undefined, + "claude-code": undefined, + "deepseek-harness": { + code: "notInstalled", + message: "DeepSeek Harness is not installed", + retryable: false, + }, + grok: undefined, + }, + ), + ).toEqual([]); + + expect( + passiveHarnessAvailabilityAgents( + { + pi: "ready", + "claude-code": "ready", + "deepseek-harness": "unavailable", + grok: "ready", + }, + { + pi: undefined, + "claude-code": undefined, + "deepseek-harness": { + code: "unavailable", + message: "DeepSeek Harness is temporarily unavailable", + retryable: true, + }, + grok: undefined, + }, + ), + ).toEqual(["deepseek-harness"]); + }); + it("keeps a ready external Model catalog stable during repeated availability checks", () => { expect(shouldReloadExternalCatalogAfterAvailabilityRefresh("ready", "ready", true)).toBe(false); expect(shouldReloadExternalCatalogAfterAvailabilityRefresh("ready", "ready", false)).toBe(true); diff --git a/packages/renderer-extension/test/renderer-settings-lifecycle.test.ts b/packages/renderer-extension/test/renderer-settings-lifecycle.test.ts new file mode 100644 index 00000000..c712b4fa --- /dev/null +++ b/packages/renderer-extension/test/renderer-settings-lifecycle.test.ts @@ -0,0 +1,95 @@ +import type { UpdateCheckResult } from "@codexhost/shared-contracts"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const triggerRefresh = vi.fn(() => true); +const triggerSetUpdateAvailable = vi.fn(); + +vi.mock("../src/codex-locale-adapter.js", () => ({ + readCodexLocaleSettings: vi.fn(async () => ({ preferredLocale: "en" })), +})); + +vi.mock("../src/settings/localization.js", () => ({ + rendererSettingsMessages: vi.fn(() => ({})), + resolveRendererSettingsLocale: vi.fn(() => "en"), +})); + +vi.mock("../src/settings/pages.js", () => ({ + createDefaultRendererSettingsPages: vi.fn(() => []), +})); + +vi.mock("../src/settings/shell.js", () => ({ + installRendererSettingsShell: vi.fn(() => ({ + supported: true, + open: false, + activePageId: undefined, + openSettings: vi.fn(), + dispose: vi.fn(), + })), +})); + +vi.mock("../src/settings/trigger.js", () => ({ + installRendererSettingsHeaderTrigger: vi.fn(() => ({ + root: null, + refresh: triggerRefresh, + setUpdateAvailable: triggerSetUpdateAvailable, + dispose: vi.fn(), + })), +})); + +import { installRendererSettingsLifecycle } from "../src/renderer-settings-lifecycle.js"; + +function failedUpdateCheck(): UpdateCheckResult { + return { + currentVersion: "0.3.2", + installation: "npm", + latestVersion: null, + updateAvailable: false, + installationAvailable: false, + releaseNotes: null, + releaseNotesUrl: null, + status: null, + error: "Update metadata is temporarily unavailable", + }; +} + +describe("Renderer Settings lifecycle", () => { + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it("does not bypass update backoff when DOM reconciliation refreshes repeatedly", async () => { + vi.useFakeTimers(); + const checkUpdate = vi.fn(async () => failedUpdateCheck()); + const client = { + checkUpdate, + startUpdate: vi.fn(), + readUpdateStatus: vi.fn(), + }; + const ownerWindow = { + navigator: { languages: ["en"] }, + document: {}, + setTimeout, + clearTimeout, + } as unknown as Window; + + const lifecycle = installRendererSettingsLifecycle(ownerWindow, { + getUpdateClient: () => client, + }); + await Promise.resolve(); + await Promise.resolve(); + expect(checkUpdate).toHaveBeenCalledTimes(1); + + for (let index = 0; index < 50; index += 1) lifecycle.refresh(); + await Promise.resolve(); + await Promise.resolve(); + expect(checkUpdate).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(999); + expect(checkUpdate).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + expect(checkUpdate).toHaveBeenCalledTimes(2); + + lifecycle.dispose(); + }); +}); diff --git a/packages/shared-contracts/src/thread-usage.ts b/packages/shared-contracts/src/thread-usage.ts index 7c825d26..bede1b0e 100644 --- a/packages/shared-contracts/src/thread-usage.ts +++ b/packages/shared-contracts/src/thread-usage.ts @@ -45,17 +45,25 @@ export const threadUsageSnapshotSchema = z path: ["contextWindowTokens"], }); } - if (usage.planFiveHourResetsAtUnix !== undefined && usage.planFiveHourUsedPercent === undefined) { + if ( + usage.planFiveHourResetsAtUnix !== undefined && + usage.planFiveHourUsedPercent === undefined + ) { context.addIssue({ code: "custom", - message: "Thread Usage planFiveHourResetsAtUnix must be provided with planFiveHourUsedPercent", + message: + "Thread Usage planFiveHourResetsAtUnix must be provided with planFiveHourUsedPercent", path: ["planFiveHourResetsAtUnix"], }); } - if (usage.planSevenDayResetsAtUnix !== undefined && usage.planSevenDayUsedPercent === undefined) { + if ( + usage.planSevenDayResetsAtUnix !== undefined && + usage.planSevenDayUsedPercent === undefined + ) { context.addIssue({ code: "custom", - message: "Thread Usage planSevenDayResetsAtUnix must be provided with planSevenDayUsedPercent", + message: + "Thread Usage planSevenDayResetsAtUnix must be provided with planSevenDayUsedPercent", path: ["planSevenDayResetsAtUnix"], }); }