Align permission runtime resolution

This commit is contained in:
murashit 2026-07-01 19:13:45 +09:00
parent 6575ad7bf3
commit 070fd261fc
40 changed files with 587 additions and 203 deletions

View file

@ -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),

View file

@ -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<RuntimePermissionState> | null | undefined): RuntimePermissionState {
return {
approvalPolicy: value?.approvalPolicy ? cloneRuntimeApprovalPolicy(value.approvalPolicy) : null,

View file

@ -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;

View file

@ -20,7 +20,12 @@ export function implementPlanTargetFromState(state: {
runtime: { pending: Pick<ChatRuntimeState["pending"], "collaborationMode"> };
messageStream: Pick<ChatMessageStreamState, "stableItems" | "activeSegment">;
}): 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));

View file

@ -153,8 +153,8 @@ async function setFastMode(host: RuntimeSettingsActionsHost, mode: FastModeState
}
async function toggleCollaborationMode(host: RuntimeSettingsActionsHost): Promise<void> {
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);
}

View file

@ -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,
};
}

View file

@ -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" },
},
},
});

View file

@ -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<T>(): PendingRuntimeIntent<T> {
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);
}

View file

@ -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<T> {
readonly configured: T | null;
readonly active: T | null;
readonly pending: PendingRuntimeIntent<T>;
readonly confirmed: T | null;
readonly confirmedSource: RuntimeValueSource;
readonly effective: T | null;
readonly source: RuntimeValueSource;
}
interface RuntimeNullableLayeredValue<T> {
readonly configured: T | null;
readonly active: T | null;
readonly pending: PendingRuntimeIntent<T | null>;
readonly confirmed: T | null;
readonly confirmedSource: RuntimeValueSource;
readonly effective: T | null;
readonly source: RuntimeValueSource;
}
@ -19,32 +38,30 @@ interface RuntimeLayeredValue<T> {
interface FastModeResolution {
readonly requested: PendingRuntimeIntent<RequestedFastMode>;
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<RuntimeSnapshot["active"]["collaborationMode"]>;
readonly effective: NonNullable<RuntimeSnapshot["active"]["collaborationMode"]>;
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<string>;
readonly reasoningEffort: RuntimeLayeredValue<ReasoningEffort>;
@ -52,7 +69,9 @@ export interface RuntimeControlsResolution {
readonly serviceTier: RuntimeLayeredValue<ServiceTier>;
readonly fastMode: FastModeResolution;
readonly collaborationMode: CollaborationModeResolution;
readonly permissions: RuntimePermissionsResolution;
readonly permissionProfile: RuntimeLayeredValue<string>;
readonly sandboxPolicy: RuntimeNullableLayeredValue<RuntimeSandboxPolicy>;
readonly approvalPolicy: RuntimeLayeredValue<RuntimeApprovalPolicy>;
readonly approvalsReviewer: RuntimeLayeredValue<ApprovalsReviewer>;
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<ApprovalsReviewer>): 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<RuntimeSandboxPolicy> {
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<RuntimePermissionsResolution, "effective" | "source"> {
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<string>,
config: RuntimeConfigSnapshot,
): PendingRuntimeIntent<RuntimeSandboxPolicy | null> {
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<T>(intent: PendingRuntimeIntent<T>, 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<T>(input: {
configured: T | null | undefined;
active: T | null | undefined;
pending: PendingRuntimeIntent<T>;
activeKnown: boolean;
}): RuntimeLayeredValue<T> {
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<T>(input: {
configured: T | null | undefined;
active: T | null | undefined;
pending: PendingRuntimeIntent<T | null>;
activeKnown: boolean;
}): RuntimeNullableLayeredValue<T> {
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<T>(input: {
@ -178,19 +219,26 @@ function resolveRuntimeValue<T>(input: {
const configured = input.configured ?? null;
const active = input.active ?? null;
const pending = input.pending ?? ({ kind: "unchanged" } satisfies PendingRuntimeIntent<T>);
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<T>(
configured: T | null | undefined,
active: T | null | undefined,
activeKnown: boolean,
): Pick<RuntimeLayeredValue<T>, "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<ServiceTier> {
@ -220,10 +268,17 @@ function serviceTierValue(
effective: ServiceTier | null,
source: RuntimeValueSource,
): RuntimeLayeredValue<ServiceTier> {
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,

View file

@ -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<string>;
readonly approvalPolicy: PendingRuntimeIntent<RuntimeApprovalPolicy>;
readonly approvalsReviewer: PendingRuntimeIntent<ApprovalsReviewer>;
readonly collaborationMode: CollaborationModeSelection;
readonly collaborationMode: CollaborationModeIntent;
readonly fastMode: PendingRuntimeIntent<RequestedFastMode>;
}
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<RuntimeApprovalPolicy>() } : {}),
...("approvalsReviewer" in update ? { approvalsReviewer: unchangedRuntimeIntent<ApprovalsReviewer>() } : {}),
...("permissions" in update ? { permissionProfile: unchangedRuntimeIntent<string>() } : {}),
...("collaborationMode" in update ? { collaborationMode: unchangedCollaborationModeIntent() } : {}),
},
};
}

View file

@ -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,
};
}

View file

@ -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));
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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;

View file

@ -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 () => {

View file

@ -8,6 +8,9 @@ function createHost(overrides: Partial<ChatReconnectActionsHost> = {}) {
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,

View file

@ -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,

View file

@ -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.");
});

View file

@ -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 });

View file

@ -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,

View file

@ -60,6 +60,9 @@ function createHost(overrides: TurnSubmissionHostOverrides = {}) {
function resumeThread(stateStore: ReturnType<typeof createChatStateStore>) {
stateStore.dispatch({
type: "active-thread/resumed",
approvalPolicyKnown: true,
sandboxPolicyKnown: true,
permissionProfileKnown: true,
approvalPolicy: null,
sandboxPolicy: null,
activePermissionProfile: null,

View file

@ -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,

View file

@ -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",

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -22,6 +22,9 @@ function activation(threadId: string, overrides: Partial<ThreadResumeSnapshot> =
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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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" },
],
},
]);

View file

@ -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,

View file

@ -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({

View file

@ -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: {

View file

@ -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();
});

View file

@ -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();
});

View file

@ -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,

View file

@ -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,