mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Add permission profile runtime selection
This commit is contained in:
parent
60dba267ba
commit
d2cb61e097
37 changed files with 570 additions and 38 deletions
|
|
@ -46,7 +46,7 @@ Each panel can keep a separate conversation, so related work can stay open side
|
|||
|
||||
The composer understands Obsidian links. It suggests vault files and recent notes, keeps wikilinks readable, includes linked files with your prompt, and opens file links from Codex replies back in Obsidian. Use `@active-note` or `@selection` to include the active note or current Markdown selection without pasting it manually. Paste or drop files to save them in the attachment folder and reference them from the same prompt.
|
||||
|
||||
Completions cover slash commands, `$skill-name` skills, recent threads, models, and reasoning levels. Use `/help` for the current command list.
|
||||
Completions cover slash commands, `$skill-name` skills, recent threads, models, reasoning levels, and permission profiles. Use `/help` for the current command list.
|
||||
|
||||
While a turn is running, the panel streams the conversation, reasoning, tool activity, file changes, plans, and agent activity. You can answer Codex questions, approve commands, respond to requests, steer or interrupt the turn, and manage active goals above the message stream.
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import type { HooksListResponse } from "../../generated/app-server/v2/HooksListR
|
|||
import type { ListMcpServerStatusResponse } from "../../generated/app-server/v2/ListMcpServerStatusResponse";
|
||||
import type { ModelListResponse } from "../../generated/app-server/v2/ModelListResponse";
|
||||
import type { ModelProviderCapabilitiesReadResponse } from "../../generated/app-server/v2/ModelProviderCapabilitiesReadResponse";
|
||||
import type { PermissionProfileListResponse } from "../../generated/app-server/v2/PermissionProfileListResponse";
|
||||
import type { PluginInstalledResponse } from "../../generated/app-server/v2/PluginInstalledResponse";
|
||||
import type { PluginReadResponse } from "../../generated/app-server/v2/PluginReadResponse";
|
||||
import type { SkillsListResponse } from "../../generated/app-server/v2/SkillsListResponse";
|
||||
|
|
@ -82,6 +83,7 @@ export interface ClientResponseByMethod {
|
|||
"plugin/installed": PluginInstalledResponse;
|
||||
"plugin/read": PluginReadResponse;
|
||||
"model/list": ModelListResponse;
|
||||
"permissionProfile/list": PermissionProfileListResponse;
|
||||
"account/rateLimits/read": GetAccountRateLimitsResponse;
|
||||
"mcpServerStatus/list": ListMcpServerStatusResponse;
|
||||
"collaborationMode/list": CollaborationModeListResponse;
|
||||
|
|
|
|||
|
|
@ -36,15 +36,15 @@ export function runtimeConfigSnapshotFromAppServerConfig(response: ConfigReadRes
|
|||
}
|
||||
|
||||
function startupPermissionsFromConfig(config: Record<string, unknown>): RuntimePermissionState {
|
||||
const activePermissionProfile = permissionProfileFromConfig(config);
|
||||
return {
|
||||
approvalPolicy: approvalPolicyOrNull(config["approval_policy"]),
|
||||
sandboxPolicy: sandboxPolicyFromConfig(config),
|
||||
activePermissionProfile: permissionProfileFromConfig(config),
|
||||
sandboxPolicy: activePermissionProfile ? null : sandboxPolicyFromConfig(config),
|
||||
activePermissionProfile,
|
||||
};
|
||||
}
|
||||
|
||||
function permissionProfileFromConfig(config: Record<string, unknown>): RuntimePermissionState["activePermissionProfile"] {
|
||||
if (typeof config["sandbox_mode"] === "string") return null;
|
||||
const profile = nonEmptyStringOrNull(config["default_permissions"]);
|
||||
return profile ? { id: profile, extends: null } : null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
archivedThreadsQueryKey,
|
||||
cloneAppServerQueryContext,
|
||||
} from "./keys";
|
||||
import { readRateLimitMetadataProbe, readSkillMetadataProbe } from "./metadata-probes";
|
||||
import { readPermissionProfileMetadataProbe, readRateLimitMetadataProbe, readSkillMetadataProbe } from "./metadata-probes";
|
||||
import type { ObservedResult, ObservedResultListener } from "./observed-result";
|
||||
import { cloneModelMetadata, cloneSharedServerMetadata, cloneThreads } from "./snapshots";
|
||||
|
||||
|
|
@ -194,6 +194,8 @@ export class AppServerQueryCache {
|
|||
...metadata,
|
||||
availableModels: probes.models.status === "ok" ? metadata.availableModels : (this.modelsSnapshot(context) ?? []),
|
||||
availableSkills: probes.skills.status === "ok" ? metadata.availableSkills : (previous?.availableSkills ?? []),
|
||||
availablePermissionProfiles:
|
||||
probes.permissionProfiles.status === "ok" ? metadata.availablePermissionProfiles : (previous?.availablePermissionProfiles ?? []),
|
||||
rateLimit: probes.rateLimits.status === "ok" ? metadata.rateLimit : (previous?.rateLimit ?? null),
|
||||
});
|
||||
this.client.setQueryData(appServerMetadataQueryKey(context), cloneSharedServerMetadata(next));
|
||||
|
|
@ -273,12 +275,13 @@ export class AppServerQueryCache {
|
|||
queryFn: async (): Promise<SharedServerMetadata> => {
|
||||
return this.runWithClient(refreshContext, async (client) => {
|
||||
const runtimeConfig = runtimeConfigSnapshotFromAppServerConfig(await readEffectiveConfig(client, refreshContext.vaultPath));
|
||||
const [models, skills, rateLimit] = await Promise.all([
|
||||
const [models, skills, permissionProfiles, rateLimit] = await Promise.all([
|
||||
this.readModelMetadataProbe(refreshContext, client),
|
||||
readSkillMetadataProbe(client, refreshContext.vaultPath, options.forceSkills ?? false),
|
||||
readPermissionProfileMetadataProbe(client, refreshContext.vaultPath),
|
||||
readRateLimitMetadataProbe(client),
|
||||
]);
|
||||
const diagnostics = [models.probe, skills.probe, rateLimit.probe].reduce(
|
||||
const diagnostics = [models.probe, skills.probe, permissionProfiles.probe, rateLimit.probe].reduce(
|
||||
(current, probe) => diagnosticsWithProbe(current, probe),
|
||||
this.appServerMetadataSnapshot(refreshContext)?.serverDiagnostics ?? createServerDiagnostics(),
|
||||
);
|
||||
|
|
@ -286,6 +289,7 @@ export class AppServerQueryCache {
|
|||
runtimeConfig,
|
||||
availableModels: models.value,
|
||||
availableSkills: skills.value,
|
||||
availablePermissionProfiles: permissionProfiles.value,
|
||||
rateLimit: rateLimit.value,
|
||||
serverDiagnostics: diagnostics,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import type { SkillMetadata } from "../../domain/catalog/metadata";
|
||||
import type { RateLimitSnapshot } from "../../domain/runtime/metrics";
|
||||
import type { RuntimePermissionProfileSummary } from "../../domain/runtime/permissions";
|
||||
import { type Diagnostics, diagnosticProbeError, diagnosticProbeOk } from "../../domain/server/diagnostics";
|
||||
import { accountRateLimitsSummaryFromResponse, rateLimitSnapshotFromAccountRateLimitsResponse } from "../protocol/runtime-metrics";
|
||||
import { listSkillCatalog } from "../services/catalog";
|
||||
import { listPermissionProfiles, listSkillCatalog } from "../services/catalog";
|
||||
import type { AppServerRequestClient } from "../services/request-client";
|
||||
import { readAccountRateLimits } from "../services/runtime-metadata";
|
||||
|
||||
|
|
@ -12,6 +13,7 @@ interface MetadataProbeResult<T, K extends keyof Diagnostics["probes"]> {
|
|||
}
|
||||
|
||||
export type SkillMetadataProbeResult = MetadataProbeResult<SkillMetadata[], "skills">;
|
||||
export type PermissionProfileMetadataProbeResult = MetadataProbeResult<RuntimePermissionProfileSummary[], "permissionProfiles">;
|
||||
export type RateLimitMetadataProbeResult = MetadataProbeResult<RateLimitSnapshot | null, "rateLimits">;
|
||||
|
||||
export async function readSkillMetadataProbe(
|
||||
|
|
@ -30,6 +32,24 @@ export async function readSkillMetadataProbe(
|
|||
}
|
||||
}
|
||||
|
||||
export async function readPermissionProfileMetadataProbe(
|
||||
client: AppServerRequestClient | null,
|
||||
vaultPath: string,
|
||||
): Promise<PermissionProfileMetadataProbeResult> {
|
||||
if (!client) {
|
||||
return {
|
||||
value: [],
|
||||
probe: diagnosticProbeError("permissionProfiles", new Error("Codex app-server is not connected."), Date.now()),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const profiles = await listPermissionProfiles(client, vaultPath);
|
||||
return { value: profiles, probe: diagnosticProbeOk("permissionProfiles", `${String(profiles.length)} profiles`, Date.now()) };
|
||||
} catch (error) {
|
||||
return { value: [], probe: diagnosticProbeError("permissionProfiles", error, Date.now()) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function readRateLimitMetadataProbe(client: AppServerRequestClient | null): Promise<RateLimitMetadataProbeResult> {
|
||||
if (!client) {
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export function cloneSharedServerMetadata(metadata: SharedServerMetadata): Share
|
|||
rateLimit: metadata.rateLimit ? cloneRateLimitSnapshot(metadata.rateLimit) : null,
|
||||
availableModels: cloneModelMetadata(metadata.availableModels),
|
||||
availableSkills: metadata.availableSkills.map((skill) => ({ ...skill })),
|
||||
availablePermissionProfiles: metadata.availablePermissionProfiles.map((profile) => ({ ...profile })),
|
||||
serverDiagnostics: {
|
||||
probes: { ...metadata.serverDiagnostics.probes },
|
||||
mcpServers: metadata.serverDiagnostics.mcpServers.map((server) => ({ ...server })),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import type { HookItem, ModelMetadata, SkillMetadata } from "../../domain/catalog/metadata";
|
||||
import type { RuntimePermissionProfileSummary } from "../../domain/runtime/permissions";
|
||||
import type { ClientResponseByMethod } from "../connection/client";
|
||||
import type { ClientRequestParams } from "../connection/rpc-messages";
|
||||
import {
|
||||
type AppServerHookOperation,
|
||||
|
|
@ -27,6 +29,29 @@ export async function listModelMetadata(client: ModelMetadataClient, options: {
|
|||
return modelMetadataFromCatalogModels(response.data);
|
||||
}
|
||||
|
||||
export async function listPermissionProfiles(client: AppServerRequestClient, cwd: string): Promise<RuntimePermissionProfileSummary[]> {
|
||||
const profiles: RuntimePermissionProfileSummary[] = [];
|
||||
const seenCursors = new Set<string>();
|
||||
let cursor: string | null = null;
|
||||
|
||||
for (;;) {
|
||||
const response: ClientResponseByMethod["permissionProfile/list"] = await client.request("permissionProfile/list", {
|
||||
cwd,
|
||||
cursor,
|
||||
limit: 100,
|
||||
});
|
||||
profiles.push(...response.data.map((profile) => ({ ...profile })));
|
||||
cursor = response.nextCursor ?? null;
|
||||
if (!cursor) break;
|
||||
if (seenCursors.has(cursor)) {
|
||||
throw new Error("Codex app-server returned a repeated permission profile list cursor.");
|
||||
}
|
||||
seenCursors.add(cursor);
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
export async function listSkillCatalog(
|
||||
client: AppServerRequestClient,
|
||||
cwd: string,
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ interface ThreadActivationResponse extends Partial<RuntimePermissionState> {
|
|||
export interface AppServerStartThreadOptions {
|
||||
cwd: string;
|
||||
serviceTier?: RuntimeServiceTierRequest;
|
||||
permissions?: RuntimeSettingsPatch["permissions"];
|
||||
}
|
||||
|
||||
export interface AppServerStartEphemeralThreadOptions {
|
||||
|
|
@ -66,11 +67,12 @@ export function startThread(
|
|||
client: AppServerRequestClient,
|
||||
options: AppServerStartThreadOptions,
|
||||
): Promise<ClientResponseByMethod["thread/start"]> {
|
||||
const { cwd, serviceTier } = options;
|
||||
const { cwd, serviceTier, permissions } = options;
|
||||
return client.request("thread/start", {
|
||||
cwd,
|
||||
serviceName: "codex-panel",
|
||||
...(serviceTier !== undefined ? { serviceTier } : {}),
|
||||
...(permissions !== undefined ? { permissions } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,12 @@ export interface RuntimePermissionKnownState {
|
|||
readonly permissionProfileKnown: boolean;
|
||||
}
|
||||
|
||||
export interface RuntimePermissionProfileSummary {
|
||||
readonly id: string;
|
||||
readonly description: string | null;
|
||||
readonly allowed: boolean;
|
||||
}
|
||||
|
||||
export function initialRuntimePermissionState(): RuntimePermissionState {
|
||||
return {
|
||||
approvalPolicy: null,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { cloneToolInventorySnapshot, type ToolInventorySnapshot } from "./tool-i
|
|||
const DIAGNOSTIC_PROBE_DEFINITIONS = {
|
||||
models: { label: "Models" },
|
||||
skills: { label: "Skills" },
|
||||
permissionProfiles: { label: "Permission profiles" },
|
||||
apps: { label: "Apps" },
|
||||
plugins: { label: "Plugins" },
|
||||
rateLimits: { label: "Rate limits" },
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import type { ModelMetadata, SkillMetadata } from "../catalog/metadata";
|
||||
import type { RuntimeConfigSnapshot } from "../runtime/config";
|
||||
import type { RateLimitSnapshot } from "../runtime/metrics";
|
||||
import type { RuntimePermissionProfileSummary } from "../runtime/permissions";
|
||||
import type { Diagnostics } from "./diagnostics";
|
||||
|
||||
export interface SharedServerMetadata {
|
||||
runtimeConfig: RuntimeConfigSnapshot | null;
|
||||
availableModels: readonly ModelMetadata[];
|
||||
availableSkills: readonly SkillMetadata[];
|
||||
availablePermissionProfiles: readonly RuntimePermissionProfileSummary[];
|
||||
rateLimit: RateLimitSnapshot | null;
|
||||
serverDiagnostics: Diagnostics;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ function applyAppServerMetadata(host: ChatServerMetadataActionsHost, metadata: S
|
|||
runtimeConfig: metadata.runtimeConfig,
|
||||
availableModels: metadata.availableModels,
|
||||
availableSkills: metadata.availableSkills,
|
||||
availablePermissionProfiles: metadata.availablePermissionProfiles,
|
||||
rateLimit: metadata.rateLimit,
|
||||
serverDiagnostics: metadata.serverDiagnostics,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type { ThreadCatalogEvent } from "../../../threads/catalog/thread-catalog
|
|||
import { resumedThreadAction } from "../../application/state/actions";
|
||||
import type { ChatState } from "../../application/state/root-reducer";
|
||||
import type { RuntimeSnapshot } from "../../domain/runtime/snapshot";
|
||||
import { serviceTierRequestForThreadStart } from "../../domain/runtime/thread-settings-patch";
|
||||
import { permissionProfileRequestForThreadStart, serviceTierRequestForThreadStart } from "../../domain/runtime/thread-settings-patch";
|
||||
import { type ChatServerActionsHost, captureChatServerClientScope } from "./host";
|
||||
|
||||
interface StartedThreadSummary {
|
||||
|
|
@ -47,11 +47,11 @@ async function startThread(
|
|||
const scope = captureChatServerClientScope(host);
|
||||
if (!scope.client) return null;
|
||||
const requestState = host.stateStore.getState();
|
||||
const serviceTier = serviceTierRequestForThreadStart(
|
||||
host.runtimeSnapshotForState(requestState),
|
||||
runtimeConfigOrDefault(requestState.connection.runtimeConfig),
|
||||
);
|
||||
const response = await startAppServerThread(scope.client, { cwd: host.vaultPath, serviceTier });
|
||||
const runtimeSnapshot = host.runtimeSnapshotForState(requestState);
|
||||
const runtimeConfig = runtimeConfigOrDefault(requestState.connection.runtimeConfig);
|
||||
const serviceTier = serviceTierRequestForThreadStart(runtimeSnapshot, runtimeConfig);
|
||||
const permissions = permissionProfileRequestForThreadStart(runtimeSnapshot, runtimeConfig);
|
||||
const response = await startAppServerThread(scope.client, { cwd: host.vaultPath, serviceTier, permissions });
|
||||
if (scope.isStale()) return null;
|
||||
const state = host.stateStore.getState();
|
||||
const fallbackPreview = preview?.trim();
|
||||
|
|
|
|||
|
|
@ -131,10 +131,10 @@ export const SLASH_COMMANDS = [
|
|||
},
|
||||
{
|
||||
command: "/permissions",
|
||||
usage: "/permissions",
|
||||
argsKind: "none",
|
||||
surface: "diagnostic",
|
||||
detail: "Show current permissions and approval settings.",
|
||||
usage: "/permissions [profile|default]",
|
||||
argsKind: "showOrSet",
|
||||
surface: "threadSetting",
|
||||
detail: "Show or set the permission profile for subsequent turns.",
|
||||
},
|
||||
{
|
||||
command: "/doctor",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { prepareFuzzySearch, type SearchResult, sortSearchResults } from "obsidian";
|
||||
import type { ModelMetadata, SkillMetadata } from "../../../../domain/catalog/metadata";
|
||||
import { findModelMetadataByIdOrName, sortedModelMetadata, supportedEffortsForModelMetadata } from "../../../../domain/catalog/metadata";
|
||||
import type { RuntimePermissionProfileSummary } from "../../../../domain/runtime/permissions";
|
||||
import { shortThreadId } from "../../../../domain/threads/id";
|
||||
import type { Thread } from "../../../../domain/threads/model";
|
||||
import { threadDisplayTitle } from "../../../../domain/threads/title";
|
||||
|
|
@ -29,6 +30,7 @@ export interface ComposerSuggestion {
|
|||
export interface ComposerSuggestionOptions {
|
||||
activeThreadId?: string | null;
|
||||
contextReferences?: ComposerContextReferences;
|
||||
permissionProfiles?: readonly RuntimePermissionProfileSummary[];
|
||||
}
|
||||
|
||||
export interface NoteCandidate {
|
||||
|
|
@ -109,6 +111,7 @@ export function activeComposerSuggestions(
|
|||
activeThreadCommandSuggestions(beforeCursor, threads, options.activeThreadId ?? null) ??
|
||||
modelOverrideSuggestions(beforeCursor, models) ??
|
||||
reasoningEffortOverrideSuggestions(beforeCursor, models, currentModel) ??
|
||||
permissionProfileOverrideSuggestions(beforeCursor, options.permissionProfiles ?? []) ??
|
||||
activeSlashCommandSuggestions(beforeCursor) ??
|
||||
activeSkillSuggestions(beforeCursor, skills) ??
|
||||
[]
|
||||
|
|
@ -488,15 +491,65 @@ function reasoningEffortOverrideSuggestions(
|
|||
return suggestions.filter((item) => item.display.toLowerCase().startsWith(query)).slice(0, 8);
|
||||
}
|
||||
|
||||
function activeCommandArgumentCompletionQuery(beforeCursor: string, pattern: RegExp): { query: string; start: number } | null {
|
||||
function permissionProfileOverrideSuggestions(
|
||||
beforeCursor: string,
|
||||
profiles: readonly RuntimePermissionProfileSummary[],
|
||||
): ComposerSuggestion[] | null {
|
||||
const completion = activeCommandArgumentCompletionQuery(beforeCursor, /^\/permissions\s+([^\n]{0,120})$/);
|
||||
if (!completion) return null;
|
||||
|
||||
const { query, rawQuery, start } = completion;
|
||||
const allowedProfiles = profiles.filter((profile) => profile.allowed);
|
||||
if (query === "default" || allowedProfiles.some((profile) => profile.id === rawQuery)) return null;
|
||||
const suggestions = [
|
||||
{
|
||||
display: "default",
|
||||
detail: "Reset permission profile",
|
||||
replacement: "default",
|
||||
start,
|
||||
appendSpaceOnInsert: true,
|
||||
},
|
||||
...allowedProfiles.map((profile) => ({
|
||||
display: profile.id,
|
||||
detail: profile.description ?? "Permission profile",
|
||||
replacement: profile.id,
|
||||
start,
|
||||
appendSpaceOnInsert: true,
|
||||
})),
|
||||
];
|
||||
|
||||
return permissionProfileSuggestionsForQuery(suggestions, query).slice(0, 8);
|
||||
}
|
||||
|
||||
function permissionProfileSuggestionsForQuery(suggestions: ComposerSuggestion[], query: string): ComposerSuggestion[] {
|
||||
if (query.length === 0) return suggestions;
|
||||
|
||||
return suggestions
|
||||
.map((suggestion) => ({ suggestion, score: permissionProfileSuggestionScore(suggestion, query) }))
|
||||
.filter((item): item is { suggestion: ComposerSuggestion; score: number } => item.score !== null)
|
||||
.sort((left, right) => left.score - right.score)
|
||||
.map((item) => item.suggestion);
|
||||
}
|
||||
|
||||
function permissionProfileSuggestionScore(suggestion: ComposerSuggestion, query: string): number | null {
|
||||
if (suggestion.display.toLowerCase().startsWith(query)) return 0;
|
||||
if (suggestion.display === "default") return null;
|
||||
return suggestion.detail.toLowerCase().includes(query) ? 1 : null;
|
||||
}
|
||||
|
||||
function activeCommandArgumentCompletionQuery(
|
||||
beforeCursor: string,
|
||||
pattern: RegExp,
|
||||
): { query: string; rawQuery: string; start: number } | null {
|
||||
const match = pattern.exec(beforeCursor);
|
||||
if (!match) return null;
|
||||
|
||||
const rawQuery = match[1];
|
||||
if (rawQuery === undefined) return null;
|
||||
const query = rawQuery.trim().toLowerCase();
|
||||
const trimmedQuery = rawQuery.trim();
|
||||
const query = trimmedQuery.toLowerCase();
|
||||
if (query.length > 0 && /\s$/.test(rawQuery)) return null;
|
||||
return { query, start: beforeCursor.length - rawQuery.length };
|
||||
return { query, rawQuery: trimmedQuery, start: beforeCursor.length - rawQuery.length };
|
||||
}
|
||||
|
||||
function activeSkillSuggestions(beforeCursor: string, skills: readonly SkillMetadata[]): ComposerSuggestion[] | null {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type { Thread } from "../../../../domain/threads/model";
|
|||
import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference";
|
||||
import { threadDisplayTitle } from "../../../../domain/threads/title";
|
||||
import type { MessageStreamAuditFact, MessageStreamNoticeSection } from "../../domain/message-stream/items";
|
||||
import { modelOverrideMessage, reasoningEffortOverrideMessage } from "../../domain/runtime/labels";
|
||||
import { modelOverrideMessage, permissionProfileOverrideMessage, reasoningEffortOverrideMessage } from "../../domain/runtime/labels";
|
||||
import {
|
||||
type SlashCommandName,
|
||||
type SlashCommandSubcommandDefinition,
|
||||
|
|
@ -42,6 +42,8 @@ export interface SlashCommandExecutionPorts {
|
|||
toggleAutoReview: ChatRuntimeSettingsActions["toggleAutoReview"];
|
||||
requestModel: ChatRuntimeSettingsActions["requestModel"];
|
||||
resetModelToConfig: ChatRuntimeSettingsActions["resetModelToConfig"];
|
||||
requestPermissionProfile: ChatRuntimeSettingsActions["requestPermissionProfile"];
|
||||
resetPermissionProfileToConfig: ChatRuntimeSettingsActions["resetPermissionProfileToConfig"];
|
||||
requestReasoningEffort: ChatRuntimeSettingsActions["requestReasoningEffort"];
|
||||
resetReasoningEffortToConfig: ChatRuntimeSettingsActions["resetReasoningEffortToConfig"];
|
||||
};
|
||||
|
|
@ -184,9 +186,17 @@ export async function executeSlashCommand(
|
|||
case "status":
|
||||
context.addStructuredSystemMessage("Thread status", detailsFromLines(context.statusSummaryLines()));
|
||||
return;
|
||||
case "permissions":
|
||||
case "permissions": {
|
||||
const requested = parsePermissionProfileOverride(args);
|
||||
if (requested !== undefined) {
|
||||
const applied = await applyPermissionProfileOverride(context, requested);
|
||||
if (applied === false) return;
|
||||
context.addSystemMessage(permissionProfileOverrideMessage(requested));
|
||||
return;
|
||||
}
|
||||
context.addStructuredSystemMessage("Permissions & Approvals", context.permissionDetails());
|
||||
return;
|
||||
}
|
||||
case "doctor":
|
||||
context.addStructuredSystemMessage("Connection diagnostics", context.connectionDiagnosticDetails());
|
||||
return;
|
||||
|
|
@ -238,6 +248,15 @@ function applyModelOverride(
|
|||
return requested === null ? context.runtimeSettings.resetModelToConfig() : context.runtimeSettings.requestModel(requested);
|
||||
}
|
||||
|
||||
function applyPermissionProfileOverride(
|
||||
context: SlashCommandExecutionContext,
|
||||
requested: string | null,
|
||||
): boolean | undefined | Promise<boolean | undefined> {
|
||||
return requested === null
|
||||
? context.runtimeSettings.resetPermissionProfileToConfig()
|
||||
: context.runtimeSettings.requestPermissionProfile(requested);
|
||||
}
|
||||
|
||||
function applyReasoningEffortOverride(
|
||||
context: SlashCommandExecutionContext,
|
||||
requested: ReasoningEffort | null,
|
||||
|
|
@ -254,6 +273,13 @@ function parseModelOverride(args: string): string | null | undefined {
|
|||
return model;
|
||||
}
|
||||
|
||||
function parsePermissionProfileOverride(args: string): string | null | undefined {
|
||||
const profile = args.trim();
|
||||
if (!profile) return undefined;
|
||||
if (profile.toLowerCase() === "default") return null;
|
||||
return profile;
|
||||
}
|
||||
|
||||
function parseReasoningEffortOverride(args: string): ReasoningEffort | null | undefined {
|
||||
const effort = args.trim();
|
||||
if (!effort) return undefined;
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ export interface ChatRuntimeSettingsActions {
|
|||
resetReasoningEffortToConfig: () => Promise<boolean>;
|
||||
requestReasoningEffortFromUi: (effort: ReasoningEffort) => Promise<void>;
|
||||
resetReasoningEffortToConfigFromUi: () => Promise<void>;
|
||||
requestPermissionProfile: (permissionProfile: string) => Promise<boolean>;
|
||||
resetPermissionProfileToConfig: () => Promise<boolean>;
|
||||
enableFastMode: () => Promise<void>;
|
||||
disableFastMode: () => Promise<void>;
|
||||
toggleFastMode: () => Promise<void>;
|
||||
|
|
@ -59,6 +61,8 @@ export function createChatRuntimeSettingsActions(host: RuntimeSettingsActionsHos
|
|||
resetReasoningEffortToConfig: () => resetReasoningEffortToConfig(host),
|
||||
requestReasoningEffortFromUi: (effort) => requestReasoningEffortFromUi(host, effort),
|
||||
resetReasoningEffortToConfigFromUi: () => resetReasoningEffortToConfigFromUi(host),
|
||||
requestPermissionProfile: (permissionProfile) => requestPermissionProfile(host, permissionProfile),
|
||||
resetPermissionProfileToConfig: () => resetPermissionProfileToConfig(host),
|
||||
enableFastMode: () => setFastMode(host, "enabled"),
|
||||
disableFastMode: () => setFastMode(host, "disabled"),
|
||||
toggleFastMode: () => toggleFastMode(host),
|
||||
|
|
@ -135,6 +139,16 @@ async function resetReasoningEffortToConfigFromUi(host: RuntimeSettingsActionsHo
|
|||
await runRuntimeUiCommand(host, () => resetReasoningEffortToConfig(host), reasoningEffortOverrideMessage(null));
|
||||
}
|
||||
|
||||
async function requestPermissionProfile(host: RuntimeSettingsActionsHost, permissionProfile: string): Promise<boolean> {
|
||||
dispatch(host, { type: "runtime/permission-profile-requested", permissionProfile });
|
||||
return applyPendingThreadSettings(host);
|
||||
}
|
||||
|
||||
async function resetPermissionProfileToConfig(host: RuntimeSettingsActionsHost): Promise<boolean> {
|
||||
dispatch(host, { type: "runtime/permission-profile-reset-to-config" });
|
||||
return applyPendingThreadSettings(host);
|
||||
}
|
||||
|
||||
async function toggleFastMode(host: RuntimeSettingsActionsHost): Promise<void> {
|
||||
const { snapshot, config } = runtimeProjection(host);
|
||||
await setFastMode(host, resolveRuntimeControls(snapshot, config).fastMode.active ? "disabled" : "enabled");
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { ModelMetadata, ReasoningEffort, SkillMetadata } from "../../../../
|
|||
import type { PendingRequestId } from "../../../../domain/pending-requests/model";
|
||||
import type { RuntimeConfigSnapshot } from "../../../../domain/runtime/config";
|
||||
import type { RateLimitSnapshot, ThreadTokenUsage } from "../../../../domain/runtime/metrics";
|
||||
import type { RuntimePermissionProfileSummary } from "../../../../domain/runtime/permissions";
|
||||
import type { ApprovalsReviewer } from "../../../../domain/runtime/policy";
|
||||
import type { RuntimeSettingsPatch } from "../../../../domain/runtime/thread-settings";
|
||||
import type { Diagnostics } from "../../../../domain/server/diagnostics";
|
||||
|
|
@ -21,8 +22,10 @@ import {
|
|||
requestApprovalsReviewerRuntimeState,
|
||||
requestFastModeRuntimeState,
|
||||
requestModelRuntimeState,
|
||||
requestPermissionProfileRuntimeState,
|
||||
requestReasoningEffortRuntimeState,
|
||||
resetModelToConfigRuntimeState,
|
||||
resetPermissionProfileToConfigRuntimeState,
|
||||
resetReasoningEffortToConfigRuntimeState,
|
||||
setSelectedCollaborationModeRuntimeState,
|
||||
} from "../../domain/runtime/state";
|
||||
|
|
@ -93,6 +96,7 @@ interface ChatConnectionState {
|
|||
readonly serverDiagnostics: Diagnostics;
|
||||
readonly availableModels: readonly ModelMetadata[];
|
||||
readonly availableSkills: readonly SkillMetadata[];
|
||||
readonly availablePermissionProfiles: readonly RuntimePermissionProfileSummary[];
|
||||
}
|
||||
|
||||
interface ChatThreadListState {
|
||||
|
|
@ -136,6 +140,7 @@ type ConnectionAction =
|
|||
runtimeConfig?: RuntimeConfigSnapshot | null;
|
||||
availableModels?: readonly ModelMetadata[];
|
||||
availableSkills?: readonly SkillMetadata[];
|
||||
availablePermissionProfiles?: readonly RuntimePermissionProfileSummary[];
|
||||
rateLimit?: RateLimitSnapshot | null;
|
||||
serverDiagnostics?: Diagnostics;
|
||||
};
|
||||
|
|
@ -151,6 +156,8 @@ type RuntimeAction =
|
|||
| { type: "runtime/model-reset-to-config" }
|
||||
| { type: "runtime/reasoning-effort-requested"; effort: ReasoningEffort }
|
||||
| { type: "runtime/reasoning-effort-reset-to-config" }
|
||||
| { type: "runtime/permission-profile-requested"; permissionProfile: string }
|
||||
| { type: "runtime/permission-profile-reset-to-config" }
|
||||
| { type: "runtime/fast-mode-requested"; fastMode: RequestedFastMode }
|
||||
| { type: "runtime/fast-mode-request-cleared" }
|
||||
| { type: "runtime/approvals-reviewer-requested"; approvalsReviewer: ApprovalsReviewer }
|
||||
|
|
@ -495,6 +502,7 @@ function clearConnectionScopedState(state: ChatState): ChatState {
|
|||
rateLimit: null,
|
||||
availableModels: [],
|
||||
availableSkills: [],
|
||||
availablePermissionProfiles: [],
|
||||
},
|
||||
threadList: initialThreadListState(),
|
||||
});
|
||||
|
|
@ -525,6 +533,7 @@ function reduceConnectionSlice(state: ChatConnectionState, action: ChatSliceActi
|
|||
...definedPatch("runtimeConfig", action.runtimeConfig),
|
||||
...definedPatch("availableModels", action.availableModels),
|
||||
...definedPatch("availableSkills", action.availableSkills),
|
||||
...definedPatch("availablePermissionProfiles", action.availablePermissionProfiles),
|
||||
...definedPatch("rateLimit", action.rateLimit),
|
||||
...definedPatch("serverDiagnostics", action.serverDiagnostics),
|
||||
});
|
||||
|
|
@ -562,6 +571,10 @@ function reduceRuntimeSlice(state: ChatRuntimeState, action: ChatSliceAction): C
|
|||
return patchObject(state, requestReasoningEffortRuntimeState(state, action.effort));
|
||||
case "runtime/reasoning-effort-reset-to-config":
|
||||
return patchObject(state, resetReasoningEffortToConfigRuntimeState(state));
|
||||
case "runtime/permission-profile-requested":
|
||||
return patchObject(state, requestPermissionProfileRuntimeState(state, action.permissionProfile));
|
||||
case "runtime/permission-profile-reset-to-config":
|
||||
return patchObject(state, resetPermissionProfileToConfigRuntimeState(state));
|
||||
case "runtime/fast-mode-requested":
|
||||
return patchObject(state, requestFastModeRuntimeState(state, action.fastMode));
|
||||
case "runtime/fast-mode-request-cleared":
|
||||
|
|
@ -619,6 +632,7 @@ function initialConnectionState(): ChatConnectionState {
|
|||
rateLimit: null,
|
||||
availableModels: [],
|
||||
availableSkills: [],
|
||||
availablePermissionProfiles: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ function cloneChatState(state: ChatState): ChatState {
|
|||
...state.connection,
|
||||
availableModels: [...state.connection.availableModels],
|
||||
availableSkills: [...state.connection.availableSkills],
|
||||
availablePermissionProfiles: state.connection.availablePermissionProfiles.map((profile) => ({ ...profile })),
|
||||
},
|
||||
threadList: {
|
||||
listedThreads: [...state.threadList.listedThreads],
|
||||
|
|
|
|||
|
|
@ -21,6 +21,12 @@ export function reasoningEffortOverrideMessage(effort: ReasoningEffort | null):
|
|||
: `Reasoning effort set to ${effort} for subsequent turns.`;
|
||||
}
|
||||
|
||||
export function permissionProfileOverrideMessage(permissionProfile: string | null): string {
|
||||
return permissionProfile === null
|
||||
? "Permission profile reset to default for subsequent turns."
|
||||
: `Permission profile set to ${permissionProfile} for subsequent turns.`;
|
||||
}
|
||||
|
||||
export function pendingRuntimeSettingLabel(
|
||||
setting: { kind: "unchanged" } | { kind: "set"; value: unknown } | { kind: "resetToConfig" },
|
||||
): string {
|
||||
|
|
|
|||
|
|
@ -113,6 +113,20 @@ export function resetReasoningEffortToConfigRuntimeState(state: ChatRuntimeState
|
|||
};
|
||||
}
|
||||
|
||||
export function requestPermissionProfileRuntimeState(state: ChatRuntimeState, permissionProfile: string): ChatRuntimeState {
|
||||
return {
|
||||
...state,
|
||||
pending: { ...state.pending, permissionProfile: setRuntimeIntentValue(permissionProfile) },
|
||||
};
|
||||
}
|
||||
|
||||
export function resetPermissionProfileToConfigRuntimeState(state: ChatRuntimeState): ChatRuntimeState {
|
||||
return {
|
||||
...state,
|
||||
pending: { ...state.pending, permissionProfile: resetRuntimeIntentToConfig() },
|
||||
};
|
||||
}
|
||||
|
||||
export function requestFastModeRuntimeState(state: ChatRuntimeState, fastMode: RequestedFastMode): ChatRuntimeState {
|
||||
return {
|
||||
...state,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,11 @@ export function serviceTierRequestForThreadStart(snapshot: RuntimeSnapshot, conf
|
|||
return runtimeSettingsPatchValue(serviceTierPatchIntent(snapshot, resolveRuntimeControls(snapshot, config), "thread-start"));
|
||||
}
|
||||
|
||||
export function permissionProfileRequestForThreadStart(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): string | undefined {
|
||||
if (snapshot.pending.permissionProfile.kind === "set") return snapshot.pending.permissionProfile.value;
|
||||
return config.startupPermissions.activePermissionProfile?.id ?? undefined;
|
||||
}
|
||||
|
||||
export function pendingRuntimeSettingsPatch(snapshot: RuntimeSnapshot, config: RuntimeConfigSnapshot): PendingRuntimeSettingsPatch {
|
||||
const update: RuntimeSettingsPatch = {};
|
||||
const resolution = resolveRuntimeControls(snapshot, config);
|
||||
|
|
|
|||
|
|
@ -257,7 +257,11 @@ export class ChatComposerController {
|
|||
state.threadList.listedThreads,
|
||||
state.connection.availableModels,
|
||||
this.options.currentModelForSuggestions(),
|
||||
{ activeThreadId: state.activeThread.id, contextReferences: this.contextReferences() },
|
||||
{
|
||||
activeThreadId: state.activeThread.id,
|
||||
contextReferences: this.contextReferences(),
|
||||
permissionProfiles: state.connection.availablePermissionProfiles,
|
||||
},
|
||||
);
|
||||
|
||||
this.dispatchSuggestions({
|
||||
|
|
@ -302,7 +306,11 @@ export class ChatComposerController {
|
|||
state.threadList.listedThreads,
|
||||
state.connection.availableModels,
|
||||
this.options.currentModelForSuggestions(),
|
||||
{ activeThreadId: state.activeThread.id, contextReferences: this.contextReferences() },
|
||||
{
|
||||
activeThreadId: state.activeThread.id,
|
||||
contextReferences: this.contextReferences(),
|
||||
permissionProfiles: state.connection.availablePermissionProfiles,
|
||||
},
|
||||
);
|
||||
return {
|
||||
suggestions,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export function runtimePermissionSections(input: RuntimePermissionSectionsInput)
|
|||
return [
|
||||
{
|
||||
title: "Permissions",
|
||||
rows: accessRows(resolution.permissionProfile.confirmed, resolution.sandboxPolicy.confirmed, input.vaultPath),
|
||||
rows: accessRows(resolution.permissionProfile.effective, resolution.sandboxPolicy.effective, input.vaultPath),
|
||||
},
|
||||
{
|
||||
title: "Approvals",
|
||||
|
|
|
|||
|
|
@ -28,7 +28,15 @@ describe("app-server diagnostics", () => {
|
|||
it("creates generic capability probe defaults", () => {
|
||||
const diagnostics = createServerDiagnostics();
|
||||
|
||||
expect(Object.keys(diagnostics.probes)).toEqual(["models", "skills", "apps", "plugins", "rateLimits", "mcpServers"]);
|
||||
expect(Object.keys(diagnostics.probes)).toEqual([
|
||||
"models",
|
||||
"skills",
|
||||
"permissionProfiles",
|
||||
"apps",
|
||||
"plugins",
|
||||
"rateLimits",
|
||||
"mcpServers",
|
||||
]);
|
||||
expect(diagnostics.probes.models).toMatchObject({
|
||||
id: "models",
|
||||
status: "unknown",
|
||||
|
|
@ -37,6 +45,7 @@ describe("app-server diagnostics", () => {
|
|||
checkedAt: null,
|
||||
});
|
||||
expect(diagnosticProbeLabel("models")).toBe("Models");
|
||||
expect(diagnosticProbeLabel("permissionProfiles")).toBe("Permission profiles");
|
||||
});
|
||||
|
||||
it("classifies ok and failed capability probes", () => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type { AppServerQueryContext } from "../../src/app-server/query/keys";
|
|||
import type { ModelMetadata, SkillMetadata } from "../../src/domain/catalog/metadata";
|
||||
import { emptyRuntimeConfigSnapshot, type RuntimeConfigSnapshot } from "../../src/domain/runtime/config";
|
||||
import type { RateLimitSnapshot } from "../../src/domain/runtime/metrics";
|
||||
import type { RuntimePermissionProfileSummary } from "../../src/domain/runtime/permissions";
|
||||
import {
|
||||
createServerDiagnostics,
|
||||
diagnosticProbeError,
|
||||
|
|
@ -19,6 +20,7 @@ describe("AppServerQueryCache", () => {
|
|||
const context = cacheContext();
|
||||
const goodMetadata = metadata({
|
||||
availableSkills: [skillMetadata("writer")],
|
||||
availablePermissionProfiles: [permissionProfile(":workspace")],
|
||||
rateLimit: rateLimit(42),
|
||||
});
|
||||
|
||||
|
|
@ -30,14 +32,17 @@ describe("AppServerQueryCache", () => {
|
|||
metadata({
|
||||
availableModels: [modelMetadata("gpt-5.6")],
|
||||
availableSkills: [skillMetadata("failed-skill")],
|
||||
availablePermissionProfiles: [permissionProfile(":failed")],
|
||||
rateLimit: rateLimit(90),
|
||||
skillsProbeStatus: "failed",
|
||||
permissionProfilesProbeStatus: "failed",
|
||||
rateLimitProbeStatus: "failed",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(cache.appServerMetadataSnapshot(context)?.availableModels.map((model) => model.model)).toEqual(["gpt-5.6"]);
|
||||
expect(cache.appServerMetadataSnapshot(context)?.availableSkills.map((skill) => skill.name)).toEqual(["writer"]);
|
||||
expect(cache.appServerMetadataSnapshot(context)?.availablePermissionProfiles.map((profile) => profile.id)).toEqual([":workspace"]);
|
||||
expect(cache.appServerMetadataSnapshot(context)?.rateLimit?.primary?.usedPercent).toBe(42);
|
||||
expect(cache.appServerMetadataSnapshot(context)?.serverDiagnostics.probes.skills.status).toBe("failed");
|
||||
expect(cache.appServerMetadataSnapshot(context)?.serverDiagnostics.probes.rateLimits.status).toBe("failed");
|
||||
|
|
@ -56,9 +61,11 @@ describe("AppServerQueryCache", () => {
|
|||
metadata({
|
||||
availableModels: [modelMetadata("failed-model")],
|
||||
availableSkills: [skillMetadata("writer")],
|
||||
availablePermissionProfiles: [permissionProfile(":workspace")],
|
||||
rateLimit: rateLimit(90),
|
||||
modelProbeStatus: "failed",
|
||||
skillsProbeStatus: "failed",
|
||||
permissionProfilesProbeStatus: "failed",
|
||||
rateLimitProbeStatus: "failed",
|
||||
}),
|
||||
);
|
||||
|
|
@ -68,6 +75,7 @@ describe("AppServerQueryCache", () => {
|
|||
expect(cached?.serverDiagnostics.probes.models.status).toBe("failed");
|
||||
expect(cached?.availableModels).toEqual([]);
|
||||
expect(cached?.availableSkills).toEqual([]);
|
||||
expect(cached?.availablePermissionProfiles).toEqual([]);
|
||||
expect(cached?.rateLimit).toBeNull();
|
||||
expect(cache.modelsSnapshot(context)).toBeNull();
|
||||
});
|
||||
|
|
@ -184,6 +192,7 @@ describe("AppServerQueryCache", () => {
|
|||
"config/read": vi.fn().mockResolvedValue({}),
|
||||
"model/list": vi.fn().mockResolvedValue({ data: [catalogModel("gpt-meta")] }),
|
||||
"skills/list": vi.fn().mockResolvedValue({ data: [{ skills: [catalogSkill("writer")] }] }),
|
||||
"permissionProfile/list": vi.fn().mockResolvedValue({ data: [permissionProfile(":workspace")], nextCursor: null }),
|
||||
"account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: appServerRateLimit(64), rateLimitsByLimitId: null }),
|
||||
});
|
||||
|
||||
|
|
@ -191,6 +200,7 @@ describe("AppServerQueryCache", () => {
|
|||
|
||||
expect(metadata?.availableModels.map((model) => model.model)).toEqual(["gpt-meta"]);
|
||||
expect(metadata?.availableSkills.map((skill) => skill.name)).toEqual(["writer"]);
|
||||
expect(metadata?.availablePermissionProfiles.map((profile) => profile.id)).toEqual([":workspace"]);
|
||||
expect(metadata?.rateLimit?.primary?.usedPercent).toBe(64);
|
||||
expect(metadata?.serverDiagnostics.probes.models.status).toBe("ok");
|
||||
expect(cache.modelsSnapshot(context)?.map((model) => model.model)).toEqual(["gpt-meta"]);
|
||||
|
|
@ -204,6 +214,7 @@ describe("AppServerQueryCache", () => {
|
|||
"config/read": vi.fn().mockResolvedValue({}),
|
||||
"model/list": listModels,
|
||||
"skills/list": vi.fn().mockResolvedValue({ data: [{ skills: [] }] }),
|
||||
"permissionProfile/list": vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
|
||||
"account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: appServerRateLimit(0), rateLimitsByLimitId: null }),
|
||||
});
|
||||
|
||||
|
|
@ -230,6 +241,7 @@ describe("AppServerQueryCache", () => {
|
|||
"config/read": vi.fn().mockResolvedValue({}),
|
||||
"model/list": vi.fn().mockRejectedValue(new Error("offline")),
|
||||
"skills/list": vi.fn().mockResolvedValue({ data: [{ skills: [] }] }),
|
||||
"permissionProfile/list": vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
|
||||
"account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: appServerRateLimit(0), rateLimitsByLimitId: null }),
|
||||
});
|
||||
cache.writeAppServerMetadata(context, metadata({ availableModels: [modelMetadata("gpt-cached")] }));
|
||||
|
|
@ -318,10 +330,12 @@ function metadata(
|
|||
overrides: {
|
||||
availableModels?: readonly ModelMetadata[];
|
||||
availableSkills?: readonly SkillMetadata[];
|
||||
availablePermissionProfiles?: readonly RuntimePermissionProfileSummary[];
|
||||
rateLimit?: RateLimitSnapshot | null;
|
||||
runtimeConfig?: RuntimeConfigSnapshot | null;
|
||||
modelProbeStatus?: "ok" | "failed";
|
||||
skillsProbeStatus?: "ok" | "failed";
|
||||
permissionProfilesProbeStatus?: "ok" | "failed";
|
||||
rateLimitProbeStatus?: "ok" | "failed";
|
||||
} = {},
|
||||
): SharedServerMetadata {
|
||||
|
|
@ -344,10 +358,17 @@ function metadata(
|
|||
? diagnosticProbeError("rateLimits", new Error("offline"), 1)
|
||||
: diagnosticProbeOk("rateLimits", "available", 1),
|
||||
);
|
||||
diagnostics = diagnosticsWithProbe(
|
||||
diagnostics,
|
||||
overrides.permissionProfilesProbeStatus === "failed"
|
||||
? diagnosticProbeError("permissionProfiles", new Error("offline"), 1)
|
||||
: diagnosticProbeOk("permissionProfiles", "0 profiles", 1),
|
||||
);
|
||||
return {
|
||||
runtimeConfig: overrides.runtimeConfig ?? emptyRuntimeConfigSnapshot(),
|
||||
availableModels: overrides.availableModels ?? [modelMetadata("gpt-5.5")],
|
||||
availableSkills: overrides.availableSkills ?? [],
|
||||
availablePermissionProfiles: overrides.availablePermissionProfiles ?? [],
|
||||
rateLimit: overrides.rateLimit ?? null,
|
||||
serverDiagnostics: diagnostics,
|
||||
};
|
||||
|
|
@ -357,6 +378,10 @@ function skillMetadata(name: string): SkillMetadata {
|
|||
return { name, description: "", path: `/tmp/${name}`, enabled: true };
|
||||
}
|
||||
|
||||
function permissionProfile(id: string): RuntimePermissionProfileSummary {
|
||||
return { id, description: null, allowed: true };
|
||||
}
|
||||
|
||||
function rateLimit(usedPercent: number): RateLimitSnapshot {
|
||||
return {
|
||||
limitId: "codex",
|
||||
|
|
|
|||
|
|
@ -199,6 +199,7 @@ function serverMetadata(overrides: Partial<SharedServerMetadata> = {}): SharedSe
|
|||
runtimeConfig: null,
|
||||
availableModels: [],
|
||||
availableSkills: [],
|
||||
availablePermissionProfiles: [],
|
||||
rateLimit: null,
|
||||
serverDiagnostics: diagnostics,
|
||||
...overrides,
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ describe("chat app-server actions", () => {
|
|||
it("keeps empty-panel runtime reservations when starting the first thread", async () => {
|
||||
const stateStore = createChatStateStore(chatStateFixture());
|
||||
stateStore.dispatch({ type: "runtime/model-requested", model: "gpt-5.5" });
|
||||
stateStore.dispatch({ type: "runtime/permission-profile-requested", permissionProfile: ":workspace" });
|
||||
stateStore.dispatch({ type: "runtime/reasoning-effort-requested", effort: "high" });
|
||||
stateStore.dispatch({ type: "runtime/fast-mode-requested", fastMode: "enabled" });
|
||||
stateStore.dispatch({ type: "runtime/approvals-reviewer-requested", approvalsReviewer: "auto_review" });
|
||||
|
|
@ -99,11 +100,13 @@ describe("chat app-server actions", () => {
|
|||
|
||||
expect(startThread).toHaveBeenCalledWith({
|
||||
cwd: "/vault",
|
||||
permissions: ":workspace",
|
||||
serviceName: "codex-panel",
|
||||
serviceTier: "fast",
|
||||
});
|
||||
expect(stateStore.getState().runtime.active.model).toBe("gpt-5");
|
||||
expect(stateStore.getState().runtime.pending.model).toEqual({ kind: "set", value: "gpt-5.5" });
|
||||
expect(stateStore.getState().runtime.pending.permissionProfile).toEqual({ kind: "set", value: ":workspace" });
|
||||
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" });
|
||||
|
|
@ -172,6 +175,49 @@ describe("chat app-server actions", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("starts threads with permission profile from explicit config", async () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatStateWith(state, {
|
||||
connection: {
|
||||
runtimeConfig: {
|
||||
...emptyRuntimeConfigSnapshot(),
|
||||
startupPermissions: {
|
||||
...emptyRuntimeConfigSnapshot().startupPermissions,
|
||||
activePermissionProfile: { id: ":workspace", extends: null },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const stateStore = createChatStateStore(state);
|
||||
const started = threadFixture("started");
|
||||
const startThread = vi.fn().mockResolvedValue({
|
||||
thread: started,
|
||||
cwd: "/vault",
|
||||
model: "gpt-5",
|
||||
serviceTier: null,
|
||||
approvalsReviewer: null,
|
||||
reasoningEffort: null,
|
||||
});
|
||||
const client = startThreadClient(startThread);
|
||||
|
||||
const actions = createChatServerThreadActions({
|
||||
stateStore,
|
||||
vaultPath: "/vault",
|
||||
currentClient: () => client,
|
||||
runtimeSnapshotForState: runtimeSnapshotForChatState,
|
||||
applyThreadCatalogEvent: vi.fn(),
|
||||
syncThreadGoal: vi.fn(),
|
||||
});
|
||||
|
||||
await actions.startThread();
|
||||
|
||||
expect(startThread).toHaveBeenCalledWith({
|
||||
cwd: "/vault",
|
||||
permissions: ":workspace",
|
||||
serviceName: "codex-panel",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps app-server preview when newly started threads already have one", async () => {
|
||||
const stateStore = createChatStateStore(chatStateFixture());
|
||||
const started = threadFixture("started", { preview: "server preview" });
|
||||
|
|
@ -757,6 +803,7 @@ function serverMetadataFixture(overrides: Partial<SharedServerMetadata> = {}): S
|
|||
runtimeConfig: emptyRuntimeConfigSnapshot(),
|
||||
availableModels: [],
|
||||
availableSkills: [],
|
||||
availablePermissionProfiles: [],
|
||||
rateLimit: null,
|
||||
serverDiagnostics: createServerDiagnostics(),
|
||||
...overrides,
|
||||
|
|
|
|||
|
|
@ -124,6 +124,7 @@ describe("composer suggestions", () => {
|
|||
{ input: "/plan", expected: { command: "plan", args: "" } },
|
||||
{ input: "/plan OK、実装してください", expected: { command: "plan", args: "OK、実装してください" } },
|
||||
{ input: "/model gpt-5.5", expected: { command: "model", args: "gpt-5.5" } },
|
||||
{ input: "/permissions :workspace", expected: { command: "permissions", args: ":workspace" } },
|
||||
{ input: "/reasoning high", expected: { command: "reasoning", args: "high" } },
|
||||
{ input: "/new", expected: null },
|
||||
{ input: "/unknown", expected: null },
|
||||
|
|
@ -446,6 +447,52 @@ describe("composer suggestions", () => {
|
|||
expect(activeComposerSuggestions("/reasoning high ", notes, [], [], models, "gpt-5.5")).toEqual([]);
|
||||
});
|
||||
|
||||
it("suggests existing allowed permission profiles for /permissions", () => {
|
||||
const permissionProfiles = [
|
||||
{ id: ":read-only", description: "Read only", allowed: true },
|
||||
{ id: ":workspace", description: "Workspace write", allowed: true },
|
||||
{ id: "DevProfile", description: "Developer profile", allowed: true },
|
||||
{ id: "reset", description: "Reset-like profile", allowed: true },
|
||||
{ id: "blocked", description: null, allowed: false },
|
||||
];
|
||||
|
||||
expect(
|
||||
suggestionReplacements(
|
||||
activeComposerSuggestions("/permissions ", notes, [], [], [], null, {
|
||||
permissionProfiles,
|
||||
}),
|
||||
),
|
||||
).toEqual(["default", ":read-only", ":workspace", "DevProfile", "reset"]);
|
||||
expect(
|
||||
activeComposerSuggestions("/permissions :r", notes, [], [], [], null, {
|
||||
permissionProfiles,
|
||||
})[0],
|
||||
).toMatchObject({
|
||||
display: ":read-only",
|
||||
detail: "Read only",
|
||||
replacement: ":read-only",
|
||||
appendSpaceOnInsert: true,
|
||||
});
|
||||
expect(
|
||||
activeComposerSuggestions("/permissions work", notes, [], [], [], null, {
|
||||
permissionProfiles,
|
||||
})[0],
|
||||
).toMatchObject({
|
||||
replacement: ":workspace",
|
||||
});
|
||||
expect(activeComposerSuggestions("/permissions blocked", notes, [], [], [], null, { permissionProfiles })).toEqual([]);
|
||||
expect(activeComposerSuggestions("/permissions devprofile", notes, [], [], [], null, { permissionProfiles })[0]).toMatchObject({
|
||||
replacement: "DevProfile",
|
||||
});
|
||||
expect(activeComposerSuggestions("/permissions res", notes, [], [], [], null, { permissionProfiles })[0]).toMatchObject({
|
||||
replacement: "reset",
|
||||
});
|
||||
expect(activeComposerSuggestions("/permissions DevProfile", notes, [], [], [], null, { permissionProfiles })).toEqual([]);
|
||||
expect(activeComposerSuggestions("/permissions reset", notes, [], [], [], null, { permissionProfiles })).toEqual([]);
|
||||
expect(activeComposerSuggestions("/permissions :read-only", notes, [], [], [], null, { permissionProfiles })).toEqual([]);
|
||||
expect(activeComposerSuggestions("/permissions :read-only ", notes, [], [], [], null, { permissionProfiles })).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not suggest fixed reasoning effort fallbacks without model support metadata", () => {
|
||||
expect(activeComposerSuggestions("/reasoning h", notes, [], [], [], null)).toEqual([]);
|
||||
expect(activeComposerSuggestions("/reasoning ", notes, [], [], [model("gpt-5.5", [])], "gpt-5.5")).toEqual([
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ function context(overrides: Partial<SlashCommandExecutionContext> = {}): SlashCo
|
|||
toggleAutoReview: vi.fn(),
|
||||
requestModel: vi.fn(),
|
||||
resetModelToConfig: vi.fn(),
|
||||
requestPermissionProfile: vi.fn(),
|
||||
resetPermissionProfileToConfig: vi.fn(),
|
||||
requestReasoningEffort: vi.fn(),
|
||||
resetReasoningEffortToConfig: vi.fn(),
|
||||
},
|
||||
|
|
@ -508,6 +510,7 @@ describe("slash commands", () => {
|
|||
{ key: "/plan [message]", value: "Toggle Plan mode, optionally with a message." },
|
||||
{ key: "/goal", value: "Show or manage the current thread goal." },
|
||||
{ key: "/goal set <objective>", value: "Create or update the thread goal." },
|
||||
{ key: "/permissions [profile|default]", value: "Show or set the permission profile for subsequent turns." },
|
||||
{ key: "/model [model|default]", value: "Show or set the model for subsequent turns." },
|
||||
]),
|
||||
},
|
||||
|
|
@ -573,13 +576,45 @@ describe("slash commands", () => {
|
|||
expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("Permissions & Approvals", details);
|
||||
});
|
||||
|
||||
it("rejects /permissions arguments", async () => {
|
||||
it("sets the permission profile for /permissions arguments", async () => {
|
||||
const ctx = context();
|
||||
|
||||
await executeSlashCommand("permissions", "anything", ctx);
|
||||
await executeSlashCommand("permissions", ":workspace", ctx);
|
||||
|
||||
expect(ctx.addStructuredSystemMessage).not.toHaveBeenCalled();
|
||||
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/permissions does not take arguments. Usage: /permissions");
|
||||
expect(ctx.runtimeSettings.requestPermissionProfile).toHaveBeenCalledWith(":workspace");
|
||||
expect(ctx.runtimeSettings.resetPermissionProfileToConfig).not.toHaveBeenCalled();
|
||||
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Permission profile set to :workspace for subsequent turns.");
|
||||
});
|
||||
|
||||
it("routes default permission profile through reset", async () => {
|
||||
const ctx = context();
|
||||
|
||||
await executeSlashCommand("permissions", "default", ctx);
|
||||
|
||||
expect(ctx.runtimeSettings.resetPermissionProfileToConfig).toHaveBeenCalledOnce();
|
||||
expect(ctx.runtimeSettings.requestPermissionProfile).not.toHaveBeenCalled();
|
||||
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Permission profile reset to default for subsequent turns.");
|
||||
});
|
||||
|
||||
it.each(["reset", "clear", "off"])("treats permission profile alias-like value %s as a profile id", async (profile) => {
|
||||
const ctx = context();
|
||||
|
||||
await executeSlashCommand("permissions", profile, ctx);
|
||||
|
||||
expect(ctx.runtimeSettings.requestPermissionProfile).toHaveBeenCalledWith(profile);
|
||||
expect(ctx.runtimeSettings.resetPermissionProfileToConfig).not.toHaveBeenCalled();
|
||||
expect(ctx.addSystemMessage).toHaveBeenCalledWith(`Permission profile set to ${profile} for subsequent turns.`);
|
||||
});
|
||||
|
||||
it("does not announce permission profile changes when applying them fails", async () => {
|
||||
const ctx = context();
|
||||
ctx.runtimeSettings.requestPermissionProfile = vi.fn().mockResolvedValue(false);
|
||||
|
||||
await executeSlashCommand("permissions", ":workspace", ctx);
|
||||
|
||||
expect(ctx.runtimeSettings.requestPermissionProfile).toHaveBeenCalledWith(":workspace");
|
||||
expect(ctx.addSystemMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows doctor diagnostics as shared structured sections", async () => {
|
||||
|
|
@ -619,7 +654,7 @@ describe("slash commands", () => {
|
|||
|
||||
it("documents status and doctor as separate commands", () => {
|
||||
expect(slashCommandHelpValue("/status")).toBe("Show current thread, context, and usage limits.");
|
||||
expect(slashCommandHelpValue("/permissions")).toBe("Show current permissions and approval settings.");
|
||||
expect(slashCommandHelpValue("/permissions [profile|default]")).toBe("Show or set the permission profile for subsequent turns.");
|
||||
expect(slashCommandHelpValue("/doctor")).toBe("Show Codex CLI and Codex App Server diagnostics.");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ function createHost(overrides: SlashCommandExecutorHostOverrides = {}) {
|
|||
toggleAutoReview: vi.fn(),
|
||||
requestModel: vi.fn(),
|
||||
resetModelToConfig: vi.fn(),
|
||||
requestPermissionProfile: vi.fn(),
|
||||
resetPermissionProfileToConfig: vi.fn(),
|
||||
requestReasoningEffort: vi.fn(),
|
||||
resetReasoningEffortToConfig: vi.fn(),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -66,6 +66,33 @@ describe("createChatRuntimeSettingsActions", () => {
|
|||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("requests and resets permission profiles through thread settings", 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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
const store = createChatStateStore(state);
|
||||
const transport = settingsTransportFixture();
|
||||
const messages: string[] = [];
|
||||
const actions = runtimeActionsFixture(store, transport, messages);
|
||||
|
||||
await expect(actions.requestPermissionProfile(":workspace")).resolves.toBe(true);
|
||||
await expect(actions.resetPermissionProfileToConfig()).resolves.toBe(true);
|
||||
|
||||
expect(transport.updateThreadSettings).toHaveBeenNthCalledWith(1, "thread", { permissions: ":workspace" });
|
||||
expect(transport.updateThreadSettings).toHaveBeenNthCalledWith(2, "thread", { permissions: null });
|
||||
expect(store.getState().runtime.pending.permissionProfile).toEqual({ kind: "unchanged" });
|
||||
expect(store.getState().runtime.active.activePermissionProfile).toBeNull();
|
||||
expect(store.getState().runtime.active.permissionProfileKnown).toBe(false);
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("reserves thread runtime settings when no thread is active", async () => {
|
||||
const store = createChatStateStore(chatStateFixture());
|
||||
const transport = settingsTransportFixture();
|
||||
|
|
@ -73,6 +100,7 @@ describe("createChatRuntimeSettingsActions", () => {
|
|||
const actions = runtimeActionsFixture(store, transport, messages);
|
||||
|
||||
await expect(actions.requestModel("gpt-5.5")).resolves.toBe(true);
|
||||
await expect(actions.requestPermissionProfile(":workspace")).resolves.toBe(true);
|
||||
await expect(actions.requestReasoningEffort("high")).resolves.toBe(true);
|
||||
await actions.enableFastMode();
|
||||
await actions.enableAutoReview();
|
||||
|
|
@ -81,6 +109,7 @@ describe("createChatRuntimeSettingsActions", () => {
|
|||
|
||||
expect(transport.updateThreadSettings).not.toHaveBeenCalled();
|
||||
expect(store.getState().runtime.pending.model).toEqual({ kind: "set", value: "gpt-5.5" });
|
||||
expect(store.getState().runtime.pending.permissionProfile).toEqual({ kind: "set", value: ":workspace" });
|
||||
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" });
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { messageStreamItems } from "../../../../../src/features/chat/application
|
|||
import { type ChatState, chatReducer } from "../../../../../src/features/chat/application/state/root-reducer";
|
||||
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
|
||||
import type { MessageStreamItem } from "../../../../../src/features/chat/domain/message-stream/items";
|
||||
import { setCollaborationModeIntent } from "../../../../../src/features/chat/domain/runtime/intent";
|
||||
import { setCollaborationModeIntent, setRuntimeIntentValue } from "../../../../../src/features/chat/domain/runtime/intent";
|
||||
import { chatStateMessageStreamItems, withChatStateMessageStreamItems } from "../../support/message-stream";
|
||||
import { chatStateFixture, chatStateWith } from "../../support/state";
|
||||
|
||||
|
|
@ -19,6 +19,7 @@ describe("chatReducer", () => {
|
|||
state = chatStateWith(state, { runtime: { active: { reasoningEffort: "high" } } });
|
||||
state = chatStateWith(state, { runtime: { active: { serviceTier: "fast" } } });
|
||||
state = chatStateWith(state, { runtime: { active: { approvalsReviewer: "auto_review" } } });
|
||||
state = chatStateWith(state, { runtime: { active: { activePermissionProfile: { id: ":workspace", extends: null } } } });
|
||||
state = chatStateWith(state, { runtime: { active: { collaborationMode: "plan" } } });
|
||||
state = chatStateWith(state, { runtime: { pending: { collaborationMode: setCollaborationModeIntent("plan") } } });
|
||||
state = chatStateWith(state, { activeThread: { goal: goal("thread") } });
|
||||
|
|
@ -40,6 +41,7 @@ describe("chatReducer", () => {
|
|||
ui: { goalEditor: { kind: "editing", threadId: "thread", objectiveDraft: "draft", tokenBudgetDraft: null } },
|
||||
});
|
||||
let pendingState = chatReducer(state, { type: "runtime/model-requested", model: "gpt-5.2" });
|
||||
pendingState = chatReducer(pendingState, { type: "runtime/permission-profile-requested", permissionProfile: ":read-only" });
|
||||
pendingState = chatReducer(pendingState, { type: "runtime/reasoning-effort-requested", effort: "medium" });
|
||||
pendingState = chatReducer(pendingState, { type: "runtime/fast-mode-requested", fastMode: "disabled" });
|
||||
pendingState = chatReducer(pendingState, { type: "runtime/approvals-reviewer-requested", approvalsReviewer: "user" });
|
||||
|
|
@ -52,8 +54,10 @@ describe("chatReducer", () => {
|
|||
expect(next.runtime.active.reasoningEffort).toBeNull();
|
||||
expect(next.runtime.active.serviceTier).toBeNull();
|
||||
expect(next.runtime.active.approvalsReviewer).toBeNull();
|
||||
expect(next.runtime.active.activePermissionProfile).toBeNull();
|
||||
expect(next.runtime.active.collaborationMode).toBeNull();
|
||||
expect(next.runtime.pending.model).toEqual({ kind: "unchanged" });
|
||||
expect(next.runtime.pending.permissionProfile).toEqual({ kind: "unchanged" });
|
||||
expect(next.runtime.pending.reasoningEffort).toEqual({ kind: "unchanged" });
|
||||
expect(next.runtime.pending.fastMode).toEqual({ kind: "unchanged" });
|
||||
expect(next.runtime.pending.approvalsReviewer).toEqual({ kind: "unchanged" });
|
||||
|
|
@ -142,6 +146,7 @@ describe("chatReducer", () => {
|
|||
it("preserves empty-panel runtime reservations when thread activation explicitly requests it", () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatReducer(state, { type: "runtime/model-requested", model: "gpt-5.5" });
|
||||
state = chatReducer(state, { type: "runtime/permission-profile-requested", permissionProfile: ":workspace" });
|
||||
state = chatReducer(state, { type: "runtime/reasoning-effort-requested", effort: "high" });
|
||||
state = chatReducer(state, { type: "runtime/fast-mode-requested", fastMode: "enabled" });
|
||||
state = chatReducer(state, { type: "runtime/approvals-reviewer-requested", approvalsReviewer: "auto_review" });
|
||||
|
|
@ -170,6 +175,7 @@ describe("chatReducer", () => {
|
|||
expect(next.runtime.active.serviceTier).toBe("fast");
|
||||
expect(next.runtime.active.approvalsReviewer).toBe("user");
|
||||
expect(next.runtime.pending.model).toEqual({ kind: "set", value: "gpt-5.5" });
|
||||
expect(next.runtime.pending.permissionProfile).toEqual({ kind: "set", value: ":workspace" });
|
||||
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" });
|
||||
|
|
@ -177,6 +183,18 @@ describe("chatReducer", () => {
|
|||
expect(next.runtime.active.collaborationMode).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps reset and set permission profile requests explicit", () => {
|
||||
let state = chatStateFixture();
|
||||
|
||||
state = chatReducer(state, { type: "runtime/permission-profile-requested", permissionProfile: ":workspace" });
|
||||
|
||||
expect(state.runtime.pending.permissionProfile).toEqual(setRuntimeIntentValue(":workspace"));
|
||||
|
||||
state = chatReducer(state, { type: "runtime/permission-profile-reset-to-config" });
|
||||
|
||||
expect(state.runtime.pending.permissionProfile).toEqual({ kind: "resetToConfig" });
|
||||
});
|
||||
|
||||
it("starts resumed threads with empty display state when no history items are supplied", () => {
|
||||
let state = chatStateFixture();
|
||||
state = withChatStateMessageStreamItems(state, [message("previous-message")]);
|
||||
|
|
|
|||
|
|
@ -150,6 +150,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
expectRequestTimes(client, "config/read", 1);
|
||||
expect(fetchModels).toHaveBeenCalledTimes(1);
|
||||
expectRequestTimes(client, "skills/list", 1);
|
||||
expectRequestTimes(client, "permissionProfile/list", 1);
|
||||
expectRequestTimes(client, "account/rateLimits/read", 1);
|
||||
expectRequestTimes(client, "thread/list", 1);
|
||||
});
|
||||
|
|
@ -421,6 +422,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
expectRequestTimes(client, "config/read", 1);
|
||||
expect(fetchModels).toHaveBeenCalledOnce();
|
||||
expectRequestTimes(client, "skills/list", 1);
|
||||
expectRequestTimes(client, "permissionProfile/list", 1);
|
||||
expectRequestTimes(client, "account/rateLimits/read", 1);
|
||||
expect(client.request).toHaveBeenCalledWith("thread/list", {
|
||||
cwd: "/vault",
|
||||
|
|
@ -446,6 +448,7 @@ describe("CodexChatView connection lifecycle", () => {
|
|||
runtimeConfig: { ...emptyRuntimeConfigSnapshot(), model: "gpt-cached" },
|
||||
availableModels: [],
|
||||
availableSkills: [{ name: "writer", enabled: true }],
|
||||
availablePermissionProfiles: [],
|
||||
rateLimit: null,
|
||||
serverDiagnostics: createServerDiagnostics(),
|
||||
}) as never,
|
||||
|
|
@ -771,6 +774,7 @@ function baseClientHandlers(): RequestHandlers {
|
|||
"config/read": vi.fn().mockResolvedValue({}),
|
||||
"model/list": vi.fn().mockResolvedValue({ data: [] }),
|
||||
"skills/list": vi.fn().mockResolvedValue({ data: [] }),
|
||||
"permissionProfile/list": vi.fn().mockResolvedValue({ data: [], nextCursor: null }),
|
||||
"account/rateLimits/read": vi.fn().mockResolvedValue({ rateLimits: null }),
|
||||
"thread/list": vi.fn().mockResolvedValue({ data: [] }),
|
||||
"thread/start": vi.fn().mockResolvedValue(startedThread("thread-new")),
|
||||
|
|
@ -1049,6 +1053,11 @@ function chatHost(overrides: ChatHostFixtureOverrides = {}): TestCodexChatHost {
|
|||
data: { skills: { name: string; description?: string; path?: string; enabled?: boolean }[] }[];
|
||||
};
|
||||
if (!connectionStillCurrent()) return null;
|
||||
const permissionProfilesResponse = (await client.request("permissionProfile/list", { cwd: vaultPath, cursor: null, limit: 100 })) as {
|
||||
data: { id: string; description: string | null; allowed: boolean }[];
|
||||
nextCursor: string | null;
|
||||
};
|
||||
if (!connectionStillCurrent()) return null;
|
||||
await client.request("account/rateLimits/read", undefined);
|
||||
if (!connectionStillCurrent()) return null;
|
||||
return {
|
||||
|
|
@ -1063,6 +1072,7 @@ function chatHost(overrides: ChatHostFixtureOverrides = {}): TestCodexChatHost {
|
|||
enabled: skill.enabled ?? true,
|
||||
})),
|
||||
),
|
||||
availablePermissionProfiles: permissionProfilesResponse.data.map((profile) => ({ ...profile })),
|
||||
rateLimit: null,
|
||||
serverDiagnostics: createServerDiagnostics(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -123,6 +123,61 @@ describe("createChatPanelRuntimeProjection", () => {
|
|||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows pending permission profile reservations in an empty panel", () => {
|
||||
const state = chatStateWith(chatStateFixture(), {
|
||||
runtime: {
|
||||
pending: {
|
||||
permissionProfile: { kind: "set", value: ":workspace" },
|
||||
},
|
||||
},
|
||||
});
|
||||
const projection = createChatPanelRuntimeProjection({
|
||||
state: () => state,
|
||||
connected: () => true,
|
||||
configuredCommand: () => "codex",
|
||||
vaultPath: () => "/vault",
|
||||
nowMs: () => 0,
|
||||
});
|
||||
|
||||
expect(projection.permissionDetails()[0]?.auditFacts).toEqual([
|
||||
{ key: "Profile", value: ":workspace" },
|
||||
{ key: "Sandbox", value: "(not reported)" },
|
||||
{ key: "Network", value: "(not reported)" },
|
||||
{ key: "Extra writable roots", value: "(not reported)" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not report legacy sandbox details for configured permission profiles in an empty panel", () => {
|
||||
const state = chatStateWith(chatStateFixture(), {
|
||||
connection: {
|
||||
runtimeConfig: runtimeConfigFixture({
|
||||
default_permissions: "DevProfile",
|
||||
sandbox_mode: "workspace-write",
|
||||
sandbox_workspace_write: {
|
||||
writable_roots: ["/vault"],
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
const projection = createChatPanelRuntimeProjection({
|
||||
state: () => state,
|
||||
connected: () => true,
|
||||
configuredCommand: () => "codex",
|
||||
vaultPath: () => "/vault",
|
||||
nowMs: () => 0,
|
||||
});
|
||||
|
||||
expect(projection.permissionDetails()[0]?.auditFacts).toEqual([
|
||||
{ key: "Profile", value: "DevProfile" },
|
||||
{ key: "Sandbox", value: "(not reported)" },
|
||||
{ key: "Network", value: "(not reported)" },
|
||||
{ key: "Extra writable roots", value: "(not reported)" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function runtimeConfigFixture(config: Record<string, unknown>): RuntimeConfigSnapshot {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { resolveRuntimeControls } from "../../src/features/chat/domain/runtime/r
|
|||
import type { RuntimeSnapshot } from "../../src/features/chat/domain/runtime/snapshot";
|
||||
import {
|
||||
pendingRuntimeSettingsPatch,
|
||||
permissionProfileRequestForThreadStart,
|
||||
serviceTierRequestForThreadStart,
|
||||
} from "../../src/features/chat/domain/runtime/thread-settings-patch";
|
||||
import { contextSummary, rateLimitSummary } from "../../src/features/chat/presentation/runtime/status";
|
||||
|
|
@ -78,10 +79,31 @@ describe("runtime settings", () => {
|
|||
expect(clonedPolicy.granular).not.toBe(originalPolicy.granular);
|
||||
});
|
||||
|
||||
it("uses legacy sandbox config instead of default permissions when both are reported", () => {
|
||||
it("keeps default permissions separate from legacy sandbox fields", () => {
|
||||
expect(
|
||||
runtimeConfigFixture({
|
||||
default_permissions: "DevProfile",
|
||||
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,
|
||||
},
|
||||
}),
|
||||
).toMatchObject({
|
||||
startupPermissions: {
|
||||
activePermissionProfile: { id: "DevProfile", extends: null },
|
||||
approvalPolicy: "on-request",
|
||||
sandboxPolicy: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses legacy sandbox config when default permissions are not reported", () => {
|
||||
expect(
|
||||
runtimeConfigFixture({
|
||||
default_permissions: ":workspace",
|
||||
approval_policy: "on-request",
|
||||
sandbox_mode: "workspace-write",
|
||||
sandbox_workspace_write: {
|
||||
|
|
@ -235,6 +257,33 @@ describe("runtime settings", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("selects config permission profiles for empty-panel thread starts", () => {
|
||||
const configured = runtimeSnapshot({
|
||||
runtimeConfig: runtimeConfigFixture({
|
||||
default_permissions: ":workspace",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(permissionProfileRequestForThreadStart(configured, snapshotConfig(configured))).toBe(":workspace");
|
||||
|
||||
const pending = runtimeSnapshot({
|
||||
runtimeConfig: runtimeConfigFixture({
|
||||
default_permissions: ":workspace",
|
||||
}),
|
||||
pending: { permissionProfile: setRuntimeIntentValue(":read-only") },
|
||||
});
|
||||
|
||||
expect(permissionProfileRequestForThreadStart(pending, snapshotConfig(pending))).toBe(":read-only");
|
||||
|
||||
const legacySandbox = runtimeSnapshot({
|
||||
runtimeConfig: runtimeConfigFixture({
|
||||
sandbox_mode: "workspace-write",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(permissionProfileRequestForThreadStart(legacySandbox, snapshotConfig(legacySandbox))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps runtime defaults, resets, and collaboration mode semantics distinct", () => {
|
||||
const snapshot = runtimeSnapshot({
|
||||
pending: {
|
||||
|
|
@ -598,8 +647,8 @@ 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" },
|
||||
permissionProfile: { effective: ":workspace", source: "config" },
|
||||
sandboxPolicy: { effective: null, source: "none" },
|
||||
approvalPolicy: { effective: "on-request", source: "config" },
|
||||
approvalsReviewer: { effective: "auto_review", source: "config" },
|
||||
serviceTier: { effective: "fast", source: "config" },
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export function deferred<T>(): Deferred<T> {
|
|||
}
|
||||
|
||||
export async function waitForAsyncWork(assertion: () => void): Promise<void> {
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
try {
|
||||
assertion();
|
||||
return;
|
||||
|
|
|
|||
Loading…
Reference in a new issue