diff --git a/src/app-server/services/threads.ts b/src/app-server/services/threads.ts index 33be5c85..6ef6decf 100644 --- a/src/app-server/services/threads.ts +++ b/src/app-server/services/threads.ts @@ -225,6 +225,9 @@ export function threadActivationSnapshotFromAppServerResponse(response: ThreadAc return { thread: threadFromThreadRecord(response.thread), cwd: response.cwd, + approvalPolicyKnown: "approvalPolicy" in response, + sandboxPolicyKnown: "sandbox" in response || "sandboxPolicy" in response, + permissionProfileKnown: "activePermissionProfile" in response, model: response.model, reasoningEffort: normalizeReasoningEffort(response.reasoningEffort), serviceTier: parseServiceTier(response.serviceTier), diff --git a/src/domain/runtime/permissions.ts b/src/domain/runtime/permissions.ts index 7a56e01e..0cb2e237 100644 --- a/src/domain/runtime/permissions.ts +++ b/src/domain/runtime/permissions.ts @@ -38,6 +38,12 @@ export interface RuntimePermissionState { readonly activePermissionProfile: RuntimeActivePermissionProfile | null; } +export interface RuntimePermissionKnownState { + readonly approvalPolicyKnown: boolean; + readonly sandboxPolicyKnown: boolean; + readonly permissionProfileKnown: boolean; +} + export function initialRuntimePermissionState(): RuntimePermissionState { return { approvalPolicy: null, @@ -46,6 +52,14 @@ export function initialRuntimePermissionState(): RuntimePermissionState { }; } +export function initialRuntimePermissionKnownState(): RuntimePermissionKnownState { + return { + approvalPolicyKnown: false, + sandboxPolicyKnown: false, + permissionProfileKnown: false, + }; +} + export function runtimePermissionStateOrDefault(value: Partial | null | undefined): RuntimePermissionState { return { approvalPolicy: value?.approvalPolicy ? cloneRuntimeApprovalPolicy(value.approvalPolicy) : null, diff --git a/src/domain/threads/activation.ts b/src/domain/threads/activation.ts index b84e2546..985b58ce 100644 --- a/src/domain/threads/activation.ts +++ b/src/domain/threads/activation.ts @@ -1,9 +1,9 @@ import type { ReasoningEffort } from "../catalog/metadata"; -import type { RuntimePermissionState } from "../runtime/permissions"; +import type { RuntimePermissionKnownState, RuntimePermissionState } from "../runtime/permissions"; import type { ApprovalsReviewer, ServiceTier } from "../runtime/policy"; import type { Thread } from "./model"; -export interface ThreadActivationSnapshot extends RuntimePermissionState { +export interface ThreadActivationSnapshot extends RuntimePermissionState, RuntimePermissionKnownState { thread: Thread; cwd: string; model: string | null; diff --git a/src/features/chat/application/conversation/plan-implementation.ts b/src/features/chat/application/conversation/plan-implementation.ts index bbf4a695..aa5af45d 100644 --- a/src/features/chat/application/conversation/plan-implementation.ts +++ b/src/features/chat/application/conversation/plan-implementation.ts @@ -20,7 +20,12 @@ export function implementPlanTargetFromState(state: { runtime: { pending: Pick }; messageStream: Pick; }): PlanImplementationTarget | null { - if (!state.activeThread.id || chatTurnBusy(state) || state.runtime.pending.collaborationMode !== "plan") { + if ( + !state.activeThread.id || + chatTurnBusy(state) || + state.runtime.pending.collaborationMode.kind !== "set" || + state.runtime.pending.collaborationMode.value !== "plan" + ) { return null; } return latestImplementablePlanTargetFromItems(messageStreamItems(state.messageStream)); diff --git a/src/features/chat/application/runtime/settings-actions.ts b/src/features/chat/application/runtime/settings-actions.ts index d3871e68..2994f2e4 100644 --- a/src/features/chat/application/runtime/settings-actions.ts +++ b/src/features/chat/application/runtime/settings-actions.ts @@ -153,8 +153,8 @@ async function setFastMode(host: RuntimeSettingsActionsHost, mode: FastModeState } async function toggleCollaborationMode(host: RuntimeSettingsActionsHost): Promise { - const current = state(host); - const next = nextCollaborationMode(current.runtime.pending.collaborationMode); + const { snapshot, config } = runtimeProjection(host); + const next = nextCollaborationMode(resolveRuntimeControls(snapshot, config).collaborationMode.effective); await setCollaborationMode(host, next); } diff --git a/src/features/chat/application/state/actions.ts b/src/features/chat/application/state/actions.ts index e08706e3..4fdc20d9 100644 --- a/src/features/chat/application/state/actions.ts +++ b/src/features/chat/application/state/actions.ts @@ -1,5 +1,9 @@ import { normalizeReasoningEffort, type ReasoningEffort } from "../../../../domain/catalog/metadata"; -import { type RuntimePermissionState, runtimePermissionStateOrDefault } from "../../../../domain/runtime/permissions"; +import { + type RuntimePermissionKnownState, + type RuntimePermissionState, + runtimePermissionStateOrDefault, +} from "../../../../domain/runtime/permissions"; import { parseServiceTier, type ServiceTier } from "../../../../domain/runtime/policy"; import type { ServerInitialization } from "../../../../domain/server/initialization"; import type { ThreadActivationSnapshot } from "../../../../domain/threads/activation"; @@ -27,6 +31,9 @@ interface ResumedThreadFromActiveRuntimeParams { | "serviceTier" | "serviceTierKnown" | "approvalsReviewer" + | "approvalPolicyKnown" + | "sandboxPolicyKnown" + | "permissionProfileKnown" | "approvalPolicy" | "sandboxPolicy" | "activePermissionProfile" @@ -35,7 +42,7 @@ interface ResumedThreadFromActiveRuntimeParams { items?: readonly MessageStreamItem[]; } -export interface ActiveThreadResumedAction extends RuntimePermissionState { +export interface ActiveThreadResumedAction extends RuntimePermissionState, RuntimePermissionKnownState { type: "active-thread/resumed"; thread: Thread; cwd: string; @@ -50,7 +57,7 @@ export interface ActiveThreadResumedAction extends RuntimePermissionState { preserveRequestedRuntimeSettings?: boolean; } -export interface ActiveThreadSettingsAppliedAction extends RuntimePermissionState { +export interface ActiveThreadSettingsAppliedAction extends RuntimePermissionState, RuntimePermissionKnownState { type: "active-thread/settings-applied"; cwd: string; model: string | null; @@ -121,6 +128,9 @@ export function resumedThreadActionFromActiveRuntime(params: ResumedThreadFromAc response: { thread: params.thread, cwd: params.cwd, + approvalPolicyKnown: params.runtime.approvalPolicyKnown, + sandboxPolicyKnown: params.runtime.sandboxPolicyKnown, + permissionProfileKnown: params.runtime.permissionProfileKnown, model: params.runtime.model, reasoningEffort: params.runtime.reasoningEffort, serviceTier: params.runtime.serviceTier, @@ -147,6 +157,9 @@ export function resumedThreadAction(params: ResumedThreadActionParams): ActiveTh serviceTier: response.serviceTier, serviceTierKnown: params.serviceTierKnown ?? true, approvalsReviewer: response.approvalsReviewer, + approvalPolicyKnown: response.approvalPolicyKnown, + sandboxPolicyKnown: response.sandboxPolicyKnown, + permissionProfileKnown: response.permissionProfileKnown, ...permissions, ...(params.items ? { items: params.items } : {}), ...(params.listedThreads ? { listedThreads: upsertThread(params.listedThreads, response.thread) } : {}), @@ -164,6 +177,9 @@ export function activeThreadSettingsAppliedAction(settings: ActiveThreadSettings collaborationMode: settings.collaborationMode.mode, serviceTier: parseServiceTier(settings.serviceTier), approvalsReviewer: settings.approvalsReviewer, + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, ...permissions, }; } diff --git a/src/features/chat/application/state/root-reducer.ts b/src/features/chat/application/state/root-reducer.ts index c420827b..9a789067 100644 --- a/src/features/chat/application/state/root-reducer.ts +++ b/src/features/chat/application/state/root-reducer.ts @@ -328,6 +328,9 @@ function reduceActiveThreadResumedTransition(state: ChatState, action: ActiveThr collaborationMode: initialActiveChatRuntimeState().collaborationMode, serviceTier: action.serviceTier, approvalsReviewer: action.approvalsReviewer, + approvalPolicyKnown: action.approvalPolicyKnown, + sandboxPolicyKnown: action.sandboxPolicyKnown, + permissionProfileKnown: action.permissionProfileKnown, approvalPolicy: action.approvalPolicy, sandboxPolicy: action.sandboxPolicy, activePermissionProfile: action.activePermissionProfile, @@ -356,13 +359,16 @@ function reduceActiveThreadSettingsAppliedTransition(state: ChatState, action: A collaborationMode: action.collaborationMode, serviceTier: action.serviceTier, approvalsReviewer: action.approvalsReviewer, + approvalPolicyKnown: action.approvalPolicyKnown, + sandboxPolicyKnown: action.sandboxPolicyKnown, + permissionProfileKnown: action.permissionProfileKnown, approvalPolicy: action.approvalPolicy, sandboxPolicy: action.sandboxPolicy, activePermissionProfile: action.activePermissionProfile, }, pending: { ...state.runtime.pending, - collaborationMode: action.collaborationMode, + collaborationMode: { kind: "unchanged" }, }, }, }); diff --git a/src/features/chat/domain/runtime/intent.ts b/src/features/chat/domain/runtime/intent.ts index 4825577a..210e7754 100644 --- a/src/features/chat/domain/runtime/intent.ts +++ b/src/features/chat/domain/runtime/intent.ts @@ -2,6 +2,7 @@ import type { ModeKind } from "../../../../domain/runtime/thread-settings"; export type CollaborationModeSelection = ModeKind; export type ActiveCollaborationMode = CollaborationModeSelection | null; +export type CollaborationModeIntent = { readonly kind: "unchanged" } | { readonly kind: "set"; readonly value: CollaborationModeSelection }; // Pending runtime intents are panel-side user requests, not app-server protocol values. // They are projected for display and reduced into panel-owned runtime settings patches before app-server protocol adaptation. @@ -23,6 +24,14 @@ export function resetRuntimeIntentToConfig(): PendingRuntimeIntent { return { kind: "resetToConfig" }; } +export function unchangedCollaborationModeIntent(): CollaborationModeIntent { + return { kind: "unchanged" }; +} + +export function setCollaborationModeIntent(value: CollaborationModeSelection): CollaborationModeIntent { + return { kind: "set", value }; +} + export function nextCollaborationMode(mode: CollaborationModeSelection): CollaborationModeSelection { return mode === "plan" ? "default" : "plan"; } @@ -30,3 +39,8 @@ export function nextCollaborationMode(mode: CollaborationModeSelection): Collabo export function effectiveCollaborationMode(mode: ActiveCollaborationMode): CollaborationModeSelection { return mode ?? "default"; } + +export function collaborationModeIntentValue(intent: CollaborationModeIntent, active: ActiveCollaborationMode): CollaborationModeSelection { + if (intent.kind === "set") return intent.value; + return effectiveCollaborationMode(active); +} diff --git a/src/features/chat/domain/runtime/resolution.ts b/src/features/chat/domain/runtime/resolution.ts index 7b765eb3..ab55dc7c 100644 --- a/src/features/chat/domain/runtime/resolution.ts +++ b/src/features/chat/domain/runtime/resolution.ts @@ -1,9 +1,16 @@ import type { ReasoningEffort } from "../../../../domain/catalog/metadata"; import { findModelMetadataByIdOrName, type ModelMetadata, supportedEffortsForModelMetadata } from "../../../../domain/catalog/metadata"; import type { RuntimeConfigSnapshot } from "../../../../domain/runtime/config"; -import { cloneRuntimePermissionState, type RuntimePermissionState } from "../../../../domain/runtime/permissions"; +import { cloneRuntimePermissionState, type RuntimeApprovalPolicy, type RuntimeSandboxPolicy } from "../../../../domain/runtime/permissions"; import type { ApprovalsReviewer, ServiceTier } from "../../../../domain/runtime/policy"; -import { effectiveCollaborationMode, type PendingRuntimeIntent, type RequestedFastMode } from "./intent"; +import { + effectiveCollaborationMode, + type PendingRuntimeIntent, + type RequestedFastMode, + resetRuntimeIntentToConfig, + setRuntimeIntentValue, + unchangedRuntimeIntent, +} from "./intent"; import type { RuntimeSnapshot } from "./snapshot"; type RuntimeValueSource = "pending" | "active-thread" | "config" | "none"; @@ -12,6 +19,18 @@ interface RuntimeLayeredValue { readonly configured: T | null; readonly active: T | null; readonly pending: PendingRuntimeIntent; + readonly confirmed: T | null; + readonly confirmedSource: RuntimeValueSource; + readonly effective: T | null; + readonly source: RuntimeValueSource; +} + +interface RuntimeNullableLayeredValue { + readonly configured: T | null; + readonly active: T | null; + readonly pending: PendingRuntimeIntent; + readonly confirmed: T | null; + readonly confirmedSource: RuntimeValueSource; readonly effective: T | null; readonly source: RuntimeValueSource; } @@ -19,32 +38,30 @@ interface RuntimeLayeredValue { interface FastModeResolution { readonly requested: PendingRuntimeIntent; readonly active: boolean; + readonly confirmedActive: boolean; readonly source: RuntimeValueSource; + readonly confirmedSource: RuntimeValueSource; readonly effectiveServiceTier: ServiceTier | null; + readonly confirmedServiceTier: ServiceTier | null; readonly serviceTierRequestValue: string; } interface AutoReviewResolution { readonly active: boolean; + readonly confirmedActive: boolean; readonly source: RuntimeValueSource; + readonly confirmedSource: RuntimeValueSource; } interface CollaborationModeResolution { readonly active: RuntimeSnapshot["active"]["collaborationMode"]; - readonly selected: RuntimeSnapshot["pending"]["collaborationMode"]; - readonly effective: RuntimeSnapshot["pending"]["collaborationMode"]; + readonly pending: RuntimeSnapshot["pending"]["collaborationMode"]; + readonly confirmed: NonNullable; + readonly effective: NonNullable; readonly dirty: boolean; readonly blockedReason: "missing-model" | null; } -interface RuntimePermissionsResolution { - readonly scope: "new-thread" | "current-thread"; - readonly configured: RuntimePermissionState; - readonly active: RuntimePermissionState | null; - readonly effective: RuntimePermissionState; - readonly source: RuntimeValueSource; -} - export interface RuntimeControlsResolution { readonly model: RuntimeLayeredValue; readonly reasoningEffort: RuntimeLayeredValue; @@ -52,7 +69,9 @@ export interface RuntimeControlsResolution { readonly serviceTier: RuntimeLayeredValue; readonly fastMode: FastModeResolution; readonly collaborationMode: CollaborationModeResolution; - readonly permissions: RuntimePermissionsResolution; + readonly permissionProfile: RuntimeLayeredValue; + readonly sandboxPolicy: RuntimeNullableLayeredValue; + readonly approvalPolicy: RuntimeLayeredValue; readonly approvalsReviewer: RuntimeLayeredValue; readonly supportedReasoningEfforts: readonly ReasoningEffort[]; } @@ -77,7 +96,19 @@ export function resolveRuntimeControls(snapshot: RuntimeSnapshot, config: Runtim const serviceTier = resolveServiceTier(snapshot, config); const fastMode = resolveFastMode(snapshot.pending.fastMode, serviceTier, serviceTiers); const collaborationMode = resolveCollaborationMode(snapshot, model.effective); - const permissions = resolveRuntimePermissions(snapshot, config); + const permissionProfile = resolveRuntimePermissionValue({ + configured: config.startupPermissions.activePermissionProfile?.id ?? null, + active: snapshot.active.activePermissionProfile?.id ?? null, + pending: snapshot.pending.permissionProfile, + activeKnown: snapshot.activeThreadId !== null && snapshot.active.permissionProfileKnown, + }); + const approvalPolicy = resolveRuntimePermissionValue({ + configured: config.startupPermissions.approvalPolicy, + active: snapshot.active.approvalPolicy, + pending: snapshot.pending.approvalPolicy, + activeKnown: snapshot.activeThreadId !== null && snapshot.active.approvalPolicyKnown, + }); + const sandboxPolicy = resolveRuntimeSandboxPolicy(snapshot, config); const autoReview = resolveAutoReview(reviewer); return { @@ -87,7 +118,9 @@ export function resolveRuntimeControls(snapshot: RuntimeSnapshot, config: Runtim serviceTier, fastMode, collaborationMode, - permissions, + permissionProfile, + sandboxPolicy, + approvalPolicy, approvalsReviewer: reviewer, supportedReasoningEfforts: supportedEffortsForModelMetadata(findModelMetadataByIdOrName(snapshot.availableModels, model.effective)), }; @@ -96,78 +129,86 @@ export function resolveRuntimeControls(snapshot: RuntimeSnapshot, config: Runtim function resolveAutoReview(reviewer: RuntimeLayeredValue): AutoReviewResolution { return { active: reviewer.effective === "auto_review" || reviewer.effective === "guardian_subagent", + confirmedActive: reviewer.confirmed === "auto_review" || reviewer.confirmed === "guardian_subagent", source: reviewer.source, + confirmedSource: reviewer.confirmedSource, }; } -function resolveRuntimePermissions(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): RuntimePermissionsResolution { - const configured = cloneRuntimePermissionState(config.startupPermissions); - const scope = snapshot.activeThreadId ? "current-thread" : "new-thread"; - const baseSource = snapshot.activeThreadId ? "active-thread" : "config"; - if (snapshot.activeThreadId) { - const active = cloneRuntimePermissionState(snapshot.active); - const { effective, source } = resolveRuntimePermissionState( - active, - configured, - snapshot.pending.approvalPolicy, - snapshot.pending.permissionProfile, - baseSource, - ); - return { - scope, - configured, - active, - effective, - source, - }; - } - const { effective, source } = resolveRuntimePermissionState( - configured, - configured, - snapshot.pending.approvalPolicy, - snapshot.pending.permissionProfile, - baseSource, - ); - return { - scope, - configured, - active: null, - effective, - source, - }; +function resolveRuntimeSandboxPolicy( + snapshot: RuntimeSnapshot, + config: RuntimeConfigSnapshot, +): RuntimeNullableLayeredValue { + const pending = sandboxPolicyIntentFromPermissionProfile(snapshot.pending.permissionProfile, config); + return resolveNullableRuntimePermissionValue({ + configured: cloneRuntimeSandboxPolicy(config.startupPermissions.sandboxPolicy), + active: cloneRuntimeSandboxPolicy(snapshot.active.sandboxPolicy), + pending, + activeKnown: snapshot.activeThreadId !== null && snapshot.active.sandboxPolicyKnown, + }); } -function resolveRuntimePermissionState( - base: RuntimePermissionState, - configured: RuntimePermissionState, - approvalPolicyIntent: RuntimeSnapshot["pending"]["approvalPolicy"], - permissionProfileIntent: RuntimeSnapshot["pending"]["permissionProfile"], - baseSource: RuntimeValueSource, -): Pick { - let effective = cloneRuntimePermissionState(base); - let source = baseSource; - const approvalPolicy = runtimePermissionIntentValue(approvalPolicyIntent, configured.approvalPolicy); - if (approvalPolicy !== undefined) { - effective = { ...effective, approvalPolicy }; - source = "pending"; +function sandboxPolicyIntentFromPermissionProfile( + intent: PendingRuntimeIntent, + config: RuntimeConfigSnapshot, +): PendingRuntimeIntent { + if (intent.kind === "set") { + return setRuntimeIntentValue(sandboxPolicyForPermissionProfile(intent.value, config)); } - const configuredPermissionProfile = configured.activePermissionProfile?.id ?? null; - const permissionProfile = runtimePermissionIntentValue(permissionProfileIntent, configuredPermissionProfile); - if (permissionProfile !== undefined) { - effective = { - ...effective, - sandboxPolicy: permissionProfile === configuredPermissionProfile ? configured.sandboxPolicy : null, - activePermissionProfile: permissionProfile ? { id: permissionProfile, extends: null } : null, - }; - source = "pending"; - } - return { effective: cloneRuntimePermissionState(effective), source }; + if (intent.kind === "resetToConfig") return resetRuntimeIntentToConfig(); + return unchangedRuntimeIntent(); } -function runtimePermissionIntentValue(intent: PendingRuntimeIntent, configured: T | null): T | null | undefined { - if (intent.kind === "set") return intent.value; - if (intent.kind === "resetToConfig") return configured; - return undefined; +function sandboxPolicyForPermissionProfile(profile: string, config: RuntimeConfigSnapshot): RuntimeSandboxPolicy | null { + return profile === config.startupPermissions.activePermissionProfile?.id + ? cloneRuntimeSandboxPolicy(config.startupPermissions.sandboxPolicy) + : null; +} + +function cloneRuntimeSandboxPolicy(value: RuntimeSandboxPolicy | null): RuntimeSandboxPolicy | null { + return cloneRuntimePermissionState({ approvalPolicy: null, activePermissionProfile: null, sandboxPolicy: value }).sandboxPolicy; +} + +function resolveRuntimePermissionValue(input: { + configured: T | null | undefined; + active: T | null | undefined; + pending: PendingRuntimeIntent; + activeKnown: boolean; +}): RuntimeLayeredValue { + if (input.pending.kind !== "unchanged") { + const value = resolveRuntimeValue({ + configured: input.configured, + active: input.active, + pending: input.pending, + }); + return { ...value, ...confirmedRuntimeValue(input.configured, input.active, input.activeKnown) }; + } + const configured = input.configured ?? null; + const active = input.active ?? null; + const pending = input.pending; + const { confirmed, confirmedSource } = confirmedRuntimeValue(input.configured, input.active, input.activeKnown); + return { configured, active, pending, confirmed, confirmedSource, effective: confirmed, source: confirmedSource }; +} + +function resolveNullableRuntimePermissionValue(input: { + configured: T | null | undefined; + active: T | null | undefined; + pending: PendingRuntimeIntent; + activeKnown: boolean; +}): RuntimeNullableLayeredValue { + if (input.pending.kind !== "unchanged") { + const value = resolveRuntimeValue({ + configured: input.configured, + active: input.active, + pending: input.pending, + }); + return { ...value, ...confirmedRuntimeValue(input.configured, input.active, input.activeKnown) }; + } + const configured = input.configured ?? null; + const active = input.active ?? null; + const pending = input.pending; + const { confirmed, confirmedSource } = confirmedRuntimeValue(input.configured, input.active, input.activeKnown); + return { configured, active, pending, confirmed, confirmedSource, effective: confirmed, source: confirmedSource }; } function resolveRuntimeValue(input: { @@ -178,19 +219,26 @@ function resolveRuntimeValue(input: { const configured = input.configured ?? null; const active = input.active ?? null; const pending = input.pending ?? ({ kind: "unchanged" } satisfies PendingRuntimeIntent); + const { confirmed, confirmedSource } = confirmedRuntimeValue(configured, active, active !== null); if (pending.kind === "set") { - return { configured, active, pending, effective: pending.value, source: "pending" }; + return { configured, active, pending, confirmed, confirmedSource, effective: pending.value, source: "pending" }; } if (pending.kind === "resetToConfig") { - return { configured, active, pending, effective: configured, source: "config" }; + return { configured, active, pending, confirmed, confirmedSource, effective: configured, source: "config" }; } - if (active !== null) { - return { configured, active, pending, effective: active, source: "active-thread" }; - } - if (configured !== null) { - return { configured, active, pending, effective: configured, source: "config" }; - } - return { configured, active, pending, effective: null, source: "none" }; + return { configured, active, pending, confirmed, confirmedSource, effective: confirmed, source: confirmedSource }; +} + +function confirmedRuntimeValue( + configured: T | null | undefined, + active: T | null | undefined, + activeKnown: boolean, +): Pick, "confirmed" | "confirmedSource"> { + const configuredValue = configured ?? null; + const activeValue = active ?? null; + if (activeKnown) return { confirmed: activeValue, confirmedSource: "active-thread" }; + if (configuredValue !== null) return { confirmed: configuredValue, confirmedSource: "config" }; + return { confirmed: null, confirmedSource: "none" }; } function resolveServiceTier(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): RuntimeLayeredValue { @@ -220,10 +268,17 @@ function serviceTierValue( effective: ServiceTier | null, source: RuntimeValueSource, ): RuntimeLayeredValue { + const { confirmed, confirmedSource } = confirmedRuntimeValue( + config.serviceTier, + snapshot.active.serviceTier, + snapshot.activeThreadId !== null && snapshot.active.serviceTierKnown, + ); return { configured: config.serviceTier, active: snapshot.active.serviceTier, pending: { kind: "unchanged" }, + confirmed, + confirmedSource, effective, source, }; @@ -237,20 +292,25 @@ function resolveFastMode( return { requested, active: isFastServiceTier(serviceTier.effective, serviceTiers), + confirmedActive: isFastServiceTier(serviceTier.confirmed, serviceTiers), source: serviceTier.source, + confirmedSource: serviceTier.confirmedSource, effectiveServiceTier: serviceTier.effective, + confirmedServiceTier: serviceTier.confirmed, serviceTierRequestValue: serviceTiers.find((tier) => tier.name.trim().toLowerCase() === "fast")?.id ?? "fast", }; } function resolveCollaborationMode(snapshot: RuntimeSnapshot, model: string | null): CollaborationModeResolution { const active = snapshot.active.collaborationMode; - const selected = snapshot.pending.collaborationMode; - const effective = effectiveCollaborationMode(active); - const dirty = selected !== effective; + const pending = snapshot.pending.collaborationMode; + const confirmed = effectiveCollaborationMode(active); + const effective = pending.kind === "set" ? pending.value : confirmed; + const dirty = pending.kind === "set"; return { active, - selected, + pending, + confirmed, effective, dirty, blockedReason: dirty && !model ? "missing-model" : null, diff --git a/src/features/chat/domain/runtime/state.ts b/src/features/chat/domain/runtime/state.ts index 916309f9..b7390354 100644 --- a/src/features/chat/domain/runtime/state.ts +++ b/src/features/chat/domain/runtime/state.ts @@ -1,18 +1,23 @@ import { normalizeReasoningEffort, type ReasoningEffort } from "../../../../domain/catalog/metadata"; import { + initialRuntimePermissionKnownState, initialRuntimePermissionState, type RuntimeApprovalPolicy, + type RuntimePermissionKnownState, type RuntimePermissionState, } from "../../../../domain/runtime/permissions"; import { type ApprovalsReviewer, parseServiceTier, type ServiceTier } from "../../../../domain/runtime/policy"; import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings"; import { type ActiveCollaborationMode, + type CollaborationModeIntent, type CollaborationModeSelection, type PendingRuntimeIntent, type RequestedFastMode, resetRuntimeIntentToConfig, + setCollaborationModeIntent, setRuntimeIntentValue, + unchangedCollaborationModeIntent, unchangedRuntimeIntent, } from "./intent"; @@ -21,7 +26,7 @@ export interface ChatRuntimeState { readonly pending: PendingRuntimeIntentState; } -export interface ActiveThreadRuntimeState extends RuntimePermissionState { +export interface ActiveThreadRuntimeState extends RuntimePermissionState, RuntimePermissionKnownState { readonly serviceTierKnown: boolean; readonly model: string | null; readonly reasoningEffort: ReasoningEffort | null; @@ -36,13 +41,14 @@ export interface PendingRuntimeIntentState { readonly permissionProfile: PendingRuntimeIntent; readonly approvalPolicy: PendingRuntimeIntent; readonly approvalsReviewer: PendingRuntimeIntent; - readonly collaborationMode: CollaborationModeSelection; + readonly collaborationMode: CollaborationModeIntent; readonly fastMode: PendingRuntimeIntent; } export function initialActiveChatRuntimeState(): ActiveThreadRuntimeState { return { ...initialRuntimePermissionState(), + ...initialRuntimePermissionKnownState(), serviceTierKnown: false, model: null, reasoningEffort: null, @@ -67,7 +73,7 @@ function initialPendingRuntimeIntentState(): PendingRuntimeIntentState { permissionProfile: unchangedRuntimeIntent(), approvalPolicy: unchangedRuntimeIntent(), approvalsReviewer: unchangedRuntimeIntent(), - collaborationMode: "default", + collaborationMode: unchangedCollaborationModeIntent(), fastMode: unchangedRuntimeIntent(), }; } @@ -141,7 +147,7 @@ export function setSelectedCollaborationModeRuntimeState( ): ChatRuntimeState { return { ...state, - pending: { ...state.pending, collaborationMode }, + pending: { ...state.pending, collaborationMode: setCollaborationModeIntent(collaborationMode) }, }; } @@ -154,7 +160,17 @@ export function commitAppliedRuntimeSettingsPatchState(state: ChatRuntimeState, ...("effort" in update ? { reasoningEffort: normalizeReasoningEffort(update.effort) } : {}), ...("serviceTier" in update ? { serviceTier: parseServiceTier(update.serviceTier), serviceTierKnown: true } : {}), ...("approvalsReviewer" in update ? { approvalsReviewer: update.approvalsReviewer ?? null } : {}), - ...("approvalPolicy" in update ? { approvalPolicy: update.approvalPolicy ?? null } : {}), + ...("approvalPolicy" in update + ? { approvalPolicy: update.approvalPolicy ?? null, approvalPolicyKnown: update.approvalPolicy !== null } + : {}), + ...("permissions" in update + ? { + activePermissionProfile: update.permissions ? { id: update.permissions, extends: null } : null, + sandboxPolicy: null, + permissionProfileKnown: update.permissions !== null, + sandboxPolicyKnown: update.permissions !== null, + } + : {}), ...(update.collaborationMode ? { collaborationMode: update.collaborationMode.mode } : {}), }, pending: { @@ -165,6 +181,7 @@ export function commitAppliedRuntimeSettingsPatchState(state: ChatRuntimeState, ...("approvalPolicy" in update ? { approvalPolicy: unchangedRuntimeIntent() } : {}), ...("approvalsReviewer" in update ? { approvalsReviewer: unchangedRuntimeIntent() } : {}), ...("permissions" in update ? { permissionProfile: unchangedRuntimeIntent() } : {}), + ...("collaborationMode" in update ? { collaborationMode: unchangedCollaborationModeIntent() } : {}), }, }; } diff --git a/src/features/chat/domain/runtime/thread-settings-patch.ts b/src/features/chat/domain/runtime/thread-settings-patch.ts index 12298835..e9885fee 100644 --- a/src/features/chat/domain/runtime/thread-settings-patch.ts +++ b/src/features/chat/domain/runtime/thread-settings-patch.ts @@ -95,7 +95,7 @@ function requestedTurnCollaborationModeSettings(resolution: RuntimeControlsResol const effort = resolution.reasoningEffort.effective; if (!model) return { collaborationMode: null, warning: "missing-model" }; return { - collaborationMode: runtimeCollaborationModeSettings(resolution.collaborationMode.selected, model, effort), + collaborationMode: runtimeCollaborationModeSettings(resolution.collaborationMode.effective, model, effort), warning: null, }; } diff --git a/src/features/chat/host/bundles/runtime-bundle.ts b/src/features/chat/host/bundles/runtime-bundle.ts index 202f4eab..aa1d5a69 100644 --- a/src/features/chat/host/bundles/runtime-bundle.ts +++ b/src/features/chat/host/bundles/runtime-bundle.ts @@ -3,6 +3,7 @@ import type { ChatAppServerGateway } from "../../app-server/session-gateway"; import { type ChatRuntimeSettingsActions, createChatRuntimeSettingsActions } from "../../application/runtime/settings-actions"; import { runtimeSnapshotForChatState } from "../../application/runtime/snapshot"; import type { ChatStateStore } from "../../application/state/store"; +import { collaborationModeIntentValue } from "../../domain/runtime/intent"; import { collaborationModeLabel as formatCollaborationModeLabel } from "../../domain/runtime/labels"; import { type ChatPanelRuntimeProjection, createChatPanelRuntimeProjection } from "../../panel/runtime-status-projection"; import type { ChatPanelEnvironment } from "../contracts"; @@ -52,5 +53,6 @@ export function createRuntimeBundle( } function collaborationModeLabel(stateStore: ChatStateStore): string { - return formatCollaborationModeLabel(stateStore.getState().runtime.pending.collaborationMode); + const runtime = stateStore.getState().runtime; + return formatCollaborationModeLabel(collaborationModeIntentValue(runtime.pending.collaborationMode, runtime.active.collaborationMode)); } diff --git a/src/features/chat/panel/runtime-status-projection.ts b/src/features/chat/panel/runtime-status-projection.ts index e15c8e5a..e44a3471 100644 --- a/src/features/chat/panel/runtime-status-projection.ts +++ b/src/features/chat/panel/runtime-status-projection.ts @@ -1,7 +1,9 @@ +import { runtimeConfigOrDefault } from "../../../domain/runtime/config"; import { runtimeSnapshotForChatState } from "../application/runtime/snapshot"; import type { ChatState } from "../application/state/root-reducer"; import type { MessageStreamNoticeSection } from "../domain/message-stream/items"; import { collaborationModeLabel as formatCollaborationModeLabel } from "../domain/runtime/labels"; +import { resolveRuntimeControls } from "../domain/runtime/resolution"; import type { RuntimeSnapshot } from "../domain/runtime/snapshot"; import { appServerDiagnosticSections } from "../presentation/runtime/diagnostic-sections"; import { runtimePermissionSections } from "../presentation/runtime/permission-sections"; @@ -103,7 +105,10 @@ function noticeSectionsFromDiagnostics( } function collaborationModeLabel(state: ChatState): string { - return formatCollaborationModeLabel(state.runtime.pending.collaborationMode); + const snapshot = runtimeSnapshot(state); + return formatCollaborationModeLabel( + resolveRuntimeControls(snapshot, runtimeConfigOrDefault(snapshot.runtimeConfig)).collaborationMode.effective, + ); } function runtimeSnapshot(state: ChatState): RuntimeSnapshot { diff --git a/src/features/chat/panel/surface/composer-projection.tsx b/src/features/chat/panel/surface/composer-projection.tsx index f406eb32..526a2a86 100644 --- a/src/features/chat/panel/surface/composer-projection.tsx +++ b/src/features/chat/panel/surface/composer-projection.tsx @@ -100,7 +100,7 @@ function composerMetaViewModel( const effort = resolution.reasoningEffort.effective; const composerContext = contextComposerMeter(context?.percent ?? null); const compactEffort = effort ? compactReasoningEffortLabel(effort) : null; - const planActive = resolution.collaborationMode.selected === "plan"; + const planActive = resolution.collaborationMode.effective === "plan"; const reviewActive = resolution.autoReview.active; const fastActive = resolution.fastMode.active; return { diff --git a/src/features/chat/presentation/runtime/permission-sections.ts b/src/features/chat/presentation/runtime/permission-sections.ts index 741ba74b..407a0a43 100644 --- a/src/features/chat/presentation/runtime/permission-sections.ts +++ b/src/features/chat/presentation/runtime/permission-sections.ts @@ -1,5 +1,5 @@ import { runtimeConfigOrDefault } from "../../../../domain/runtime/config"; -import type { RuntimePermissionState, RuntimeSandboxPolicy } from "../../../../domain/runtime/permissions"; +import type { RuntimeApprovalPolicy, RuntimeSandboxPolicy } from "../../../../domain/runtime/permissions"; import { resolveRuntimeControls } from "../../domain/runtime/resolution"; import type { RuntimeSnapshot } from "../../domain/runtime/snapshot"; import type { DiagnosticRow, DiagnosticSection } from "./diagnostic-sections"; @@ -15,37 +15,37 @@ export function runtimePermissionSections(input: RuntimePermissionSectionsInput) return [ { title: "Permissions", - rows: accessRows(resolution.permissions.effective, input.vaultPath), + rows: accessRows(resolution.permissionProfile.confirmed, resolution.sandboxPolicy.confirmed, input.vaultPath), }, { title: "Approvals", - rows: approvalRows(resolution.permissions.effective, resolution.approvalsReviewer.effective), + rows: approvalRows(resolution.approvalPolicy.confirmed, resolution.autoReview.confirmedActive), }, ]; } -function accessRows(permissions: RuntimePermissionState, vaultPath: string): DiagnosticRow[] { +function accessRows(profile: string | null, sandbox: RuntimeSandboxPolicy | null, vaultPath: string): DiagnosticRow[] { return [ - { label: "Profile", value: profileLabel(permissions) }, - { label: "Sandbox", value: sandboxLabel(permissions.sandboxPolicy) }, - { label: "Network", value: networkLabel(permissions.sandboxPolicy) }, - { label: "Extra writable roots", value: writableRootsLabel(permissions.sandboxPolicy, vaultPath) }, + { label: "Profile", value: profileLabel(profile, sandbox) }, + { label: "Sandbox", value: sandboxLabel(sandbox) }, + { label: "Network", value: networkLabel(sandbox) }, + { label: "Extra writable roots", value: writableRootsLabel(sandbox, vaultPath) }, ]; } -function approvalRows(permissions: RuntimePermissionState, reviewer: string | null): DiagnosticRow[] { +function approvalRows(policy: RuntimeApprovalPolicy | null, autoReview: boolean): DiagnosticRow[] { return [ - { label: "Approval policy", value: approvalPolicyLabel(permissions.approvalPolicy) }, + { label: "Approval policy", value: approvalPolicyLabel(policy) }, { - label: "Reviewer", - value: reviewer ?? "(Codex default)", + label: "Auto review", + value: autoReview ? "on" : "off", }, ]; } -function profileLabel(permissions: RuntimePermissionState): string { - if (permissions.activePermissionProfile) return permissions.activePermissionProfile.id; - if (permissions.sandboxPolicy) return "(legacy sandbox)"; +function profileLabel(profile: string | null, sandbox: RuntimeSandboxPolicy | null): string { + if (profile) return profile; + if (sandbox) return "(legacy sandbox)"; return "(not reported)"; } @@ -84,7 +84,7 @@ function displayRoot(root: string, vaultPath: string): string { return root; } -function approvalPolicyLabel(policy: RuntimePermissionState["approvalPolicy"]): string { +function approvalPolicyLabel(policy: RuntimeApprovalPolicy | null): string { if (!policy) return "(not reported)"; if (typeof policy === "string") return policy; diff --git a/tests/features/chat/app-server/actions/actions.test.ts b/tests/features/chat/app-server/actions/actions.test.ts index c5c6f260..6c9cb985 100644 --- a/tests/features/chat/app-server/actions/actions.test.ts +++ b/tests/features/chat/app-server/actions/actions.test.ts @@ -106,7 +106,7 @@ describe("chat app-server actions", () => { expect(stateStore.getState().runtime.pending.reasoningEffort).toEqual({ kind: "set", value: "high" }); expect(stateStore.getState().runtime.pending.fastMode).toEqual({ kind: "set", value: "enabled" }); expect(stateStore.getState().runtime.pending.approvalsReviewer).toEqual({ kind: "set", value: "auto_review" }); - expect(stateStore.getState().runtime.pending.collaborationMode).toBe("plan"); + expect(stateStore.getState().runtime.pending.collaborationMode).toEqual({ kind: "set", value: "plan" }); }); it("can skip newly started thread goal sync when the caller sets the first goal", async () => { diff --git a/tests/features/chat/application/connection/reconnect-actions.test.ts b/tests/features/chat/application/connection/reconnect-actions.test.ts index dcc166d7..59d0c5e5 100644 --- a/tests/features/chat/application/connection/reconnect-actions.test.ts +++ b/tests/features/chat/application/connection/reconnect-actions.test.ts @@ -8,6 +8,9 @@ function createHost(overrides: Partial = {}) { stateStore.dispatch({ type: "ui/panel-set", panel: "history" }); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, diff --git a/tests/features/chat/application/conversation/composer-submit-actions.test.ts b/tests/features/chat/application/conversation/composer-submit-actions.test.ts index 552193e3..9090f65c 100644 --- a/tests/features/chat/application/conversation/composer-submit-actions.test.ts +++ b/tests/features/chat/application/conversation/composer-submit-actions.test.ts @@ -151,6 +151,9 @@ describe("submitComposer", () => { const { host, interruptTurn, showLatest, stateStore } = createHost(""); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, diff --git a/tests/features/chat/application/conversation/plan-implementation.test.ts b/tests/features/chat/application/conversation/plan-implementation.test.ts index d254619a..b8558888 100644 --- a/tests/features/chat/application/conversation/plan-implementation.test.ts +++ b/tests/features/chat/application/conversation/plan-implementation.test.ts @@ -30,6 +30,9 @@ const streamingPlanItem = (id: string): MessageStreamItem => ({ function resumeThread(stateStore: ChatStateStore, items: readonly MessageStreamItem[]): void { stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, @@ -99,7 +102,7 @@ describe("implementPlan", () => { expect(ensureConnected).toHaveBeenCalledOnce(); expect(requestDefaultCollaborationModeForNextTurn).toHaveBeenCalledOnce(); - expect(stateStore.getState().runtime.pending.collaborationMode).toBe("default"); + expect(stateStore.getState().runtime.pending.collaborationMode).toEqual({ kind: "set", value: "default" }); expect(stateStore.getState().ui.toolbarPanel).toBeNull(); expect(sendTurnText).toHaveBeenCalledWith("Please implement this plan."); }); diff --git a/tests/features/chat/application/conversation/slash-command-execution.test.ts b/tests/features/chat/application/conversation/slash-command-execution.test.ts index 406bfc96..81b3843d 100644 --- a/tests/features/chat/application/conversation/slash-command-execution.test.ts +++ b/tests/features/chat/application/conversation/slash-command-execution.test.ts @@ -563,7 +563,7 @@ describe("slash commands", () => { it("shows permissions and approvals as shared structured sections", async () => { const details = [ { title: "Permissions", auditFacts: [{ key: "Profile", value: "workspace-write" }] }, - { title: "Approvals", auditFacts: [{ key: "Reviewer", value: "auto_review" }] }, + { title: "Approvals", auditFacts: [{ key: "Auto review", value: "on" }] }, ]; const ctx = context({ permissionDetails: () => details }); diff --git a/tests/features/chat/application/conversation/slash-command-executor.test.ts b/tests/features/chat/application/conversation/slash-command-executor.test.ts index d3dc5e18..3d039c70 100644 --- a/tests/features/chat/application/conversation/slash-command-executor.test.ts +++ b/tests/features/chat/application/conversation/slash-command-executor.test.ts @@ -90,6 +90,9 @@ describe("executeSlashCommandWithState", () => { }); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, @@ -110,6 +113,9 @@ describe("executeSlashCommandWithState", () => { const { compactThread, host, stateStore } = createHost({ connectionAvailable: () => false }); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, diff --git a/tests/features/chat/application/conversation/turn-submission-actions.test.ts b/tests/features/chat/application/conversation/turn-submission-actions.test.ts index cbe341cc..1a85320b 100644 --- a/tests/features/chat/application/conversation/turn-submission-actions.test.ts +++ b/tests/features/chat/application/conversation/turn-submission-actions.test.ts @@ -60,6 +60,9 @@ function createHost(overrides: TurnSubmissionHostOverrides = {}) { function resumeThread(stateStore: ReturnType) { stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, diff --git a/tests/features/chat/application/runtime/settings-actions.test.ts b/tests/features/chat/application/runtime/settings-actions.test.ts index cd9e079a..569f2472 100644 --- a/tests/features/chat/application/runtime/settings-actions.test.ts +++ b/tests/features/chat/application/runtime/settings-actions.test.ts @@ -10,6 +10,7 @@ import { runtimeSnapshotForChatState } from "../../../../../src/features/chat/ap import type { ActiveThreadSettingsAppliedAction } from "../../../../../src/features/chat/application/state/actions"; import type { ChatState } from "../../../../../src/features/chat/application/state/root-reducer"; import { createChatStateStore } from "../../../../../src/features/chat/application/state/store"; +import { setRuntimeIntentValue } from "../../../../../src/features/chat/domain/runtime/intent"; import { chatStateFixture, chatStateWith } from "../../support/state"; describe("createChatRuntimeSettingsActions", () => { @@ -35,6 +36,36 @@ describe("createChatRuntimeSettingsActions", () => { expect(messages).toEqual([]); }); + it("commits pending permission profile updates into active permission state", async () => { + let state = chatStateFixture(); + state = chatStateWith(state, { activeThread: { id: "thread" } }); + state = chatStateWith(state, { + runtime: { + active: { + activePermissionProfile: { id: ":read-only", extends: null }, + sandboxPolicy: { type: "readOnly", networkAccess: false }, + }, + pending: { + permissionProfile: setRuntimeIntentValue(":workspace"), + }, + }, + }); + const store = createChatStateStore(state); + const transport = settingsTransportFixture(); + const messages: string[] = []; + const controller = runtimeControllerFixture(store, transport, messages); + + await expect(controller.applyPendingThreadSettings()).resolves.toBe(true); + + expect(transport.updateThreadSettings).toHaveBeenCalledWith("thread", { permissions: ":workspace" }); + expect(store.getState().runtime.pending.permissionProfile).toEqual({ kind: "unchanged" }); + expect(store.getState().runtime.active.activePermissionProfile).toEqual({ id: ":workspace", extends: null }); + expect(store.getState().runtime.active.permissionProfileKnown).toBe(true); + expect(store.getState().runtime.active.sandboxPolicy).toBeNull(); + expect(store.getState().runtime.active.sandboxPolicyKnown).toBe(true); + expect(messages).toEqual([]); + }); + it("reserves thread runtime settings when no thread is active", async () => { const store = createChatStateStore(chatStateFixture()); const transport = settingsTransportFixture(); @@ -53,7 +84,7 @@ describe("createChatRuntimeSettingsActions", () => { expect(store.getState().runtime.pending.reasoningEffort).toEqual({ kind: "set", value: "high" }); expect(store.getState().runtime.pending.fastMode).toEqual({ kind: "set", value: "enabled" }); expect(store.getState().runtime.pending.approvalsReviewer).toEqual({ kind: "set", value: "auto_review" }); - expect(store.getState().runtime.pending.collaborationMode).toBe("default"); + expect(store.getState().runtime.pending.collaborationMode).toEqual({ kind: "set", value: "default" }); expect(messages).toEqual([ "Fast mode on for subsequent turns.", "Auto-review on for subsequent turns.", @@ -154,7 +185,7 @@ describe("createChatRuntimeSettingsActions", () => { await expect(controller.setCollaborationMode("plan")).resolves.toBe(true); expect(transport.updateThreadSettings).not.toHaveBeenCalled(); - expect(store.getState().runtime.pending.collaborationMode).toBe("plan"); + expect(store.getState().runtime.pending.collaborationMode).toEqual({ kind: "set", value: "plan" }); expect(store.getState().runtime.active.collaborationMode).toBeNull(); expect(messages).toEqual(["Plan mode is selected, but No effective model is available. Sending without a mode override."]); }); @@ -163,7 +194,7 @@ describe("createChatRuntimeSettingsActions", () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread" } }); state = chatStateWith(state, { runtime: { active: { collaborationMode: "plan" } } }); - state = chatStateWith(state, { runtime: { pending: { collaborationMode: "plan" } } }); + state = chatStateWith(state, { runtime: { pending: { collaborationMode: { kind: "set", value: "plan" } } } }); const store = createChatStateStore(state); const transport = settingsTransportFixture(); const messages: string[] = []; @@ -172,7 +203,7 @@ describe("createChatRuntimeSettingsActions", () => { controller.requestDefaultCollaborationModeForNextTurn(); expect(transport.updateThreadSettings).not.toHaveBeenCalled(); - expect(store.getState().runtime.pending.collaborationMode).toBe("default"); + expect(store.getState().runtime.pending.collaborationMode).toEqual({ kind: "set", value: "default" }); expect(store.getState().runtime.active.collaborationMode).toBe("plan"); expect(messages).toEqual([]); }); @@ -425,6 +456,9 @@ function threadSettings( collaborationMode: "default", serviceTier, approvalsReviewer, + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, diff --git a/tests/features/chat/application/state/actions.test.ts b/tests/features/chat/application/state/actions.test.ts index 6a749795..e934b8c6 100644 --- a/tests/features/chat/application/state/actions.test.ts +++ b/tests/features/chat/application/state/actions.test.ts @@ -17,6 +17,9 @@ describe("chat thread resume helpers", () => { reasoningEffort: "high", serviceTier: "fast", serviceTierKnown: true, + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalsReviewer: "user", approvalPolicy: "on-request", sandboxPolicy: { @@ -34,6 +37,9 @@ describe("chat thread resume helpers", () => { expect(action).toMatchObject({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, thread: resumed, cwd: "/vault", model: "gpt-5.5", @@ -65,6 +71,9 @@ describe("chat thread resume helpers", () => { expect(action).toMatchObject({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: "on-request", sandboxPolicy: { type: "workspaceWrite", @@ -87,6 +96,9 @@ function responseFixture(thread: Thread): ThreadActivationSnapshot { model: "gpt-5.5", serviceTier: "fast", cwd: "/vault", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalsReviewer: "user", reasoningEffort: "high", approvalPolicy: "on-request", diff --git a/tests/features/chat/application/state/root-reducer.test.ts b/tests/features/chat/application/state/root-reducer.test.ts index 1cb68529..1238baaa 100644 --- a/tests/features/chat/application/state/root-reducer.test.ts +++ b/tests/features/chat/application/state/root-reducer.test.ts @@ -19,7 +19,7 @@ describe("chatReducer", () => { state = chatStateWith(state, { runtime: { active: { serviceTier: "fast" } } }); state = chatStateWith(state, { runtime: { active: { approvalsReviewer: "auto_review" } } }); state = chatStateWith(state, { runtime: { active: { collaborationMode: "plan" } } }); - state = chatStateWith(state, { runtime: { pending: { collaborationMode: "plan" } } }); + state = chatStateWith(state, { runtime: { pending: { collaborationMode: { kind: "set", value: "plan" } } } }); state = chatStateWith(state, { activeThread: { goal: goal("thread") } }); state = chatStateWith(state, { messageStream: { historyCursor: "cursor" } }); state = chatStateWith(state, { messageStream: { loadingHistory: true } }); @@ -56,7 +56,7 @@ describe("chatReducer", () => { expect(next.runtime.pending.reasoningEffort).toEqual({ kind: "unchanged" }); expect(next.runtime.pending.fastMode).toEqual({ kind: "unchanged" }); expect(next.runtime.pending.approvalsReviewer).toEqual({ kind: "unchanged" }); - expect(next.runtime.pending.collaborationMode).toBe("default"); + expect(next.runtime.pending.collaborationMode).toEqual({ kind: "unchanged" }); expect(activeTurnId(next)).toBeNull(); expect(chatStateMessageStreamItems(next)).toEqual([]); expect(next.messageStream.turnDiffs.size).toBe(0); @@ -91,7 +91,7 @@ describe("chatReducer", () => { state = chatStateWith(state, { composer: { suggestions: [suggestion("/plan")] } }); state = chatStateWith(state, { composer: { suggestionsDismissedSignature: "dismissed" } }); state = chatStateWith(state, { runtime: { active: { collaborationMode: "plan" } } }); - state = chatStateWith(state, { runtime: { pending: { collaborationMode: "plan" } } }); + state = chatStateWith(state, { runtime: { pending: { collaborationMode: { kind: "set", value: "plan" } } } }); state = chatStateWith(state, { ui: { disclosures: { approvalDetails: new Set(["1:details"]) } } }); state = chatStateWith(state, { ui: { disclosures: { textDetails: new Set(["previous:details"]) } } }); state = chatStateWith(state, { ui: { messageActionMenu: { forkMenuItemId: "previous" } } }); @@ -102,6 +102,9 @@ describe("chatReducer", () => { const next = chatReducer(state, { type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, @@ -129,7 +132,7 @@ describe("chatReducer", () => { expect(next.composer.suggestions).toEqual([]); expect(next.composer.suggestionsDismissedSignature).toBeNull(); expect(next.runtime.active.collaborationMode).toBeNull(); - expect(next.runtime.pending.collaborationMode).toBe("default"); + expect(next.runtime.pending.collaborationMode).toEqual({ kind: "unchanged" }); expect(uiDisclosureCount(next)).toBe(0); expect(next.ui.messageActionMenu.forkMenuItemId).toBeNull(); expect(next.ui.goalEditor.kind).toBe("closed"); @@ -145,6 +148,9 @@ describe("chatReducer", () => { const next = chatReducer(state, { type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, @@ -166,7 +172,7 @@ describe("chatReducer", () => { expect(next.runtime.pending.reasoningEffort).toEqual({ kind: "set", value: "high" }); expect(next.runtime.pending.fastMode).toEqual({ kind: "set", value: "enabled" }); expect(next.runtime.pending.approvalsReviewer).toEqual({ kind: "set", value: "auto_review" }); - expect(next.runtime.pending.collaborationMode).toBe("plan"); + expect(next.runtime.pending.collaborationMode).toEqual({ kind: "set", value: "plan" }); expect(next.runtime.active.collaborationMode).toBeNull(); }); @@ -176,6 +182,9 @@ describe("chatReducer", () => { const next = chatReducer(state, { type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, @@ -650,6 +659,9 @@ describe("chatReducer", () => { it("preserves unknown service tier state when resuming from active runtime", () => { const state = chatReducer(chatStateFixture(), { type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, @@ -715,6 +727,9 @@ describe("chatReducer", () => { panelA.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, @@ -731,6 +746,9 @@ describe("chatReducer", () => { panelB.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, diff --git a/tests/features/chat/application/threads/active-thread-identity-sync.test.ts b/tests/features/chat/application/threads/active-thread-identity-sync.test.ts index 53cee8da..ac70ebed 100644 --- a/tests/features/chat/application/threads/active-thread-identity-sync.test.ts +++ b/tests/features/chat/application/threads/active-thread-identity-sync.test.ts @@ -44,6 +44,9 @@ describe("createActiveThreadIdentitySync", () => { const { controller, host, restoredClear, stateStore } = createController(); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, @@ -69,6 +72,9 @@ describe("createActiveThreadIdentitySync", () => { const { controller, host, restoredClear, stateStore } = createController(); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, @@ -107,6 +113,9 @@ describe("createActiveThreadIdentitySync", () => { const { controller, host, restoredRename, stateStore } = createController(); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, diff --git a/tests/features/chat/application/threads/goal-actions.test.ts b/tests/features/chat/application/threads/goal-actions.test.ts index 17805333..bc0bbaab 100644 --- a/tests/features/chat/application/threads/goal-actions.test.ts +++ b/tests/features/chat/application/threads/goal-actions.test.ts @@ -187,6 +187,9 @@ describe("createGoalActions", () => { const startThread = vi.fn().mockImplementation(async () => { stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, diff --git a/tests/features/chat/application/threads/resume-actions.test.ts b/tests/features/chat/application/threads/resume-actions.test.ts index 61146e55..412a995b 100644 --- a/tests/features/chat/application/threads/resume-actions.test.ts +++ b/tests/features/chat/application/threads/resume-actions.test.ts @@ -22,6 +22,9 @@ function activation(threadId: string, overrides: Partial = cwd: "/vault", model: "gpt-test", serviceTier: null, + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalsReviewer: "user", reasoningEffort: null, approvalPolicy: null, @@ -120,6 +123,9 @@ describe("ResumeActions", () => { const { actions, host, resumeThread, stateStore } = createActions(); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, @@ -165,6 +171,9 @@ describe("ResumeActions", () => { await actions.resumeThread("thread"); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, diff --git a/tests/features/chat/application/threads/thread-management-actions.test.ts b/tests/features/chat/application/threads/thread-management-actions.test.ts index 2d4a1ddf..503eb80d 100644 --- a/tests/features/chat/application/threads/thread-management-actions.test.ts +++ b/tests/features/chat/application/threads/thread-management-actions.test.ts @@ -82,6 +82,9 @@ describe("thread management actions", () => { host.threadTransport.compactThread.mockReturnValue(compact.promise); host.stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, @@ -100,6 +103,9 @@ describe("thread management actions", () => { }); host.stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, @@ -241,6 +247,9 @@ describe("thread management actions", () => { host.threadTransport.forkThread.mockReturnValue(fork.promise); host.stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, @@ -264,6 +273,9 @@ describe("thread management actions", () => { }); host.stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, @@ -325,6 +337,9 @@ describe("thread management actions", () => { const host = hostMock({ items: turnItems() }); host.stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, @@ -360,6 +375,9 @@ describe("thread management actions", () => { host.threadTransport.rollbackThread.mockReturnValue(rollback.promise); host.stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, @@ -384,6 +402,9 @@ describe("thread management actions", () => { }); host.stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, @@ -412,6 +433,9 @@ describe("thread management actions", () => { }); host.stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, diff --git a/tests/features/chat/application/threads/thread-navigation-actions.test.ts b/tests/features/chat/application/threads/thread-navigation-actions.test.ts index 73228468..5045552c 100644 --- a/tests/features/chat/application/threads/thread-navigation-actions.test.ts +++ b/tests/features/chat/application/threads/thread-navigation-actions.test.ts @@ -11,6 +11,9 @@ import { function resumeThreadState(stateStore: ChatStateStore, threadId: string): void { stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, diff --git a/tests/features/chat/host/reconnect-action.test.ts b/tests/features/chat/host/reconnect-action.test.ts index a36c2415..14b81108 100644 --- a/tests/features/chat/host/reconnect-action.test.ts +++ b/tests/features/chat/host/reconnect-action.test.ts @@ -11,6 +11,9 @@ describe("createReconnectAction", () => { const stateStore = createChatStateStore(); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, diff --git a/tests/features/chat/host/session-graph.test.ts b/tests/features/chat/host/session-graph.test.ts index c92f20bf..037adf84 100644 --- a/tests/features/chat/host/session-graph.test.ts +++ b/tests/features/chat/host/session-graph.test.ts @@ -158,6 +158,9 @@ describe("createChatPanelSessionGraph actions", () => { }); stateStore.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, diff --git a/tests/features/chat/panel/runtime-status-projection.test.ts b/tests/features/chat/panel/runtime-status-projection.test.ts index 876d6da9..a0908a18 100644 --- a/tests/features/chat/panel/runtime-status-projection.test.ts +++ b/tests/features/chat/panel/runtime-status-projection.test.ts @@ -75,7 +75,50 @@ describe("createChatPanelRuntimeProjection", () => { title: "Approvals", auditFacts: [ { key: "Approval policy", value: "on-request" }, - { key: "Reviewer", value: "auto_review" }, + { key: "Auto review", value: "on" }, + ], + }, + ]); + }); + + it("keeps pending approval reviewer out of diagnostic permission details", () => { + const state = chatStateWith(chatStateFixture(), { + activeThread: { id: "thread-1" }, + connection: { + runtimeConfig: runtimeConfigFixture({ + approvals_reviewer: "user", + approval_policy: "on-request", + }), + }, + runtime: { + pending: { + approvalsReviewer: { kind: "set", value: "auto_review" }, + }, + }, + }); + const projection = createChatPanelRuntimeProjection({ + state: () => state, + connected: () => true, + configuredCommand: () => "codex", + vaultPath: () => "/vault", + nowMs: () => 0, + }); + + expect(projection.permissionDetails()).toEqual([ + { + title: "Permissions", + auditFacts: [ + { key: "Profile", value: "(not reported)" }, + { key: "Sandbox", value: "(not reported)" }, + { key: "Network", value: "(not reported)" }, + { key: "Extra writable roots", value: "(not reported)" }, + ], + }, + { + title: "Approvals", + auditFacts: [ + { key: "Approval policy", value: "on-request" }, + { key: "Auto review", value: "off" }, ], }, ]); diff --git a/tests/features/chat/panel/surface/message-stream-presenter.test.ts b/tests/features/chat/panel/surface/message-stream-presenter.test.ts index 266a8335..2e3a7673 100644 --- a/tests/features/chat/panel/surface/message-stream-presenter.test.ts +++ b/tests/features/chat/panel/surface/message-stream-presenter.test.ts @@ -42,6 +42,9 @@ describe("MessageStreamPresenter scroll pinning", () => { const store = createChatStateStore(chatStateFixture()); store.dispatch({ type: "active-thread/resumed", + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: null, sandboxPolicy: null, activePermissionProfile: null, diff --git a/tests/features/chat/panel/surface/projections.test.ts b/tests/features/chat/panel/surface/projections.test.ts index 7740f3f1..a2ee28fa 100644 --- a/tests/features/chat/panel/surface/projections.test.ts +++ b/tests/features/chat/panel/surface/projections.test.ts @@ -149,7 +149,8 @@ describe("chat panel surface projections", () => { ); expect(parent.textContent).toContain(":workspace"); expect(parent.textContent).toContain("on-request"); - expect(parent.textContent).toContain("auto_review"); + expect(parent.textContent).toContain("off"); + expect(parent.textContent).not.toContain("auto_review"); expect(parent.textContent).not.toContain("user -> auto_review"); unmountUiRoot(parent); }); @@ -157,7 +158,7 @@ describe("chat panel surface projections", () => { it("builds composer meta from context and runtime state", () => { let state = chatStateFixture(); state = chatStateWith(state, { activeThread: { id: "thread-1" } }); - state = chatStateWith(state, { runtime: { pending: { collaborationMode: "plan" } } }); + state = chatStateWith(state, { runtime: { pending: { collaborationMode: { kind: "set", value: "plan" } } } }); state = chatStateWith(state, { connection: { runtimeConfig: runtimeConfigFixture({ diff --git a/tests/features/chat/support/state.ts b/tests/features/chat/support/state.ts index 88d8e146..37e9ce59 100644 --- a/tests/features/chat/support/state.ts +++ b/tests/features/chat/support/state.ts @@ -53,6 +53,9 @@ function runtimeWithPatch(runtime: ChatState["runtime"], patch: RuntimePatch): C active: { ...runtime.active, ...(patch.active && "serviceTier" in patch.active ? { serviceTierKnown: true } : {}), + ...(patch.active && "approvalPolicy" in patch.active ? { approvalPolicyKnown: true } : {}), + ...(patch.active && "sandboxPolicy" in patch.active ? { sandboxPolicyKnown: true } : {}), + ...(patch.active && "activePermissionProfile" in patch.active ? { permissionProfileKnown: true } : {}), ...patch.active, }, pending: { diff --git a/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx b/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx index 10106aad..93e3f8a9 100644 --- a/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx +++ b/tests/features/chat/ui/message-stream/blocks-and-messages.test.tsx @@ -799,7 +799,7 @@ describe("message stream rendering and message action menu", () => { const baseState = { activeThread: { id: "thread" }, turn: { lifecycle: { kind: "idle" as const } }, - runtime: { pending: { collaborationMode: "plan" as const } }, + runtime: { pending: { collaborationMode: { kind: "set", value: "plan" } as const } }, messageStream: { stableItems: [ firstPlan, @@ -818,7 +818,9 @@ describe("message stream rendering and message action menu", () => { }; expect(implementPlanTargetFromState(baseState)).toEqual({ itemId: secondPlan.id }); - expect(implementPlanTargetFromState({ ...baseState, runtime: { pending: { collaborationMode: "default" } } })).toBeNull(); + expect( + implementPlanTargetFromState({ ...baseState, runtime: { pending: { collaborationMode: { kind: "set", value: "default" } } } }), + ).toBeNull(); expect(implementPlanTargetFromState({ ...baseState, turn: { lifecycle: { kind: "running", turnId: "turn-2" } } })).toBeNull(); }); diff --git a/tests/features/chat/ui/toolbar.test.ts b/tests/features/chat/ui/toolbar.test.ts index f0c4c7ea..454f00e5 100644 --- a/tests/features/chat/ui/toolbar.test.ts +++ b/tests/features/chat/ui/toolbar.test.ts @@ -212,7 +212,7 @@ describe("Toolbar decisions", () => { title: "Approvals", rows: [ { label: "Approval policy", value: "on-request" }, - { label: "Reviewer", value: "auto_review" }, + { label: "Auto review", value: "on" }, ], }, ], @@ -229,7 +229,7 @@ describe("Toolbar decisions", () => { ); expect(parent.textContent).toContain(":workspace"); expect(parent.textContent).toContain("workspace-write"); - expect(parent.textContent).toContain("auto_review"); + expect(parent.textContent).toContain("on"); expect(parent.querySelector(".codex-panel__status-diagnostics-row--warning")).toBeNull(); expect(parent.querySelector(".codex-panel__status-diagnostics-row--error")).toBeNull(); }); diff --git a/tests/runtime/runtime-settings.test.ts b/tests/runtime/runtime-settings.test.ts index 01435f28..a8fd1b5b 100644 --- a/tests/runtime/runtime-settings.test.ts +++ b/tests/runtime/runtime-settings.test.ts @@ -99,22 +99,17 @@ describe("runtime settings", () => { }); }); - it("treats active thread permissions as authoritative over startup config", () => { + it("falls back to startup permissions until active thread permissions are reported", () => { const configured = runtimeSnapshot({ runtimeConfig: runtimeConfigFixture({ default_permissions: ":workspace", approval_policy: "on-request", }), }); - expect(resolveRuntimeControls(configured, snapshotConfig(configured)).permissions).toMatchObject({ - scope: "new-thread", - source: "config", - active: null, - effective: { - activePermissionProfile: { id: ":workspace", extends: null }, - approvalPolicy: "on-request", - sandboxPolicy: null, - }, + expect(resolveRuntimeControls(configured, snapshotConfig(configured))).toMatchObject({ + permissionProfile: { effective: ":workspace", source: "config" }, + sandboxPolicy: { effective: null, source: "none" }, + approvalPolicy: { effective: "on-request", source: "config" }, }); const activeUnreported = runtimeSnapshot({ @@ -124,19 +119,18 @@ describe("runtime settings", () => { approval_policy: "on-request", }), }); - expect(resolveRuntimeControls(activeUnreported, snapshotConfig(activeUnreported)).permissions).toMatchObject({ - scope: "current-thread", - source: "active-thread", - effective: { - activePermissionProfile: null, - approvalPolicy: null, - sandboxPolicy: null, - }, + expect(resolveRuntimeControls(activeUnreported, snapshotConfig(activeUnreported))).toMatchObject({ + permissionProfile: { configured: ":workspace", active: null, effective: ":workspace", source: "config" }, + sandboxPolicy: { configured: null, active: null, effective: null, source: "none" }, + approvalPolicy: { configured: "on-request", active: null, effective: "on-request", source: "config" }, }); const activeReported = runtimeSnapshot({ activeThreadId: "thread", active: { + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: "never", sandboxPolicy: { type: "readOnly", networkAccess: false }, activePermissionProfile: { id: ":read-only", extends: null }, @@ -146,22 +140,36 @@ describe("runtime settings", () => { }); expect(resolveRuntimeControls(activeReported, snapshotConfig(activeReported))).toMatchObject({ approvalsReviewer: { effective: "auto_review", source: "pending" }, - permissions: { - scope: "current-thread", - source: "active-thread", - effective: { - activePermissionProfile: { id: ":read-only", extends: null }, - approvalPolicy: "never", - sandboxPolicy: { type: "readOnly", networkAccess: false }, - }, + permissionProfile: { effective: ":read-only", source: "active-thread" }, + sandboxPolicy: { effective: { type: "readOnly", networkAccess: false }, source: "active-thread" }, + approvalPolicy: { effective: "never", source: "active-thread" }, + }); + + const approvalOnlyReported = runtimeSnapshot({ + activeThreadId: "thread", + runtimeConfig: runtimeConfigFixture({ + default_permissions: ":workspace", + approval_policy: "on-request", + }), + active: { + approvalPolicyKnown: true, + approvalPolicy: "never", }, }); + expect(resolveRuntimeControls(approvalOnlyReported, snapshotConfig(approvalOnlyReported))).toMatchObject({ + permissionProfile: { effective: ":workspace", source: "config" }, + sandboxPolicy: { effective: null, source: "none" }, + approvalPolicy: { effective: "never", source: "active-thread" }, + }); }); it("keeps permission display scope separate from pending permission source", () => { const snapshot = runtimeSnapshot({ activeThreadId: "thread", active: { + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, approvalPolicy: "on-request", sandboxPolicy: { type: "readOnly", networkAccess: false }, activePermissionProfile: null, @@ -172,14 +180,10 @@ describe("runtime settings", () => { }, }); - expect(resolveRuntimeControls(snapshot, snapshotConfig(snapshot)).permissions).toMatchObject({ - scope: "current-thread", - source: "pending", - effective: { - activePermissionProfile: { id: ":workspace", extends: null }, - approvalPolicy: "never", - sandboxPolicy: null, - }, + expect(resolveRuntimeControls(snapshot, snapshotConfig(snapshot))).toMatchObject({ + permissionProfile: { effective: ":workspace", source: "pending" }, + sandboxPolicy: { effective: null, source: "pending" }, + approvalPolicy: { effective: "never", source: "pending" }, }); expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ update: { @@ -202,22 +206,23 @@ describe("runtime settings", () => { }, }), active: { + sandboxPolicyKnown: true, + permissionProfileKnown: true, activePermissionProfile: { id: ":workspace", extends: null }, sandboxPolicy: null, }, pending: { permissionProfile: resetRuntimeIntentToConfig() }, }); - expect(resolveRuntimeControls(snapshot, snapshotConfig(snapshot)).permissions).toMatchObject({ - scope: "current-thread", - source: "pending", - effective: { - activePermissionProfile: null, - sandboxPolicy: { + expect(resolveRuntimeControls(snapshot, snapshotConfig(snapshot))).toMatchObject({ + permissionProfile: { effective: null, source: "config" }, + sandboxPolicy: { + effective: { type: "workspaceWrite", writableRoots: ["/vault"], networkAccess: false, }, + source: "config", }, }); expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ @@ -261,7 +266,9 @@ describe("runtime settings", () => { }); it("treats unreported thread collaboration mode as default without losing the unknown state", () => { - const snapshot = runtimeSnapshot({ active: { collaborationMode: null }, pending: { collaborationMode: "default" } }); + const snapshot = runtimeSnapshot({ + active: { collaborationMode: null }, + }); expect(snapshot.active.collaborationMode).toBeNull(); expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toEqual({ @@ -269,7 +276,7 @@ describe("runtime settings", () => { collaborationModeWarning: null, }); - expect(snapshot.pending.collaborationMode).toBe("default"); + expect(snapshot.pending.collaborationMode).toEqual({ kind: "unchanged" }); }); it("keeps model reset tied to config when active thread model differs", () => { @@ -293,7 +300,7 @@ describe("runtime settings", () => { it("builds the Plan collaboration mode payload from selected runtime settings", () => { const snapshot = runtimeSnapshot({ pending: { - collaborationMode: "plan", + collaborationMode: { kind: "set", value: "plan" }, model: setRuntimeIntentValue("gpt-5.5"), reasoningEffort: setRuntimeIntentValue("high"), }, @@ -316,7 +323,7 @@ describe("runtime settings", () => { it("uses the explicit config for collaboration mode thread settings", () => { const snapshot = runtimeSnapshot({ - pending: { collaborationMode: "plan" }, + pending: { collaborationMode: { kind: "set", value: "plan" } }, runtimeConfig: runtimeConfigFixture({ model: "snapshot-model", model_reasoning_effort: "low", @@ -345,12 +352,12 @@ describe("runtime settings", () => { it("keeps collaboration mode settings separate from reviewer and direct runtime intents", () => { const reviewerSnapshot = runtimeSnapshot({ pending: { - collaborationMode: "plan", + collaborationMode: { kind: "set", value: "plan" }, approvalsReviewer: setRuntimeIntentValue("auto_review"), }, }); const activeRuntimeSnapshot = runtimeSnapshot({ - pending: { collaborationMode: "plan" }, + pending: { collaborationMode: { kind: "set", value: "plan" } }, active: { model: "gpt-5-active", serviceTier: "fast" }, runtimeConfig: runtimeConfigFixture({}), }); @@ -541,6 +548,15 @@ describe("runtime settings", () => { runtimeConfig: runtimeConfigFixture({ model: "gpt-config", model_reasoning_effort: "medium", + default_permissions: ":workspace", + approval_policy: "on-request", + sandbox_mode: "workspace-write", + sandbox_workspace_write: { + writable_roots: ["/vault"], + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + }, approvals_reviewer: "auto_review", service_tier: "fast", }), @@ -549,8 +565,14 @@ describe("runtime settings", () => { ...configured, activeThreadId: "thread", active: { + approvalPolicyKnown: true, + sandboxPolicyKnown: true, + permissionProfileKnown: true, model: "gpt-active", reasoningEffort: "high", + activePermissionProfile: { id: ":read-only", extends: null }, + sandboxPolicy: { type: "readOnly", networkAccess: false }, + approvalPolicy: "never", approvalsReviewer: "user", serviceTier: "flex", }, @@ -561,6 +583,8 @@ describe("runtime settings", () => { ...active.pending, model: setRuntimeIntentValue("gpt-pending"), reasoningEffort: setRuntimeIntentValue("low"), + permissionProfile: setRuntimeIntentValue(":workspace"), + approvalPolicy: setRuntimeIntentValue("on-failure"), approvalsReviewer: setRuntimeIntentValue("guardian_subagent"), fastMode: setRuntimeIntentValue("enabled"), }, @@ -569,45 +593,67 @@ describe("runtime settings", () => { expect(resolveRuntimeControls(configured, snapshotConfig(configured))).toMatchObject({ model: { effective: "gpt-config", source: "config" }, reasoningEffort: { effective: "medium", source: "config" }, + permissionProfile: { effective: null, source: "none" }, + sandboxPolicy: { effective: { type: "workspaceWrite", writableRoots: ["/vault"], networkAccess: false }, source: "config" }, + approvalPolicy: { effective: "on-request", source: "config" }, approvalsReviewer: { effective: "auto_review", source: "config" }, serviceTier: { effective: "fast", source: "config" }, }); expect(resolveRuntimeControls(active, snapshotConfig(active))).toMatchObject({ model: { effective: "gpt-active", source: "active-thread" }, reasoningEffort: { effective: "high", source: "active-thread" }, + permissionProfile: { effective: ":read-only", source: "active-thread" }, + sandboxPolicy: { effective: { type: "readOnly", networkAccess: false }, source: "active-thread" }, + approvalPolicy: { effective: "never", source: "active-thread" }, approvalsReviewer: { effective: "user", source: "active-thread" }, serviceTier: { effective: "flex", source: "active-thread" }, }); expect(resolveRuntimeControls(pending, snapshotConfig(pending))).toMatchObject({ - model: { effective: "gpt-pending", source: "pending" }, - reasoningEffort: { effective: "low", source: "pending" }, - approvalsReviewer: { effective: "guardian_subagent", source: "pending" }, - serviceTier: { effective: "fast", source: "pending" }, - fastMode: { active: true, source: "pending", serviceTierRequestValue: "fast" }, + model: { confirmed: "gpt-active", confirmedSource: "active-thread", effective: "gpt-pending", source: "pending" }, + reasoningEffort: { confirmed: "high", confirmedSource: "active-thread", effective: "low", source: "pending" }, + permissionProfile: { confirmed: ":read-only", confirmedSource: "active-thread", effective: ":workspace", source: "pending" }, + sandboxPolicy: { + confirmed: { type: "readOnly", networkAccess: false }, + confirmedSource: "active-thread", + effective: null, + source: "pending", + }, + approvalPolicy: { confirmed: "never", confirmedSource: "active-thread", effective: "on-failure", source: "pending" }, + approvalsReviewer: { confirmed: "user", confirmedSource: "active-thread", effective: "guardian_subagent", source: "pending" }, + serviceTier: { confirmed: "flex", confirmedSource: "active-thread", effective: "fast", source: "pending" }, + fastMode: { + active: true, + confirmedActive: false, + source: "pending", + confirmedSource: "active-thread", + serviceTierRequestValue: "fast", + }, }); }); it("reports collaboration mode dirtiness and missing model blockers from the resolved runtime", () => { const blocked = runtimeSnapshot({ - pending: { collaborationMode: "plan" }, + pending: { collaborationMode: { kind: "set", value: "plan" } }, runtimeConfig: runtimeConfigFixture({}), }); const ready = runtimeSnapshot({ pending: { - collaborationMode: "plan", + collaborationMode: { kind: "set", value: "plan" }, model: setRuntimeIntentValue("gpt-5.5"), }, }); expect(resolveRuntimeControls(blocked, snapshotConfig(blocked)).collaborationMode).toMatchObject({ - selected: "plan", - effective: "default", + pending: { kind: "set", value: "plan" }, + confirmed: "default", + effective: "plan", dirty: true, blockedReason: "missing-model", }); expect(resolveRuntimeControls(ready, snapshotConfig(ready)).collaborationMode).toMatchObject({ - selected: "plan", - effective: "default", + pending: { kind: "set", value: "plan" }, + confirmed: "default", + effective: "plan", dirty: true, blockedReason: null, }); @@ -615,8 +661,10 @@ describe("runtime settings", () => { it("resolves requested approval reviewer without adding it to turn runtime settings", () => { const snapshot = runtimeSnapshot({ pending: { approvalsReviewer: setRuntimeIntentValue("auto_review") } }); + const resolution = resolveRuntimeControls(snapshot, snapshotConfig(snapshot)); expect(autoReviewActive(snapshot, snapshotConfig(snapshot))).toBe(true); + expect(resolution.autoReview).toMatchObject({ active: true, confirmedActive: false, source: "pending", confirmedSource: "none" }); expect(pendingRuntimeSettingsPatch(snapshot, snapshotConfig(snapshot))).toMatchObject({ update: { approvalsReviewer: "auto_review" }, }); @@ -871,6 +919,9 @@ function runtimeSnapshot(overrides: RuntimeSnapshotPatch = {}): RuntimeSnapshot }), activeThreadId: null, active: { + approvalPolicyKnown: false, + sandboxPolicyKnown: false, + permissionProfileKnown: false, serviceTierKnown: false, model: null, reasoningEffort: null, @@ -887,7 +938,7 @@ function runtimeSnapshot(overrides: RuntimeSnapshotPatch = {}): RuntimeSnapshot permissionProfile: { kind: "unchanged" }, approvalPolicy: { kind: "unchanged" }, approvalsReviewer: { kind: "unchanged" }, - collaborationMode: "default", + collaborationMode: { kind: "unchanged" }, fastMode: { kind: "unchanged" }, }, tokenUsage: null, diff --git a/tests/runtime/service-tier-state.test.ts b/tests/runtime/service-tier-state.test.ts index 4f5f374e..3d0f33ea 100644 --- a/tests/runtime/service-tier-state.test.ts +++ b/tests/runtime/service-tier-state.test.ts @@ -23,6 +23,9 @@ function fastMode(serviceTier: string | null, serviceTiers: ModelMetadata["servi runtimeConfig: config, activeThreadId: "thread", active: { + approvalPolicyKnown: false, + sandboxPolicyKnown: false, + permissionProfileKnown: false, serviceTierKnown: true, model: "gpt-5.5", reasoningEffort: null, @@ -39,7 +42,7 @@ function fastMode(serviceTier: string | null, serviceTiers: ModelMetadata["servi permissionProfile: { kind: "unchanged" }, approvalPolicy: { kind: "unchanged" }, approvalsReviewer: { kind: "unchanged" }, - collaborationMode: "default", + collaborationMode: { kind: "unchanged" }, fastMode: { kind: "unchanged" }, }, tokenUsage: null,