diff --git a/client/src/components/cos/tabs/schedule/PipelineStageConfig.jsx b/client/src/components/cos/tabs/schedule/PipelineStageConfig.jsx index adbe63a50d..378b38232e 100644 --- a/client/src/components/cos/tabs/schedule/PipelineStageConfig.jsx +++ b/client/src/components/cos/tabs/schedule/PipelineStageConfig.jsx @@ -23,14 +23,17 @@ import { // Which providers on THIS install the stage's picker actually offers. The // posture half is server-published (`publicReviewPostures`, derived from the // vendor rows — no vendor names on the client); the rest is the picker's own -// visibility rule, reused so the "eligible" note can never list a provider -// the dropdown hides (switched off, hardware-incompatible), which is what left -// Stage 3 looking unconfigurable. +// visibility rule, reused so the notes below can never name a provider the +// dropdown hides (switched off, hardware-incompatible). The eligible set is +// not re-listed in the copy — the dropdown already IS that list. const eligibleProvidersFor = (providers, policy) => selectableProviders(providers, { allowed: policy.provider }); const providerNames = (providers) => providers.map((p) => p.name || p.id).join(', '); +// Constant now that the eligible set is left to the dropdown. +const NO_TOOL_STAGE_NOTE = "Tool-free stage. A local model must additionally report no tool-calling capability; a cloud model is held tool-free by the provider's own enforced flags. Leave the provider unset to use the first eligible one. It returns only a binary allowlist; the final stage never receives rejected content."; + // Every enabled CLI/TUI provider can run the actions stage; the note says which // of them the server additionally wraps in the vendor's own OS sandbox, so a // choice that relies on the disposable worktree alone is a visible one. @@ -39,13 +42,13 @@ const actionsStageNote = (eligibleProviders) => { const sandboxed = eligibleProviders.filter(isSandboxed); const worktreeOnly = eligibleProviders.filter((p) => !isSandboxed(p)); const isolation = worktreeOnly.length === 0 - ? ['Each runs headless inside its vendor\'s maintained OS sandbox.'] + ? ['Every selectable provider runs headless inside its vendor\'s maintained OS sandbox.'] : [ sandboxed.length > 0 && `OS-sandboxed by the vendor's own recipe: ${providerNames(sandboxed)}.`, `Headless with standard permissions, isolated by the disposable worktree only: ${providerNames(worktreeOnly)}.`, ].filter(Boolean); return [ - `Sandboxed stage. Eligible on this install: ${providerNames(eligibleProviders)}.`, + 'Sandboxed stage.', ...isolation, 'PortOS passes the selected provider, model, and thinking effort through, with no forge credential or configuration overlays; the deterministic coordinator owns comments, issue filing, CI triggers, and merges.', ].join(' '); @@ -233,7 +236,7 @@ export default function PipelineStageConfig({ taskType, config, providers, provi

{localModelsLoading ? 'Loading installed local model capability reports…' - : `Tool-free stage. Eligible on this install: ${eligibleProviders.map((p) => p.name || p.id).join(', ')}. A local model must additionally report no tool-calling capability; a cloud model is held tool-free by the provider's own enforced flags. Leave the provider unset to use the first eligible one. It returns only a binary allowlist; the final stage never receives rejected content.`} + : NO_TOOL_STAGE_NOTE}

)} {isActionsStage && eligibleProviders?.length > 0 && ( diff --git a/client/src/components/cos/tabs/schedule/PipelineStageConfig.test.jsx b/client/src/components/cos/tabs/schedule/PipelineStageConfig.test.jsx index 0b0c8d9dcc..8001d2612a 100644 --- a/client/src/components/cos/tabs/schedule/PipelineStageConfig.test.jsx +++ b/client/src/components/cos/tabs/schedule/PipelineStageConfig.test.jsx @@ -174,14 +174,16 @@ describe('PipelineStageConfig — posture-driven eligibility', () => { expect([...providerSelects[1].options].map((o) => o.value)).toEqual(['', 'grok-cli']); // A non-local provider's own catalog is selectable — the installed-local // model list only applies where PortOS can probe capabilities. - expect(screen.getAllByText(/Eligible on this install: Grok/).length).toBe(2); + expect(screen.getByText(/^Tool-free stage\./)).toBeInTheDocument(); + expect(screen.getByText(/^Sandboxed stage\./)).toBeInTheDocument(); }); // The bug behind #5906's blocked run: the CLI records were disabled and the // TUI records enabled, so the note listed three "eligible" providers while // the dropdown offered only the placeholder. Eligibility now follows what the - // server publishes for the ENABLED records — TUI siblings included. - it('lists only enabled providers as eligible, matching what the dropdown offers', () => { + // server publishes for the ENABLED records — TUI siblings included — and the + // dropdown is the only place that list is rendered. + it('offers only enabled providers in the dropdown, and names none of them in the note', () => { renderWith([ { id: 'codex', name: 'Codex CLI', type: 'cli', command: 'codex', enabled: false, models: ['gpt-5.6'], publicReviewPostures: ['no-tool', 'sandboxed-actions'] }, { id: 'codex-tui', name: 'Codex TUI', type: 'tui', command: 'codex', enabled: true, models: ['gpt-5.6'], publicReviewPostures: ['no-tool', 'sandboxed-actions'] }, @@ -189,9 +191,11 @@ describe('PipelineStageConfig — posture-driven eligibility', () => { ]); const providerSelects = screen.getAllByLabelText('Provider'); expect([...providerSelects[1].options].map((o) => o.value)).toEqual(['', 'codex-tui']); - const note = screen.getByText(/Sandboxed stage\. Eligible on this install:/); - expect(note.textContent).toContain('Eligible on this install: Codex TUI.'); + // The dropdown IS the eligible list; the note must not re-name providers, + // least of all the disabled ones. + const note = screen.getByText(/^Sandboxed stage\./); expect(note.textContent).not.toContain('Grok Build CLI'); + expect(note.textContent).not.toContain('Codex CLI'); }); it('warns instead of silently offering nothing when a stage has no eligible provider', () => { @@ -212,8 +216,10 @@ describe('PipelineStageConfig — posture-driven eligibility', () => { const providerSelects = screen.getAllByLabelText('Provider'); expect([...providerSelects[0].options].map((o) => o.value)).toEqual(['', 'codex-tui']); expect([...providerSelects[1].options].map((o) => o.value)).toEqual(['', 'codex-tui', 'opencode-tui']); - const note = screen.getByText(/Sandboxed stage\. Eligible on this install:/); - expect(note.textContent).toContain('Eligible on this install: Codex TUI, OpenCode TUI.'); + const note = screen.getByText(/^Sandboxed stage\./); + // The eligible set is the dropdown's job; the note only separates the + // vendor-sandboxed providers from the worktree-only ones. + expect(note.textContent).not.toContain('Eligible on this install'); expect(note.textContent).toContain("OS-sandboxed by the vendor's own recipe: Codex TUI."); expect(note.textContent).toContain('isolated by the disposable worktree only: OpenCode TUI.'); });