Simplify app-server action and settings boundaries

This commit is contained in:
murashit 2026-06-16 18:17:10 +09:00
parent 4a7e5f905f
commit f2178b2db4
22 changed files with 588 additions and 580 deletions

View file

@ -1,24 +0,0 @@
import type { AppServerClient } from "../connection/client";
import { normalizeExplicitThreadName } from "../../domain/threads/model";
export interface RenameThreadResult {
name: string;
}
export interface ThreadRename {
name: string;
}
export function threadRenameFromValue(value: string): ThreadRename | null {
const name = normalizeExplicitThreadName(value);
return name ? { name } : null;
}
export async function renameThreadOnAppServer(
client: AppServerClient,
threadId: string,
rename: ThreadRename,
): Promise<RenameThreadResult> {
await client.setThreadName(threadId, rename.name);
return { name: rename.name };
}

View file

@ -34,7 +34,6 @@ export interface ChatServerDiagnosticsActionsHost extends ChatServerActionHost {
export interface ChatServerDiagnosticsActions {
refreshDiagnosticProbes: (options?: RefreshDiagnosticProbesOptions) => Promise<void>;
refreshPublishedDiagnosticProbes: (options?: RefreshDiagnosticProbesOptions) => Promise<void>;
mcpStatusLines: () => Promise<string[]>;
recordMcpStartupStatus: (name: string, startupStatus: McpServerStartupStatus, message: string | null) => void;
}
@ -44,7 +43,6 @@ export function createChatServerDiagnosticsActions(host: ChatServerDiagnosticsAc
refreshDiagnosticProbes: async (options) => {
await refreshDiagnosticProbes(host, options);
},
refreshPublishedDiagnosticProbes: (options) => refreshPublishedDiagnosticProbes(host, options),
mcpStatusLines: () => mcpStatusLines(host),
recordMcpStartupStatus: (name, startupStatus, message) => {
recordMcpStartupStatus(host, name, startupStatus, message);
@ -137,13 +135,6 @@ async function readRateLimitDiagnosticProbe(client: AppServerClient): Promise<Di
return { method: "account/rateLimits/read", probe: result.probe };
}
async function refreshPublishedDiagnosticProbes(
host: ChatServerDiagnosticsActionsHost,
options: RefreshDiagnosticProbesOptions = {},
): Promise<void> {
await refreshDiagnosticProbes(host, options);
}
async function mcpStatusLines(host: ChatServerDiagnosticsActionsHost): Promise<string[]> {
const client = host.currentClient();
if (!client) return ["MCP servers", "Codex app-server is not connected."];

View file

@ -20,13 +20,10 @@ export interface ChatServerMetadataActions {
applyAppServerMetadata: (metadata: SharedServerMetadata) => void;
loadAppServerMetadata: () => Promise<SharedServerMetadata | null>;
refreshAppServerMetadata: () => Promise<SharedServerMetadata | null>;
refreshPublishedAppServerMetadata: () => Promise<SharedServerMetadata | null>;
applyAppServerMetadataSnapshot: () => void;
refreshSkills: (forceReload?: boolean) => Promise<void>;
refreshPublishedSkills: (forceReload?: boolean) => Promise<void>;
loadSkills: (forceReload?: boolean) => Promise<SkillMetadataProbeResult>;
refreshRateLimits: () => Promise<void>;
refreshPublishedRateLimits: () => Promise<void>;
refreshRateLimits: (options?: { preserveExistingOnFailure?: boolean }) => Promise<void>;
loadRateLimit: () => Promise<RateLimitMetadataProbeResult>;
}
@ -37,17 +34,14 @@ export function createChatServerMetadataActions(host: ChatServerMetadataActionsH
},
loadAppServerMetadata: () => loadAppServerMetadata(host),
refreshAppServerMetadata: () => refreshAppServerMetadata(host),
refreshPublishedAppServerMetadata: () => refreshPublishedAppServerMetadata(host),
applyAppServerMetadataSnapshot: () => {
applyAppServerMetadataSnapshot(host);
},
refreshSkills: async (forceReload) => {
await refreshSkills(host, forceReload);
},
refreshPublishedSkills: (forceReload) => refreshPublishedSkills(host, forceReload),
loadSkills: (forceReload) => loadSkills(host, forceReload),
refreshRateLimits: () => refreshRateLimits(host),
refreshPublishedRateLimits: () => refreshPublishedRateLimits(host),
refreshRateLimits: (options) => refreshRateLimits(host, options),
loadRateLimit: () => loadRateLimit(host),
};
}
@ -80,10 +74,6 @@ async function refreshAppServerMetadata(host: ChatServerMetadataActionsHost): Pr
return metadata;
}
async function refreshPublishedAppServerMetadata(host: ChatServerMetadataActionsHost): Promise<SharedServerMetadata | null> {
return refreshAppServerMetadata(host);
}
function applyAppServerMetadataSnapshot(host: ChatServerMetadataActionsHost): void {
const metadata = host.appServerMetadataSnapshot();
if (metadata) applyAppServerMetadata(host, metadata);
@ -114,50 +104,33 @@ async function refreshSkills(host: ChatServerMetadataActionsHost, forceReload =
return null;
}
async function refreshPublishedSkills(host: ChatServerMetadataActionsHost, forceReload = false): Promise<void> {
await refreshSkills(host, forceReload);
}
async function loadSkills(host: ChatServerMetadataActionsHost, forceReload = false): Promise<SkillMetadataProbeResult> {
return readSkillMetadataProbe(host.currentClient(), host.vaultPath, forceReload);
}
async function refreshRateLimits(host: ChatServerMetadataActionsHost): Promise<void> {
async function refreshRateLimits(
host: ChatServerMetadataActionsHost,
options: { preserveExistingOnFailure?: boolean } = {},
): Promise<void> {
const client = host.currentClient();
const rateLimit = await readRateLimitMetadataProbe(client);
if (client && host.currentClient() !== client) return;
const next = updateRateLimitMetadata(host, rateLimit, { preserveRateLimitOnFailure: false });
const preserveExistingOnFailure = options.preserveExistingOnFailure === true;
const next = updateRateLimitMetadata(host, rateLimit, { preserveRateLimitOnFailure: preserveExistingOnFailure });
if (next) {
applyAppServerMetadata(host, next);
return;
}
const diagnostics = diagnosticsWithProbe(currentMetadataDiagnostics(host), rateLimit.probe);
host.stateStore.dispatch({
type: "connection/metadata-applied",
rateLimit: rateLimit.data,
serverDiagnostics: diagnostics,
});
}
async function refreshPublishedRateLimits(host: ChatServerMetadataActionsHost): Promise<void> {
const client = host.currentClient();
const rateLimit = await readRateLimitMetadataProbe(client);
if (client && host.currentClient() !== client) return;
const next = updateRateLimitMetadata(host, rateLimit, { preserveRateLimitOnFailure: true });
if (next) {
applyAppServerMetadata(host, next);
return;
}
const diagnostics = diagnosticsWithProbe(currentMetadataDiagnostics(host), rateLimit.probe);
if (rateLimit.probe.status === "ok") {
host.stateStore.dispatch({
type: "connection/metadata-applied",
rateLimit: rateLimit.data,
serverDiagnostics: diagnostics,
});
return;
}
host.stateStore.dispatch({ type: "connection/metadata-applied", serverDiagnostics: diagnostics });
host.stateStore.dispatch(
preserveExistingOnFailure && rateLimit.probe.status !== "ok"
? { type: "connection/metadata-applied", serverDiagnostics: diagnostics }
: {
type: "connection/metadata-applied",
rateLimit: rateLimit.data,
serverDiagnostics: diagnostics,
},
);
}
async function loadRateLimit(host: ChatServerMetadataActionsHost): Promise<RateLimitMetadataProbeResult> {

View file

@ -20,12 +20,12 @@ export interface ChatConnectionAdapter {
}
export interface ChatConnectionMetadataActions {
refreshPublishedAppServerMetadata: () => Promise<unknown>;
refreshPublishedSkills: (forceReload?: boolean) => Promise<void>;
refreshAppServerMetadata: () => Promise<unknown>;
refreshSkills: (forceReload?: boolean) => Promise<void>;
}
export interface ChatConnectionDiagnosticsActions {
refreshPublishedDiagnosticProbes: (options?: { appServerMetadataSnapshot?: boolean; forceResourceProbes?: boolean }) => Promise<void>;
refreshDiagnosticProbes: (options?: { appServerMetadataSnapshot?: boolean; forceResourceProbes?: boolean }) => Promise<void>;
}
export interface ChatConnectionControllerHost {
@ -125,8 +125,8 @@ async function refreshDiagnostics(
await controller.ensureConnected();
if (!host.connection.currentClient()) return;
host.clearDeferredDiagnostics();
await host.metadata.refreshPublishedAppServerMetadata();
await host.diagnostics.refreshPublishedDiagnosticProbes({ appServerMetadataSnapshot: true });
await host.metadata.refreshAppServerMetadata();
await host.diagnostics.refreshDiagnosticProbes({ appServerMetadataSnapshot: true });
}
async function refreshStatusPanel(
@ -143,7 +143,7 @@ async function refreshStatusPanel(
async function refreshSkills(host: ChatConnectionControllerHost, forceReload = false): Promise<void> {
if (!host.connection.currentClient()) return;
await host.metadata.refreshPublishedSkills(forceReload);
await host.metadata.refreshSkills(forceReload);
}
async function initializeConnection(host: ChatConnectionControllerHost, connection: ActiveConnectionWork): Promise<void> {
@ -154,7 +154,7 @@ async function initializeConnection(host: ChatConnectionControllerHost, connecti
host.stateStore.dispatch({ type: "connection/initialized", initializeResponse: initialization });
const client = host.connection.currentClient();
if (!client) throw new Error("Codex app-server connection did not initialize.");
await host.metadata.refreshPublishedAppServerMetadata();
await host.metadata.refreshAppServerMetadata();
if (host.connectionWork.isStale(connection)) return;
await host.loadSharedThreadList();
if (host.connectionWork.isStale(connection)) return;

View file

@ -90,23 +90,10 @@ export function createConversationTurnActions(
startNewThread: thread.startNewThread,
startThreadForGoal: (objective) => startThreadForGoal(refs.threadStarter, objective),
resumeThread: thread.selectThread,
forkThread: (threadId) => refs.threadActions.forkThread(threadId),
rollbackThread: (threadId) => refs.threadActions.rollbackThread(threadId),
compactThread: (threadId) => refs.threadActions.compactThread(threadId),
archiveThread: (threadId, saveMarkdown) => refs.threadActions.archiveThread(threadId, saveMarkdown),
renameThread: (threadId, name) => refs.threadActions.renameThread(threadId, name).then(() => undefined),
threadActions: refs.threadActions,
reconnect: refs.reconnectPanel,
toggleFastMode: () => refs.runtimeSettings.toggleFastMode(),
toggleCollaborationMode: () => refs.runtimeSettings.toggleCollaborationMode(),
toggleAutoReview: () => void refs.runtimeSettings.toggleAutoReview(),
requestModel: (model) => refs.runtimeSettings.requestModel(model),
resetModelToConfig: () => refs.runtimeSettings.resetModelToConfig(),
requestReasoningEffort: (effort) => refs.runtimeSettings.requestReasoningEffort(effort),
resetReasoningEffortToConfig: () => refs.runtimeSettings.resetReasoningEffortToConfig(),
activeGoal: () => refs.goals.activeGoal(),
setGoalObjective: (threadId, objective, tokenBudget) => refs.goals.setObjective(threadId, objective, tokenBudget),
setGoalStatus: (threadId, goalStatus) => refs.goals.setStatus(threadId, goalStatus),
clearGoal: (threadId) => refs.goals.clear(threadId),
runtimeSettings: refs.runtimeSettings,
goals: refs.goals,
addSystemMessage: status.addSystemMessage,
addStructuredSystemMessage: status.addStructuredSystemMessage,
setStatus: status.set,

View file

@ -1,10 +1,13 @@
import type { CodexInput } from "../../../../domain/chat/input";
import type { ThreadGoal, ThreadGoalStatus } from "../../../../domain/threads/goal";
import type { ThreadGoal } from "../../../../domain/threads/goal";
import type { ReasoningEffort } from "../../../../domain/catalog/metadata";
import { normalizeReasoningEffort } from "../../../../domain/catalog/metadata";
import type { Thread } from "../../../../domain/threads/model";
import { getThreadTitle } from "../../../../domain/threads/model";
import type { ReferencedThreadMetadata } from "../../../../domain/threads/reference";
import type { ChatRuntimeSettingsActions } from "../runtime/settings-actions";
import type { GoalActions } from "../threads/goal-actions";
import type { ThreadManagementActions } from "../threads/thread-management-actions";
import {
slashCommandDefinition,
slashCommandHelpSections,
@ -34,26 +37,22 @@ export interface SlashCommandExecutionContext {
startThreadForGoal: (objective: string) => Promise<string | null>;
resumeThread: (threadId: string) => Promise<void>;
referThread: (thread: Thread, message: string) => Promise<ThreadReferenceInput | null>;
forkThread: (threadId: string) => Promise<void>;
rollbackThread: (threadId: string) => Promise<void>;
compactThread: (threadId: string) => Promise<void>;
archiveThread: (threadId: string, saveMarkdown?: boolean) => Promise<void>;
renameThread: (threadId: string, name: string) => Promise<void>;
threadActions: Pick<ThreadManagementActions, "forkThread" | "rollbackThread" | "compactThread" | "archiveThread" | "renameThread">;
reconnect: () => Promise<void>;
toggleFastMode: () => void | Promise<void>;
toggleCollaborationMode: () => void | Promise<void>;
toggleAutoReview: () => void | Promise<void>;
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: MessageStreamNoticeSection[]) => void;
requestModel: (model: string) => boolean | undefined | Promise<boolean | undefined>;
resetModelToConfig: () => boolean | undefined | Promise<boolean | undefined>;
requestReasoningEffort: (effort: ReasoningEffort) => boolean | undefined | Promise<boolean | undefined>;
resetReasoningEffortToConfig: () => boolean | undefined | Promise<boolean | undefined>;
runtimeSettings: Pick<
ChatRuntimeSettingsActions,
| "toggleFastMode"
| "toggleCollaborationMode"
| "toggleAutoReview"
| "requestModel"
| "resetModelToConfig"
| "requestReasoningEffort"
| "resetReasoningEffortToConfig"
>;
supportedReasoningEfforts: () => readonly ReasoningEffort[];
activeGoal: () => ThreadGoal | null;
setGoalObjective: (threadId: string, objective: string, tokenBudget: number | null) => Promise<boolean>;
setGoalStatus: (threadId: string, status: ThreadGoalStatus) => Promise<boolean>;
clearGoal: (threadId: string) => Promise<boolean>;
goals: Pick<GoalActions, "activeGoal" | "setObjective" | "setStatus" | "clear">;
statusSummaryLines: () => string[];
connectionDiagnosticDetails: () => MessageStreamNoticeSection[];
mcpStatusLines: () => Promise<string[]>;
@ -84,189 +83,167 @@ export async function executeSlashCommand(
return;
}
if (command === "clear") {
await context.startNewThread();
return;
}
if (command === "resume") {
const thread = resolveThreadArgument(args, context.listedThreads);
if (!thread.ok) {
context.addSystemMessage(thread.message);
switch (command) {
case "clear":
await context.startNewThread();
return;
case "resume": {
const thread = resolveThreadArgument(args, context.listedThreads);
if (!thread.ok) {
context.addSystemMessage(thread.message);
return;
}
await context.resumeThread(thread.thread.id);
return;
}
await context.resumeThread(thread.thread.id);
return;
}
case "reconnect":
await context.reconnect();
return;
case "refer": {
const parsed = parseReferArgs(args);
if (!parsed) {
context.addSystemMessage(usageError(command, "requires a thread and a message"));
return;
}
const thread = resolveThreadArgument(parsed.threadQuery, context.listedThreads);
if (!thread.ok) {
context.addSystemMessage(thread.message);
return;
}
if (thread.thread.id === context.activeThreadId) {
context.addSystemMessage(currentThreadReferenceMessage());
return;
}
const reference = await context.referThread(thread.thread, parsed.message);
if (!reference) return;
return { sendText: parsed.message, sendInput: reference.input, referencedThread: reference.referencedThread };
}
case "fork":
if (!context.activeThreadId) {
context.addSystemMessage(noActiveThreadToForkMessage());
return;
}
await context.threadActions.forkThread(context.activeThreadId);
return;
case "rollback":
if (!context.activeThreadId) {
context.addSystemMessage(noActiveThreadToRollbackMessage());
return;
}
if (context.busy) {
context.addSystemMessage(interruptBeforeRollbackMessage());
return;
}
await context.threadActions.rollbackThread(context.activeThreadId);
return;
case "compact":
if (!context.activeThreadId) {
context.addSystemMessage(noActiveThreadToCompactMessage());
return;
}
await context.threadActions.compactThread(context.activeThreadId);
return;
case "archive": {
if (context.busy) {
context.addSystemMessage(finishBeforeArchivingThreadsMessage());
return;
}
if (command === "reconnect") {
await context.reconnect();
return;
}
if (command === "refer") {
const parsed = parseReferArgs(args);
if (!parsed) {
context.addSystemMessage(usageError(command, "requires a thread and a message"));
const thread = resolveThreadArgument(args, context.listedThreads);
if (!thread.ok) {
context.addSystemMessage(thread.message);
return;
}
await context.threadActions.archiveThread(thread.thread.id);
return;
}
const thread = resolveThreadArgument(parsed.threadQuery, context.listedThreads);
if (!thread.ok) {
context.addSystemMessage(thread.message);
case "rename": {
const parsed = parseThreadAndNameArgs(args);
if (!parsed) {
context.addSystemMessage(usageError(command, "requires a thread and a name"));
return;
}
const thread = resolveThreadArgument(parsed.threadQuery, context.listedThreads);
if (!thread.ok) {
context.addSystemMessage(thread.message);
return;
}
await context.threadActions.renameThread(thread.thread.id, parsed.text);
return;
}
if (thread.thread.id === context.activeThreadId) {
context.addSystemMessage(currentThreadReferenceMessage());
case "fast":
await context.runtimeSettings.toggleFastMode();
return;
case "auto-review":
await context.runtimeSettings.toggleAutoReview();
return;
case "plan":
await context.runtimeSettings.toggleCollaborationMode();
if (args) return { sendText: args };
return;
case "goal":
return await executeGoalCommand(args, context);
case "status":
context.addStructuredSystemMessage("Thread status", detailsFromLines(context.statusSummaryLines()));
return;
case "doctor":
context.addStructuredSystemMessage("Connection diagnostics", context.connectionDiagnosticDetails());
return;
case "mcp":
context.addStructuredSystemMessage("MCP servers", detailsFromLines(await context.mcpStatusLines()));
return;
case "model": {
const requested = parseModelOverride(args);
if (requested !== undefined) {
const applied = await applyModelOverride(context, requested);
if (applied === false) return;
context.addSystemMessage(modelOverrideMessage(requested));
return;
}
context.addStructuredSystemMessage("Model settings", detailsFromLines(context.modelStatusLines()));
return;
}
const reference = await context.referThread(thread.thread, parsed.message);
if (!reference) return;
return { sendText: parsed.message, sendInput: reference.input, referencedThread: reference.referencedThread };
}
if (command === "fork") {
if (!context.activeThreadId) {
context.addSystemMessage(noActiveThreadToForkMessage());
return;
}
await context.forkThread(context.activeThreadId);
return;
}
if (command === "rollback") {
if (!context.activeThreadId) {
context.addSystemMessage(noActiveThreadToRollbackMessage());
return;
}
if (context.busy) {
context.addSystemMessage(interruptBeforeRollbackMessage());
return;
}
await context.rollbackThread(context.activeThreadId);
return;
}
if (command === "compact") {
if (!context.activeThreadId) {
context.addSystemMessage(noActiveThreadToCompactMessage());
return;
}
await context.compactThread(context.activeThreadId);
return;
}
if (command === "archive") {
if (context.busy) {
context.addSystemMessage(finishBeforeArchivingThreadsMessage());
return;
}
const thread = resolveThreadArgument(args, context.listedThreads);
if (!thread.ok) {
context.addSystemMessage(thread.message);
return;
}
await context.archiveThread(thread.thread.id);
return;
}
if (command === "rename") {
const parsed = parseThreadAndNameArgs(args);
if (!parsed) {
context.addSystemMessage(usageError(command, "requires a thread and a name"));
return;
}
const thread = resolveThreadArgument(parsed.threadQuery, context.listedThreads);
if (!thread.ok) {
context.addSystemMessage(thread.message);
return;
}
await context.renameThread(thread.thread.id, parsed.text);
return;
}
if (command === "fast") {
await context.toggleFastMode();
return;
}
if (command === "auto-review") {
await context.toggleAutoReview();
return;
}
if (command === "plan") {
await context.toggleCollaborationMode();
if (args) return { sendText: args };
return;
}
if (command === "goal") {
return await executeGoalCommand(args, context);
}
if (command === "status") {
context.addStructuredSystemMessage("Thread status", detailsFromLines(context.statusSummaryLines()));
return;
}
if (command === "doctor") {
context.addStructuredSystemMessage("Connection diagnostics", context.connectionDiagnosticDetails());
return;
}
if (command === "mcp") {
context.addStructuredSystemMessage("MCP servers", detailsFromLines(await context.mcpStatusLines()));
return;
}
if (command === "model") {
const requested = parseModelOverride(args);
if (requested !== undefined) {
const applied = await applyModelOverride(context, requested);
if (applied === false) return;
context.addSystemMessage(modelOverrideMessage(requested));
return;
}
context.addStructuredSystemMessage("Model settings", detailsFromLines(context.modelStatusLines()));
return;
}
if (command === "reasoning") {
const requested = parseReasoningEffortOverride(args);
if (requested !== undefined) {
if (requested !== null && !context.supportedReasoningEfforts().includes(requested)) {
case "reasoning": {
const requested = parseReasoningEffortOverride(args);
if (requested !== undefined) {
if (requested !== null && !context.supportedReasoningEfforts().includes(requested)) {
context.addSystemMessage(`Unsupported reasoning level: ${args}. Usage: ${slashCommandDefinition(command).usage}`);
return;
}
const applied = await applyReasoningEffortOverride(context, requested);
if (applied === false) return;
context.addSystemMessage(reasoningEffortOverrideMessage(requested));
return;
}
if (args) {
context.addSystemMessage(`Unsupported reasoning level: ${args}. Usage: ${slashCommandDefinition(command).usage}`);
return;
}
const applied = await applyReasoningEffortOverride(context, requested);
if (applied === false) return;
context.addSystemMessage(reasoningEffortOverrideMessage(requested));
context.addStructuredSystemMessage("Reasoning effort", detailsFromLines(context.effortStatusLines()));
return;
}
if (args) {
context.addSystemMessage(`Unsupported reasoning level: ${args}. Usage: ${slashCommandDefinition(command).usage}`);
case "help":
context.addStructuredSystemMessage("Available slash commands", slashCommandHelpSections());
return;
}
context.addStructuredSystemMessage("Reasoning effort", detailsFromLines(context.effortStatusLines()));
return;
}
context.addStructuredSystemMessage("Available slash commands", slashCommandHelpSections());
const _exhaustive: never = command;
return _exhaustive;
}
function applyModelOverride(
context: SlashCommandExecutionContext,
requested: string | null,
): boolean | undefined | Promise<boolean | undefined> {
return requested === null ? context.resetModelToConfig() : context.requestModel(requested);
return requested === null ? context.runtimeSettings.resetModelToConfig() : context.runtimeSettings.requestModel(requested);
}
function applyReasoningEffortOverride(
context: SlashCommandExecutionContext,
requested: ReasoningEffort | null,
): boolean | undefined | Promise<boolean | undefined> {
return requested === null ? context.resetReasoningEffortToConfig() : context.requestReasoningEffort(requested);
return requested === null
? context.runtimeSettings.resetReasoningEffortToConfig()
: context.runtimeSettings.requestReasoningEffort(requested);
}
function parseModelOverride(args: string): string | null | undefined {
@ -299,7 +276,7 @@ async function executeGoalCommand(args: string, context: SlashCommandExecutionCo
return;
}
if (parsed.kind === "show") {
const goal = context.activeGoal();
const goal = context.goals.activeGoal();
if (!goal) {
context.addSystemMessage("No goal set.");
return;
@ -307,14 +284,14 @@ async function executeGoalCommand(args: string, context: SlashCommandExecutionCo
context.addStructuredSystemMessage("Thread goal", goalDetails(goal));
return;
}
const goal = context.activeGoal();
const goal = context.goals.activeGoal();
if (parsed.kind === "set") {
const threadId = context.activeThreadId ?? (await context.startThreadForGoal(parsed.objective));
if (!threadId) {
context.addSystemMessage("No active thread for goal management.");
return;
}
await context.setGoalObjective(threadId, parsed.objective, goal?.tokenBudget ?? null);
await context.goals.setObjective(threadId, parsed.objective, goal?.tokenBudget ?? null);
return;
}
const threadId = context.activeThreadId;
@ -330,14 +307,14 @@ async function executeGoalCommand(args: string, context: SlashCommandExecutionCo
return { composerDraft: `/goal set ${goal.objective}` };
}
if (parsed.kind === "pause") {
await context.setGoalStatus(threadId, "paused");
await context.goals.setStatus(threadId, "paused");
return;
}
if (parsed.kind === "resume") {
await context.setGoalStatus(threadId, "active");
await context.goals.setStatus(threadId, "active");
return;
}
await context.clearGoal(threadId);
await context.goals.clear(threadId);
}
type GoalArgs =

View file

@ -3,10 +3,11 @@ import { codexTextInputWithAttachments, type CodexInput } from "../../../../doma
import { readReferencedThreadConversationSummaries } from "../../../../app-server/threads/data";
import { referencedThreadPromptBundle, REFERENCED_THREAD_TURN_LIMIT } from "../../../../domain/threads/reference";
import type { Thread } from "../../../../domain/threads/model";
import type { MessageStreamNoticeSection } from "../../domain/message-stream/items";
import type { ChatRuntimeSettingsActions } from "../runtime/settings-actions";
import { referencedThreadStatus, referencedThreadUnreadableMessage } from "./messages";
import {
executeSlashCommand as runSlashCommand,
type SlashCommandExecutionContext,
type SlashCommandExecutionResult,
type ThreadReferenceInput,
} from "./slash-command-execution";
@ -17,14 +18,37 @@ import { submissionStateSnapshot } from "../state/selectors";
import type { ChatStateStore } from "../state/store";
import { currentModel, runtimeConfigOrDefault } from "../../domain/runtime/effective";
import { runtimeSnapshotForChatState } from "../runtime/snapshot";
import type { GoalActions } from "../threads/goal-actions";
import type { ThreadManagementActions } from "../threads/thread-management-actions";
type DynamicSlashCommandExecutionContext = "activeThreadId" | "busy" | "listedThreads" | "referThread" | "supportedReasoningEfforts";
export interface SlashCommandHandlerHost extends Omit<SlashCommandExecutionContext, DynamicSlashCommandExecutionContext> {
export interface SlashCommandHandlerHost {
stateStore: ChatStateStore;
currentClient: () => AppServerClient | null;
codexInput: (text: string) => CodexInput;
setStatus: (status: string) => void;
startNewThread: () => Promise<void>;
startThreadForGoal: (objective: string) => Promise<string | null>;
resumeThread: (threadId: string) => Promise<void>;
reconnect: () => Promise<void>;
threadActions: Pick<ThreadManagementActions, "forkThread" | "rollbackThread" | "compactThread" | "archiveThread" | "renameThread">;
runtimeSettings: Pick<
ChatRuntimeSettingsActions,
| "toggleFastMode"
| "toggleCollaborationMode"
| "toggleAutoReview"
| "requestModel"
| "resetModelToConfig"
| "requestReasoningEffort"
| "resetReasoningEffortToConfig"
>;
goals: Pick<GoalActions, "activeGoal" | "setObjective" | "setStatus" | "clear">;
addSystemMessage: (text: string) => void;
addStructuredSystemMessage: (text: string, details: MessageStreamNoticeSection[]) => void;
statusSummaryLines: () => string[];
connectionDiagnosticDetails: () => MessageStreamNoticeSection[];
mcpStatusLines: () => Promise<string[]>;
modelStatusLines: () => string[];
effortStatusLines: () => string[];
}
export async function executeSlashCommandWithState(

View file

@ -5,11 +5,10 @@ import { ConnectionManager } from "../../../app-server/connection/connection-man
import type { AppServerObservedQueryResult } from "../../../app-server/query/cache";
import { isStaleAppServerSharedQueryContextError } from "../../../app-server/query/shared-queries";
import { appServerQueryContextRawEquals, type AppServerQueryContext } from "../../../app-server/query/keys";
import { renameThreadOnAppServer, threadRenameFromValue } from "../../../app-server/services/thread-rename";
import type { ModelMetadata } from "../../../domain/catalog/metadata";
import type { Thread } from "../../../domain/threads/model";
import { getThreadTitle } from "../../../domain/threads/model";
import { getThreadTitle, normalizeExplicitThreadName } from "../../../domain/threads/model";
import type { SharedServerMetadata } from "../../../domain/server/metadata";
import { ConnectionWorkTracker } from "../../../shared/lifecycle/connection-work";
import { shortThreadId } from "../../../utils";
@ -640,15 +639,15 @@ export class ChatPanelSession implements ChatSurfaceHandle {
stateStore: this.stateStore,
titleService,
renameGeneratedTitle: async (threadId, title, options) => {
const rename = threadRenameFromValue(title);
if (!rename) return false;
const name = normalizeExplicitThreadName(title);
if (!name) return false;
const client = currentClient();
if (!client) return false;
const result = await renameThreadOnAppServer(client, threadId, rename);
await client.setThreadName(threadId, name);
if (currentClient() !== client) return false;
if (options.shouldPublish()) {
this.environment.plugin.threadCatalog.renameThreadInCatalog(threadId, result.name);
this.environment.plugin.threadCatalog.renameThreadInCatalog(threadId, name);
}
return true;
},
@ -1168,9 +1167,9 @@ export class ChatPanelSession implements ChatSurfaceHandle {
void loadSharedThreadList();
},
refreshRateLimits: () => {
void serverMetadata.refreshPublishedRateLimits();
void serverMetadata.refreshRateLimits({ preserveExistingOnFailure: true });
},
refreshSkills: (forceReload) => void serverMetadata.refreshPublishedSkills(forceReload),
refreshSkills: (forceReload) => void serverMetadata.refreshSkills(forceReload),
applyAppServerMetadataSnapshot: () => {
serverMetadata.applyAppServerMetadataSnapshot();
},
@ -1227,17 +1226,17 @@ export class ChatPanelSession implements ChatSurfaceHandle {
isConnected: () => connection.isConnected(),
},
metadata: {
refreshPublishedAppServerMetadata: () => serverMetadata.refreshPublishedAppServerMetadata(),
refreshPublishedSkills: (forceReload) => serverMetadata.refreshPublishedSkills(forceReload),
refreshAppServerMetadata: () => serverMetadata.refreshAppServerMetadata(),
refreshSkills: (forceReload) => serverMetadata.refreshSkills(forceReload),
},
diagnostics: {
refreshPublishedDiagnosticProbes: (options) => serverDiagnostics.refreshPublishedDiagnosticProbes(options),
refreshDiagnosticProbes: (options) => serverDiagnostics.refreshDiagnosticProbes(options),
},
loadSharedThreadList,
scheduleDeferredDiagnostics: () => {
this.deferredTasks.scheduleDiagnostics(() => {
if (connection.isConnected()) {
void serverDiagnostics.refreshPublishedDiagnosticProbes({ appServerMetadataSnapshot: true });
void serverDiagnostics.refreshDiagnosticProbes({ appServerMetadataSnapshot: true });
}
});
},

View file

@ -1,7 +1,7 @@
import type { AppServerClient } from "../../app-server/connection/client";
import { archiveThreadOnAppServer, type ArchiveThreadResult } from "../../app-server/services/thread-archive";
import type { ArchiveExportAdapter } from "../../app-server/services/thread-archive-markdown";
import { renameThreadOnAppServer, threadRenameFromValue, type ThreadRename } from "../../app-server/services/thread-rename";
import { normalizeExplicitThreadName } from "../../domain/threads/model";
import type { CodexPanelSettings } from "../../settings/model";
export interface ThreadOperationsHost {
@ -48,25 +48,16 @@ async function renameThread(
value: string,
options: RenameThreadOptions = {},
): Promise<boolean> {
const rename = threadRenameFromValue(value);
if (!rename) return false;
const name = normalizeExplicitThreadName(value);
if (!name) return false;
await host.connection.ensureConnected();
return renameConnectedThread(host, threadId, rename, options);
}
async function renameConnectedThread(
host: ThreadOperationsHost,
threadId: string,
rename: ThreadRename,
options: RenameThreadOptions = {},
): Promise<boolean> {
const client = host.connection.currentClient();
if (!client) return false;
const result = await renameThreadOnAppServer(client, threadId, rename);
await client.setThreadName(threadId, name);
if (options.shouldPublish?.() ?? true) {
host.catalog.renameThreadInCatalog(threadId, result.name);
host.catalog.renameThreadInCatalog(threadId, name);
}
return true;
}

View file

@ -51,9 +51,6 @@ export class CodexPanelRuntime {
this.threadCatalog = createSharedThreadCatalog({
queries: this.appServerSharedQueries,
surfaces: {
invalidateThreadsFromOpenSurface: () => {
this.invalidateThreadsFromOpenSurface();
},
applyThreadArchived: (threadId, archiveOptions) => {
this.applyThreadArchived(threadId, archiveOptions);
},
@ -178,17 +175,6 @@ export class CodexPanelRuntime {
}
}
private invalidateThreadsFromOpenSurface(): void {
const chatSurface = this.connectedPanelSurface();
if (chatSurface) {
void chatSurface.refreshSharedThreadList();
return;
}
const threadsView = this.threadsViews().at(0);
if (threadsView) void threadsView.refresh();
}
private applyThreadArchived(threadId: string, archiveOptions: { closeOpenPanels?: boolean } = {}): void {
const leavesToClose = archiveOptions.closeOpenPanels ? this.panels.panelLeavesForThread(threadId) : [];
for (const view of this.panels.panelViews()) {
@ -207,14 +193,6 @@ export class CodexPanelRuntime {
}
}
private connectedPanelSurface(): (ChatWorkspacePanelSurface & ChatSharedThreadSurface) | null {
for (const view of this.panels.panelViews()) {
const surface: ChatWorkspacePanelSurface & ChatSharedThreadSurface = view.surface;
if (surface.openPanelSnapshot().connected) return surface;
}
return null;
}
private refreshThreadsViewLiveState(): void {
for (const view of this.threadsViews()) {
view.refreshLiveState();

View file

@ -36,7 +36,7 @@ type SettingsAppServerData = Pick<
"modelsSnapshot" | "observeModelsResult" | "fetchModels" | "refreshModels" | "notifyContextChanged"
>;
type SettingsThreadCatalog = Pick<SharedThreadCatalog, "invalidateThreadsFromOpenSurface">;
type SettingsThreadCatalog = Pick<SharedThreadCatalog, "refreshActiveThreads">;
function archivedThreadTitleForStatus(thread: Thread | undefined, threadId: string): string {
return thread ? archivedThreadDisplayTitle(thread) : threadId;
@ -60,7 +60,10 @@ export interface SettingsDynamicDataSnapshot {
export class SettingsDynamicDataController {
private settingsDataAutoLoadStarted = false;
private settingsDynamicOperationId = 0;
private settingsRefreshOperationId = 0;
private modelsOperationId = 0;
private hooksOperationId = 0;
private archivedThreadsOperationId = 0;
private settingsDataRefreshLifecycle: SettingsDataRefreshLifecycleState = { kind: "idle" };
private archivedThreads: Thread[] = [];
@ -99,7 +102,10 @@ export class SettingsDynamicDataController {
resetSettingsDataContext(): void {
this.settingsDataAutoLoadStarted = false;
this.settingsDynamicOperationId += 1;
this.settingsRefreshOperationId += 1;
this.modelsOperationId += 1;
this.hooksOperationId += 1;
this.archivedThreadsOperationId += 1;
this.settingsDataRefreshLifecycle = { kind: "idle" };
this.models = [...(this.host.appServerData.modelsSnapshot() ?? [])];
this.modelsLifecycle = createSettingsDynamicSectionLifecycle();
@ -124,7 +130,10 @@ export class SettingsDynamicDataController {
async refreshSettingsData(options: { forceModels?: boolean } = {}): Promise<void> {
this.settingsDataAutoLoadStarted = true;
const operationId = this.nextSettingsDynamicOperationId();
const operationId = this.nextSettingsRefreshOperationId();
const modelsOperationId = this.nextModelsOperationId();
const hooksOperationId = this.nextHooksOperationId();
const archivedThreadsOperationId = this.nextArchivedThreadsOperationId();
this.settingsDataRefreshLifecycle = transitionSettingsDataRefreshLifecycle(this.settingsDataRefreshLifecycle, {
type: "started",
operationId,
@ -132,17 +141,17 @@ export class SettingsDynamicDataController {
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
type: "started",
status: "Loading models...",
operationId,
operationId: modelsOperationId,
});
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "started",
status: "Loading archived threads...",
operationId,
operationId: archivedThreadsOperationId,
});
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "started",
status: "Loading hooks...",
operationId,
operationId: hooksOperationId,
});
this.callbacks.display();
@ -152,14 +161,16 @@ export class SettingsDynamicDataController {
options.forceModels === false ? this.host.appServerData.fetchModels() : this.host.appServerData.refreshModels(),
this.withSettingsConnection((client) => loadSettingsCompanionData(client, this.host.vaultPath)),
] as const);
if (this.isStaleSettingsDynamicOperation(operationId)) return;
if (this.isStaleSettingsRefreshOperation(operationId)) return;
if (modelsResult.status === "fulfilled") {
if (this.isStaleModelsOperation(modelsOperationId)) {
// A newer models operation owns this section.
} else if (modelsResult.status === "fulfilled") {
this.models = [...modelsResult.value];
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
type: "loaded",
status: `Loaded ${String(modelsResult.value.length)} model${modelsResult.value.length === 1 ? "" : "s"}.`,
operationId,
operationId: modelsOperationId,
});
} else if (isStaleAppServerSharedQueryContextError(modelsResult.reason)) {
return;
@ -168,7 +179,7 @@ export class SettingsDynamicDataController {
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
type: "failed",
status: `Could not load models: ${errorMessage(modelsResult.reason)}`,
operationId,
operationId: modelsOperationId,
});
}
@ -180,60 +191,70 @@ export class SettingsDynamicDataController {
archivedThreads: { ok: false as const, status: `Could not load archived threads: ${errorMessage(companionResult.reason)}` },
};
if (companion.hooks.ok) {
if (this.isStaleHooksOperation(hooksOperationId)) {
// A newer hooks operation owns this section.
} else if (companion.hooks.ok) {
this.hooks = companion.hooks.data.hooks;
this.hookWarnings = companion.hooks.data.warnings;
this.hookErrors = companion.hooks.data.errors;
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "loaded",
status: companion.hooks.status,
operationId,
operationId: hooksOperationId,
});
} else {
failedCount += 1;
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "failed",
status: companion.hooks.status,
operationId,
operationId: hooksOperationId,
});
}
if (companion.archivedThreads.ok) {
if (this.isStaleArchivedThreadsOperation(archivedThreadsOperationId)) {
// A newer archived threads operation owns this section.
} else if (companion.archivedThreads.ok) {
this.archivedThreads = companion.archivedThreads.data;
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "loaded",
status: companion.archivedThreads.status,
operationId,
operationId: archivedThreadsOperationId,
});
} else {
failedCount += 1;
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "failed",
status: companion.archivedThreads.status,
operationId,
operationId: archivedThreadsOperationId,
});
}
} catch (error) {
if (this.isStaleSettingsDynamicOperation(operationId)) return;
if (this.isStaleSettingsRefreshOperation(operationId)) return;
failedCount = 3;
const message = errorMessage(error);
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
type: "failed",
status: `Could not load models: ${message}`,
operationId,
});
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "failed",
status: `Could not load hooks: ${message}`,
operationId,
});
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "failed",
status: `Could not load archived threads: ${message}`,
operationId,
});
if (!this.isStaleModelsOperation(modelsOperationId)) {
this.modelsLifecycle = transitionSettingsDynamicSectionLifecycle(this.modelsLifecycle, {
type: "failed",
status: `Could not load models: ${message}`,
operationId: modelsOperationId,
});
}
if (!this.isStaleHooksOperation(hooksOperationId)) {
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "failed",
status: `Could not load hooks: ${message}`,
operationId: hooksOperationId,
});
}
if (!this.isStaleArchivedThreadsOperation(archivedThreadsOperationId)) {
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "failed",
status: `Could not load archived threads: ${message}`,
operationId: archivedThreadsOperationId,
});
}
} finally {
const staleOperation = this.isStaleSettingsDynamicOperation(operationId);
const staleOperation = this.isStaleSettingsRefreshOperation(operationId);
this.settingsDataRefreshLifecycle = transitionSettingsDataRefreshLifecycle(this.settingsDataRefreshLifecycle, {
type: "completed",
failedCount,
@ -266,7 +287,7 @@ export class SettingsDynamicDataController {
}
async trustHook(hook: HookItem): Promise<void> {
const operationId = this.nextSettingsDynamicOperationId();
const operationId = this.nextHooksOperationId();
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "started",
status: "Loading hooks...",
@ -275,7 +296,7 @@ export class SettingsDynamicDataController {
this.callbacks.display();
try {
await this.withSettingsConnection((client) => trustHookItem(client, hook));
if (this.isStaleSettingsDynamicOperation(operationId)) return;
if (this.isStaleHooksOperation(operationId)) return;
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "loaded",
status: "Trusted hook definition.",
@ -283,7 +304,7 @@ export class SettingsDynamicDataController {
});
await this.loadHooks();
} catch (error) {
if (this.isStaleSettingsDynamicOperation(operationId)) return;
if (this.isStaleHooksOperation(operationId)) return;
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "failed",
status: `Could not trust hook: ${errorMessage(error)}`,
@ -295,7 +316,7 @@ export class SettingsDynamicDataController {
}
async setHookEnabled(hook: HookItem, enabled: boolean): Promise<void> {
const operationId = this.nextSettingsDynamicOperationId();
const operationId = this.nextHooksOperationId();
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "started",
status: "Loading hooks...",
@ -304,7 +325,7 @@ export class SettingsDynamicDataController {
this.callbacks.display();
try {
await this.withSettingsConnection((client) => setHookItemEnabled(client, hook, enabled));
if (this.isStaleSettingsDynamicOperation(operationId)) return;
if (this.isStaleHooksOperation(operationId)) return;
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "loaded",
status: enabled ? "Enabled hook." : "Disabled hook.",
@ -312,7 +333,7 @@ export class SettingsDynamicDataController {
});
await this.loadHooks();
} catch (error) {
if (this.isStaleSettingsDynamicOperation(operationId)) return;
if (this.isStaleHooksOperation(operationId)) return;
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "failed",
status: `Could not update hook: ${errorMessage(error)}`,
@ -324,7 +345,7 @@ export class SettingsDynamicDataController {
}
async restoreArchivedThread(threadId: string): Promise<void> {
const operationId = this.nextSettingsDynamicOperationId();
const operationId = this.nextArchivedThreadsOperationId();
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "started",
status: "Loading archived threads...",
@ -333,16 +354,23 @@ export class SettingsDynamicDataController {
this.callbacks.display();
try {
const restoredThread = await this.withSettingsConnection((client) => restoreArchivedThreadOnAppServer(client, threadId));
if (this.isStaleSettingsDynamicOperation(operationId)) return;
if (this.isStaleArchivedThreadsOperation(operationId)) return;
this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId);
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "loaded",
status: `Restored "${archivedThreadDisplayTitle(restoredThread)}".`,
operationId,
});
this.host.threadCatalog.invalidateThreadsFromOpenSurface();
this.callbacks.display();
try {
await this.host.threadCatalog.refreshActiveThreads();
} catch (error) {
if (!this.isStaleArchivedThreadsOperation(operationId) && !isStaleAppServerSharedQueryContextError(error)) {
this.callbacks.notify("Could not refresh active Codex threads.");
}
}
} catch (error) {
if (this.isStaleSettingsDynamicOperation(operationId)) return;
if (this.isStaleArchivedThreadsOperation(operationId)) return;
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "failed",
status: `Could not restore archived thread: ${errorMessage(error)}`,
@ -350,12 +378,12 @@ export class SettingsDynamicDataController {
});
this.callbacks.notify("Could not restore archived Codex thread.");
} finally {
if (!this.isStaleSettingsDynamicOperation(operationId)) this.callbacks.display();
if (!this.isStaleArchivedThreadsOperation(operationId)) this.callbacks.display();
}
}
async deleteArchivedThread(threadId: string): Promise<void> {
const operationId = this.nextSettingsDynamicOperationId();
const operationId = this.nextArchivedThreadsOperationId();
const title = archivedThreadTitleForStatus(
this.archivedThreads.find((thread) => thread.id === threadId),
threadId,
@ -368,16 +396,15 @@ export class SettingsDynamicDataController {
this.callbacks.display();
try {
await this.withSettingsConnection((client) => deleteArchivedThreadOnAppServer(client, threadId));
if (this.isStaleSettingsDynamicOperation(operationId)) return;
if (this.isStaleArchivedThreadsOperation(operationId)) return;
this.archivedThreads = this.archivedThreads.filter((thread) => thread.id !== threadId);
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "loaded",
status: `Deleted "${title}".`,
operationId,
});
this.host.threadCatalog.invalidateThreadsFromOpenSurface();
} catch (error) {
if (this.isStaleSettingsDynamicOperation(operationId)) return;
if (this.isStaleArchivedThreadsOperation(operationId)) return;
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
type: "failed",
status: `Could not delete archived thread: ${errorMessage(error)}`,
@ -385,7 +412,7 @@ export class SettingsDynamicDataController {
});
this.callbacks.notify("Could not delete archived Codex thread.");
} finally {
if (!this.isStaleSettingsDynamicOperation(operationId)) this.callbacks.display();
if (!this.isStaleArchivedThreadsOperation(operationId)) this.callbacks.display();
}
}
@ -407,7 +434,7 @@ export class SettingsDynamicDataController {
}
private async loadHooks(): Promise<void> {
const operationId = this.nextSettingsDynamicOperationId();
const operationId = this.nextHooksOperationId();
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "started",
status: "Loading hooks...",
@ -416,7 +443,7 @@ export class SettingsDynamicDataController {
this.callbacks.display();
try {
const hooks = await this.withSettingsConnection((client) => loadHookData(client, this.host.vaultPath));
if (this.isStaleSettingsDynamicOperation(operationId)) return;
if (this.isStaleHooksOperation(operationId)) return;
this.hooks = hooks.hooks;
this.hookWarnings = hooks.warnings;
this.hookErrors = hooks.errors;
@ -426,7 +453,7 @@ export class SettingsDynamicDataController {
operationId,
});
} catch (error) {
if (this.isStaleSettingsDynamicOperation(operationId)) return;
if (this.isStaleHooksOperation(operationId)) return;
this.hooksLifecycle = transitionSettingsDynamicSectionLifecycle(this.hooksLifecycle, {
type: "failed",
status: `Could not load hooks: ${errorMessage(error)}`,
@ -434,7 +461,7 @@ export class SettingsDynamicDataController {
});
this.callbacks.notify("Could not load Codex hooks.");
} finally {
if (!this.isStaleSettingsDynamicOperation(operationId)) this.callbacks.display();
if (!this.isStaleHooksOperation(operationId)) this.callbacks.display();
}
}
@ -444,13 +471,40 @@ export class SettingsDynamicDataController {
});
}
private nextSettingsDynamicOperationId(): number {
this.settingsDynamicOperationId += 1;
return this.settingsDynamicOperationId;
private nextSettingsRefreshOperationId(): number {
this.settingsRefreshOperationId += 1;
return this.settingsRefreshOperationId;
}
private isStaleSettingsDynamicOperation(operationId: number): boolean {
return operationId !== this.settingsDynamicOperationId;
private nextModelsOperationId(): number {
this.modelsOperationId += 1;
return this.modelsOperationId;
}
private nextHooksOperationId(): number {
this.hooksOperationId += 1;
return this.hooksOperationId;
}
private nextArchivedThreadsOperationId(): number {
this.archivedThreadsOperationId += 1;
return this.archivedThreadsOperationId;
}
private isStaleSettingsRefreshOperation(operationId: number): boolean {
return operationId !== this.settingsRefreshOperationId;
}
private isStaleModelsOperation(operationId: number): boolean {
return operationId !== this.modelsOperationId;
}
private isStaleHooksOperation(operationId: number): boolean {
return operationId !== this.hooksOperationId;
}
private isStaleArchivedThreadsOperation(operationId: number): boolean {
return operationId !== this.archivedThreadsOperationId;
}
private selectedModel(modelIdOrName: string | null): ModelMetadata | null {

View file

@ -3,7 +3,6 @@ import type { AppServerObservedQueryResult } from "../app-server/query/cache";
import type { AppServerSharedQueries } from "../app-server/query/shared-queries";
interface ThreadSurfaceActions {
invalidateThreadsFromOpenSurface(): void;
applyThreadArchived(threadId: string, options?: { closeOpenPanels?: boolean }): void;
applyThreadRenamed(threadId: string, name: string | null): void;
refreshThreadsViewLiveState(): void;
@ -23,7 +22,6 @@ export interface SharedThreadCatalog {
listener: (result: AppServerObservedQueryResult<readonly Thread[]>) => void,
options?: { emitCurrent?: boolean },
): () => void;
invalidateThreadsFromOpenSurface(): void;
renameThreadInCatalog(threadId: string, name: string | null): void;
archiveThreadInCatalog(threadId: string, options?: { closeOpenPanels?: boolean }): void;
refreshThreadsViewLiveState(): void;
@ -38,9 +36,6 @@ export function createSharedThreadCatalog(options: SharedThreadCatalogOptions):
options.queries.setActiveThreads(threads);
},
observeActiveThreadsResult: (listener, observeOptions) => options.queries.observeActiveThreadsResult(listener, observeOptions),
invalidateThreadsFromOpenSurface: () => {
options.surfaces.invalidateThreadsFromOpenSurface();
},
renameThreadInCatalog: (threadId, name) => {
options.queries.updateActiveThreads((current) => {
return current ? current.map((thread) => (thread.id === threadId ? { ...thread, name } : thread)) : null;

View file

@ -1,30 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient } from "../../src/app-server/connection/client";
import { renameThreadOnAppServer, threadRenameFromValue } from "../../src/app-server/services/thread-rename";
describe("thread rename operation", () => {
it("normalizes thread names before saving them", async () => {
const setThreadName = vi.fn().mockResolvedValue({});
const client = fakeClient({ setThreadName });
const rename = threadRenameFromValue(" Renamed thread ");
expect(rename).toEqual({ name: "Renamed thread" });
if (!rename) throw new Error("Expected normalized rename");
await expect(renameThreadOnAppServer(client, "thread", rename)).resolves.toEqual({ name: "Renamed thread" });
expect(setThreadName).toHaveBeenCalledWith("thread", "Renamed thread");
});
it("exposes normalized rename construction for surface guards", () => {
expect(threadRenameFromValue(" Saved title ")).toEqual({ name: "Saved title" });
expect(threadRenameFromValue(" ")).toBeNull();
});
});
function fakeClient(options: { setThreadName: ReturnType<typeof vi.fn> }): AppServerClient {
return {
setThreadName: options.setThreadName,
} as unknown as AppServerClient;
}

View file

@ -25,15 +25,15 @@ function createController({ connected = false, client = {} as AppServerClient }
currentClient: () => currentClient,
isConnected: () => Boolean(currentClient),
};
const refreshPublishedAppServerMetadata = vi.fn().mockResolvedValue(null);
const refreshPublishedDiagnosticProbes = vi.fn().mockResolvedValue(undefined);
const refreshPublishedSkills = vi.fn().mockResolvedValue(undefined);
const refreshAppServerMetadata = vi.fn().mockResolvedValue(null);
const refreshDiagnosticProbes = vi.fn().mockResolvedValue(undefined);
const refreshSkills = vi.fn().mockResolvedValue(undefined);
const metadata = {
refreshPublishedAppServerMetadata,
refreshPublishedSkills,
refreshAppServerMetadata,
refreshSkills,
} satisfies ChatConnectionMetadataActions;
const diagnostics = {
refreshPublishedDiagnosticProbes,
refreshDiagnosticProbes,
} satisfies ChatConnectionDiagnosticsActions;
const host: ChatConnectionControllerHost = {
stateStore,
@ -57,15 +57,15 @@ function createController({ connected = false, client = {} as AppServerClient }
connect,
controller: createChatConnectionController(host),
host,
refreshPublishedAppServerMetadata,
refreshPublishedDiagnosticProbes,
refreshAppServerMetadata,
refreshDiagnosticProbes,
stateStore,
};
}
describe("ChatConnectionController", () => {
it("connects once and publishes startup metadata", async () => {
const { connect, controller, host, refreshPublishedAppServerMetadata, stateStore } = createController();
const { connect, controller, host, refreshAppServerMetadata, stateStore } = createController();
await controller.ensureConnected();
@ -76,29 +76,29 @@ describe("ChatConnectionController", () => {
platformOs: "macos",
userAgent: "test",
});
expect(refreshPublishedAppServerMetadata).toHaveBeenCalledOnce();
expect(refreshAppServerMetadata).toHaveBeenCalledOnce();
expect(host.loadSharedThreadList).toHaveBeenCalledOnce();
expect(host.scheduleDeferredDiagnostics).toHaveBeenCalledOnce();
expect(host.setStatus).toHaveBeenCalledWith("Connected.", { kind: "connected" });
});
it("refreshes metadata before metadata-backed diagnostics", async () => {
const { controller, host, refreshPublishedAppServerMetadata, refreshPublishedDiagnosticProbes } = createController({ connected: true });
const { controller, host, refreshAppServerMetadata, refreshDiagnosticProbes } = createController({ connected: true });
await controller.refreshDiagnostics();
expect(host.clearDeferredDiagnostics).toHaveBeenCalledTimes(2);
expect(refreshPublishedAppServerMetadata).toHaveBeenCalledOnce();
expect(refreshPublishedDiagnosticProbes).toHaveBeenCalledWith({ appServerMetadataSnapshot: true });
expect(refreshAppServerMetadata).toHaveBeenCalledOnce();
expect(refreshDiagnosticProbes).toHaveBeenCalledWith({ appServerMetadataSnapshot: true });
});
it("refreshes active threads without refreshing metadata", async () => {
const { controller, host, refreshPublishedAppServerMetadata } = createController({ connected: true });
const { controller, host, refreshAppServerMetadata } = createController({ connected: true });
await controller.fetchActiveThreads();
expect(host.loadSharedThreadList).toHaveBeenCalledOnce();
expect(refreshPublishedAppServerMetadata).not.toHaveBeenCalled();
expect(refreshAppServerMetadata).not.toHaveBeenCalled();
});
it("clears disconnected connection state on server exit while keeping last startup metadata", () => {

View file

@ -423,7 +423,7 @@ describe("chat server actions", () => {
updateAppServerMetadata,
});
const refreshing = diagnostics.refreshPublishedDiagnosticProbes({ appServerMetadataSnapshot: true });
const refreshing = diagnostics.refreshDiagnosticProbes({ appServerMetadataSnapshot: true });
currentClient = secondClient;
hooksRefresh.resolve({ data: [{ cwd: "/vault", hooks: [{}] }] });
@ -473,7 +473,7 @@ describe("chat server actions", () => {
refreshAppServerMetadata,
});
const refreshing = controller.refreshPublishedAppServerMetadata();
const refreshing = controller.refreshAppServerMetadata();
await expect(refreshing).resolves.toBeNull();
expect(stateStore.getState().connection.availableModels).toEqual([]);
@ -543,7 +543,7 @@ describe("chat server actions", () => {
refreshAppServerMetadata: async () => null,
});
const refreshing = controller.refreshPublishedSkills(true);
const refreshing = controller.refreshSkills(true);
currentClient = secondClient;
skillRefresh.resolve({ data: [{ skills: [skillFixture("stale-skill")] }] });
@ -571,7 +571,7 @@ describe("chat server actions", () => {
refreshAppServerMetadata: async () => null,
});
await controller.refreshPublishedRateLimits();
await controller.refreshRateLimits({ preserveExistingOnFailure: true });
expect(stateStore.getState().connection.rateLimit).toMatchObject({ primary: { usedPercent: 64 } });
expect(cachedMetadata.current?.rateLimit).toStrictEqual(rateLimit);
@ -597,7 +597,7 @@ describe("chat server actions", () => {
refreshAppServerMetadata: async () => null,
});
await controller.refreshPublishedRateLimits();
await controller.refreshRateLimits({ preserveExistingOnFailure: true });
expect(stateStore.getState().connection.rateLimit).toBe(previousRateLimit);
expect(stateStore.getState().connection.serverDiagnostics.probes["account/rateLimits/read"]).toMatchObject({ status: "failed" });
@ -622,7 +622,7 @@ describe("chat server actions", () => {
refreshAppServerMetadata: async () => null,
});
const refreshing = controller.refreshPublishedRateLimits();
const refreshing = controller.refreshRateLimits({ preserveExistingOnFailure: true });
currentClient = secondClient;
rateLimitRefresh.resolve({
rateLimits: rateLimitFixture({ primary: { usedPercent: 88, windowDurationMins: 300, resetsAt: null } }),

View file

@ -20,26 +20,32 @@ function context(overrides: Partial<SlashCommandExecutionContext> = {}): SlashCo
input: [{ type: "text", text: "referenced" }],
referencedThread: { threadId: "thread-2", title: "Referenced", includedTurns: 1, turnLimit: 20 },
}),
forkThread: vi.fn().mockResolvedValue(undefined),
rollbackThread: vi.fn().mockResolvedValue(undefined),
compactThread: vi.fn().mockResolvedValue(undefined),
archiveThread: vi.fn().mockResolvedValue(undefined),
renameThread: vi.fn().mockResolvedValue(undefined),
threadActions: {
forkThread: vi.fn().mockResolvedValue(undefined),
rollbackThread: vi.fn().mockResolvedValue(undefined),
compactThread: vi.fn().mockResolvedValue(undefined),
archiveThread: vi.fn().mockResolvedValue(undefined),
renameThread: vi.fn().mockResolvedValue(true),
},
reconnect: vi.fn().mockResolvedValue(undefined),
toggleFastMode: vi.fn(),
toggleCollaborationMode: vi.fn(),
toggleAutoReview: vi.fn(),
addSystemMessage: vi.fn(),
addStructuredSystemMessage: vi.fn(),
requestModel: vi.fn(),
resetModelToConfig: vi.fn(),
requestReasoningEffort: vi.fn(),
resetReasoningEffortToConfig: vi.fn(),
runtimeSettings: {
toggleFastMode: vi.fn(),
toggleCollaborationMode: vi.fn(),
toggleAutoReview: vi.fn(),
requestModel: vi.fn(),
resetModelToConfig: vi.fn(),
requestReasoningEffort: vi.fn(),
resetReasoningEffortToConfig: vi.fn(),
},
supportedReasoningEfforts: () => ["low", "medium", "high"],
activeGoal: vi.fn(() => null),
setGoalObjective: vi.fn().mockResolvedValue(true),
setGoalStatus: vi.fn().mockResolvedValue(true),
clearGoal: vi.fn().mockResolvedValue(true),
goals: {
activeGoal: vi.fn(() => null),
setObjective: vi.fn().mockResolvedValue(true),
setStatus: vi.fn().mockResolvedValue(true),
clear: vi.fn().mockResolvedValue(true),
},
statusSummaryLines: () => ["status"],
connectionDiagnosticDetails: () => [{ title: "Process", rows: [{ key: "connection", value: "connected" }] }],
mcpStatusLines: vi.fn().mockResolvedValue(["mcp"]),
@ -173,7 +179,7 @@ describe("slash commands", () => {
await executeSlashCommand("fork", "", ctx);
expect(ctx.forkThread).toHaveBeenCalledWith("active-thread");
expect(ctx.threadActions.forkThread).toHaveBeenCalledWith("active-thread");
});
it("rejects /fork arguments", async () => {
@ -181,7 +187,7 @@ describe("slash commands", () => {
await executeSlashCommand("fork", "anything", ctx);
expect(ctx.forkThread).not.toHaveBeenCalled();
expect(ctx.threadActions.forkThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/fork does not take arguments. Usage: /fork");
});
@ -190,7 +196,7 @@ describe("slash commands", () => {
await executeSlashCommand("rollback", "", ctx);
expect(ctx.rollbackThread).toHaveBeenCalledWith("active-thread");
expect(ctx.threadActions.rollbackThread).toHaveBeenCalledWith("active-thread");
});
it("rejects /rollback without an active thread", async () => {
@ -198,7 +204,7 @@ describe("slash commands", () => {
await executeSlashCommand("rollback", "", ctx);
expect(ctx.rollbackThread).not.toHaveBeenCalled();
expect(ctx.threadActions.rollbackThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("No active thread to roll back.");
});
@ -207,7 +213,7 @@ describe("slash commands", () => {
await executeSlashCommand("rollback", "", ctx);
expect(ctx.rollbackThread).not.toHaveBeenCalled();
expect(ctx.threadActions.rollbackThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Interrupt the current turn before rolling back.");
});
@ -216,7 +222,7 @@ describe("slash commands", () => {
await executeSlashCommand("rollback", "2", ctx);
expect(ctx.rollbackThread).not.toHaveBeenCalled();
expect(ctx.threadActions.rollbackThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/rollback does not take arguments. Usage: /rollback");
});
@ -225,13 +231,14 @@ describe("slash commands", () => {
const result = await executeSlashCommand("plan", "", ctx);
expect(ctx.toggleCollaborationMode).toHaveBeenCalledOnce();
expect(ctx.runtimeSettings.toggleCollaborationMode).toHaveBeenCalledOnce();
expect(result).toBeUndefined();
});
it("shows the current goal for /goal", async () => {
const currentGoal = goal();
const ctx = context({ activeGoal: vi.fn(() => currentGoal) });
const ctx = context();
ctx.goals.activeGoal = vi.fn(() => currentGoal);
await executeSlashCommand("goal", "", ctx);
@ -253,21 +260,23 @@ describe("slash commands", () => {
it("sets, pauses, resumes, and clears goals", async () => {
const currentGoal = goal();
const ctx = context({ activeGoal: vi.fn(() => currentGoal) });
const ctx = context();
ctx.goals.activeGoal = vi.fn(() => currentGoal);
await executeSlashCommand("goal", "set Ship this", ctx);
await executeSlashCommand("goal", "pause", ctx);
await executeSlashCommand("goal", "resume", ctx);
await executeSlashCommand("goal", "clear", ctx);
expect(ctx.setGoalObjective).toHaveBeenCalledWith("thread-1", "Ship this", null);
expect(ctx.setGoalStatus).toHaveBeenCalledWith("thread-1", "paused");
expect(ctx.setGoalStatus).toHaveBeenCalledWith("thread-1", "active");
expect(ctx.clearGoal).toHaveBeenCalledWith("thread-1");
expect(ctx.goals.setObjective).toHaveBeenCalledWith("thread-1", "Ship this", null);
expect(ctx.goals.setStatus).toHaveBeenCalledWith("thread-1", "paused");
expect(ctx.goals.setStatus).toHaveBeenCalledWith("thread-1", "active");
expect(ctx.goals.clear).toHaveBeenCalledWith("thread-1");
});
it("loads the current goal into the composer for /goal edit", async () => {
const ctx = context({ activeGoal: vi.fn(() => goal({ objective: "Ship goal support" })) });
const ctx = context();
ctx.goals.activeGoal = vi.fn(() => goal({ objective: "Ship goal support" }));
const result = await executeSlashCommand("goal", "edit", ctx);
@ -287,21 +296,22 @@ describe("slash commands", () => {
await executeSlashCommand("goal", "set", ctx);
expect(ctx.setGoalObjective).not.toHaveBeenCalled();
expect(ctx.goals.setObjective).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/goal set <objective> requires an objective. Usage: /goal set <objective>");
});
it("rejects extra arguments for goal subcommands without free text", async () => {
const currentGoal = goal();
const ctx = context({ activeGoal: vi.fn(() => currentGoal) });
const ctx = context();
ctx.goals.activeGoal = vi.fn(() => currentGoal);
await executeSlashCommand("goal", "edit later", ctx);
await executeSlashCommand("goal", "pause later", ctx);
await executeSlashCommand("goal", "resume now", ctx);
await executeSlashCommand("goal", "clear please", ctx);
expect(ctx.setGoalStatus).not.toHaveBeenCalled();
expect(ctx.clearGoal).not.toHaveBeenCalled();
expect(ctx.goals.setStatus).not.toHaveBeenCalled();
expect(ctx.goals.clear).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/goal edit does not take arguments. Usage: /goal edit");
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/goal pause does not take arguments. Usage: /goal pause");
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/goal resume does not take arguments. Usage: /goal resume");
@ -324,7 +334,7 @@ describe("slash commands", () => {
await executeSlashCommand("goal", "set Ship this", ctx);
expect(ctx.startThreadForGoal).toHaveBeenCalledWith("Ship this");
expect(ctx.setGoalObjective).toHaveBeenCalledWith("thread-new", "Ship this", null);
expect(ctx.goals.setObjective).toHaveBeenCalledWith("thread-new", "Ship this", null);
expect(ctx.addSystemMessage).not.toHaveBeenCalledWith("No active thread for goal management.");
});
@ -334,7 +344,7 @@ describe("slash commands", () => {
await executeSlashCommand("goal", "pause", ctx);
expect(ctx.startThreadForGoal).not.toHaveBeenCalled();
expect(ctx.setGoalStatus).not.toHaveBeenCalled();
expect(ctx.goals.setStatus).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("No active thread for goal management.");
});
@ -343,7 +353,7 @@ describe("slash commands", () => {
const result = await executeSlashCommand("plan", "OK、実装してください", ctx);
expect(ctx.toggleCollaborationMode).toHaveBeenCalledOnce();
expect(ctx.runtimeSettings.toggleCollaborationMode).toHaveBeenCalledOnce();
expect(result).toEqual({ sendText: "OK、実装してください" });
});
@ -352,7 +362,7 @@ describe("slash commands", () => {
const result = await executeSlashCommand("auto-review", "", ctx);
expect(ctx.toggleAutoReview).toHaveBeenCalledOnce();
expect(ctx.runtimeSettings.toggleAutoReview).toHaveBeenCalledOnce();
expect(result).toBeUndefined();
});
@ -361,7 +371,7 @@ describe("slash commands", () => {
await executeSlashCommand("auto-review", "この依頼からお願いします", ctx);
expect(ctx.toggleAutoReview).not.toHaveBeenCalled();
expect(ctx.runtimeSettings.toggleAutoReview).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/auto-review does not take arguments. Usage: /auto-review");
});
@ -370,7 +380,7 @@ describe("slash commands", () => {
await executeSlashCommand("compact", "ignored for now", ctx);
expect(ctx.compactThread).not.toHaveBeenCalled();
expect(ctx.threadActions.compactThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/compact does not take arguments. Usage: /compact");
});
@ -379,7 +389,7 @@ describe("slash commands", () => {
await executeSlashCommand("archive", "", ctx);
expect(ctx.archiveThread).not.toHaveBeenCalled();
expect(ctx.threadActions.archiveThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/archive requires a thread. Usage: /archive <thread>");
});
@ -390,7 +400,7 @@ describe("slash commands", () => {
await executeSlashCommand("archive", "thread-beta", ctx);
expect(ctx.archiveThread).toHaveBeenCalledWith("thread-beta");
expect(ctx.threadActions.archiveThread).toHaveBeenCalledWith("thread-beta");
});
it("rejects /archive without a thread before active-thread checks", async () => {
@ -398,7 +408,7 @@ describe("slash commands", () => {
await executeSlashCommand("archive", "", ctx);
expect(ctx.archiveThread).not.toHaveBeenCalled();
expect(ctx.threadActions.archiveThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/archive requires a thread. Usage: /archive <thread>");
});
@ -407,7 +417,7 @@ describe("slash commands", () => {
await executeSlashCommand("archive", "thread-1", ctx);
expect(ctx.archiveThread).not.toHaveBeenCalled();
expect(ctx.threadActions.archiveThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Finish or interrupt the current turn before archiving threads.");
});
@ -418,7 +428,7 @@ describe("slash commands", () => {
await executeSlashCommand("archive", "Draft", ctx);
expect(ctx.archiveThread).not.toHaveBeenCalled();
expect(ctx.threadActions.archiveThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Multiple matching threads: Draft, Draft notes");
});
@ -429,7 +439,7 @@ describe("slash commands", () => {
await executeSlashCommand("rename", "thread-beta New Beta Name", ctx);
expect(ctx.renameThread).toHaveBeenCalledWith("thread-beta", "New Beta Name");
expect(ctx.threadActions.renameThread).toHaveBeenCalledWith("thread-beta", "New Beta Name");
});
it("trims /rename names before saving", async () => {
@ -439,7 +449,7 @@ describe("slash commands", () => {
await executeSlashCommand("rename", "thread-beta New Beta Name ", ctx);
expect(ctx.renameThread).toHaveBeenCalledWith("thread-beta", "New Beta Name");
expect(ctx.threadActions.renameThread).toHaveBeenCalledWith("thread-beta", "New Beta Name");
});
it("rejects /rename without a thread and name", async () => {
@ -447,7 +457,7 @@ describe("slash commands", () => {
await executeSlashCommand("rename", "thread-1", ctx);
expect(ctx.renameThread).not.toHaveBeenCalled();
expect(ctx.threadActions.renameThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/rename requires a thread and a name. Usage: /rename <thread> <name>");
});
@ -456,7 +466,7 @@ describe("slash commands", () => {
await executeSlashCommand("rename", "thread-1 ", ctx);
expect(ctx.renameThread).not.toHaveBeenCalled();
expect(ctx.threadActions.renameThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/rename requires a thread and a name. Usage: /rename <thread> <name>");
});
@ -467,7 +477,7 @@ describe("slash commands", () => {
await executeSlashCommand("rename", "Draft New name", ctx);
expect(ctx.renameThread).not.toHaveBeenCalled();
expect(ctx.threadActions.renameThread).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Multiple matching threads: Draft, Draft notes");
});
@ -601,22 +611,21 @@ describe("slash commands", () => {
await executeSlashCommand("reasoning", "extreme", ctx);
expect(ctx.requestReasoningEffort).not.toHaveBeenCalled();
expect(ctx.resetReasoningEffortToConfig).not.toHaveBeenCalled();
expect(ctx.runtimeSettings.requestReasoningEffort).not.toHaveBeenCalled();
expect(ctx.runtimeSettings.resetReasoningEffortToConfig).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Unsupported reasoning level: extreme. Usage: /reasoning [level|default]");
});
it("does not announce model or effort changes when applying them fails", async () => {
const ctx = context({
requestModel: vi.fn().mockResolvedValue(false),
requestReasoningEffort: vi.fn().mockResolvedValue(false),
});
const ctx = context();
ctx.runtimeSettings.requestModel = vi.fn().mockResolvedValue(false);
ctx.runtimeSettings.requestReasoningEffort = vi.fn().mockResolvedValue(false);
await executeSlashCommand("model", "gpt-5.5", ctx);
await executeSlashCommand("reasoning", "high", ctx);
expect(ctx.requestModel).toHaveBeenCalledWith("gpt-5.5");
expect(ctx.requestReasoningEffort).toHaveBeenCalledWith("high");
expect(ctx.runtimeSettings.requestModel).toHaveBeenCalledWith("gpt-5.5");
expect(ctx.runtimeSettings.requestReasoningEffort).toHaveBeenCalledWith("high");
expect(ctx.addSystemMessage).not.toHaveBeenCalled();
});
@ -626,10 +635,10 @@ describe("slash commands", () => {
await executeSlashCommand("model", "default", ctx);
await executeSlashCommand("reasoning", "default", ctx);
expect(ctx.resetModelToConfig).toHaveBeenCalledOnce();
expect(ctx.resetReasoningEffortToConfig).toHaveBeenCalledOnce();
expect(ctx.requestModel).not.toHaveBeenCalled();
expect(ctx.requestReasoningEffort).not.toHaveBeenCalled();
expect(ctx.runtimeSettings.resetModelToConfig).toHaveBeenCalledOnce();
expect(ctx.runtimeSettings.resetReasoningEffortToConfig).toHaveBeenCalledOnce();
expect(ctx.runtimeSettings.requestModel).not.toHaveBeenCalled();
expect(ctx.runtimeSettings.requestReasoningEffort).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Model reset to default for subsequent turns.");
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Reasoning effort reset to default for subsequent turns.");
});
@ -642,10 +651,10 @@ describe("slash commands", () => {
await executeSlashCommand("reasoning", alias, ctx);
}
expect(ctx.resetModelToConfig).toHaveBeenCalledTimes(3);
expect(ctx.resetReasoningEffortToConfig).toHaveBeenCalledTimes(3);
expect(ctx.requestModel).not.toHaveBeenCalled();
expect(ctx.requestReasoningEffort).not.toHaveBeenCalled();
expect(ctx.runtimeSettings.resetModelToConfig).toHaveBeenCalledTimes(3);
expect(ctx.runtimeSettings.resetReasoningEffortToConfig).toHaveBeenCalledTimes(3);
expect(ctx.runtimeSettings.requestModel).not.toHaveBeenCalled();
expect(ctx.runtimeSettings.requestReasoningEffort).not.toHaveBeenCalled();
});
it("shows model and reasoning status for empty runtime commands", async () => {
@ -659,8 +668,8 @@ describe("slash commands", () => {
expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("Model settings", [{ auditFacts: [{ key: "model", value: "gpt-5.5" }] }]);
expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("Reasoning effort", [{ auditFacts: [{ key: "effort", value: "high" }] }]);
expect(ctx.requestModel).not.toHaveBeenCalled();
expect(ctx.requestReasoningEffort).not.toHaveBeenCalled();
expect(ctx.runtimeSettings.requestModel).not.toHaveBeenCalled();
expect(ctx.runtimeSettings.requestReasoningEffort).not.toHaveBeenCalled();
});
it("preserves supported reasoning effort casing", async () => {
@ -670,7 +679,7 @@ describe("slash commands", () => {
await executeSlashCommand("reasoning", "CaseSensitive", ctx);
expect(ctx.requestReasoningEffort).toHaveBeenCalledWith("CaseSensitive");
expect(ctx.runtimeSettings.requestReasoningEffort).toHaveBeenCalledWith("CaseSensitive");
expect(ctx.addSystemMessage).toHaveBeenCalledWith("Reasoning effort set to CaseSensitive for subsequent turns.");
});
@ -697,7 +706,7 @@ describe("slash commands", () => {
await executeSlashCommand("fast", "now", ctx);
expect(ctx.toggleFastMode).not.toHaveBeenCalled();
expect(ctx.runtimeSettings.toggleFastMode).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/fast does not take arguments. Usage: /fast");
});

View file

@ -38,23 +38,29 @@ function createHost(overrides: SlashCommandHostOverrides = {}) {
startNewThread: vi.fn().mockResolvedValue(undefined),
startThreadForGoal: vi.fn().mockResolvedValue("thread-new"),
resumeThread: vi.fn().mockResolvedValue(undefined),
forkThread: vi.fn().mockResolvedValue(undefined),
rollbackThread: vi.fn().mockResolvedValue(undefined),
compactThread,
archiveThread: vi.fn().mockResolvedValue(undefined),
renameThread: vi.fn().mockResolvedValue(undefined),
threadActions: {
forkThread: vi.fn().mockResolvedValue(undefined),
rollbackThread: vi.fn().mockResolvedValue(undefined),
compactThread,
archiveThread: vi.fn().mockResolvedValue(undefined),
renameThread: vi.fn().mockResolvedValue(true),
},
reconnect: vi.fn().mockResolvedValue(undefined),
toggleFastMode: vi.fn(),
toggleCollaborationMode: vi.fn(),
toggleAutoReview: vi.fn(),
requestModel: vi.fn(),
resetModelToConfig: vi.fn(),
requestReasoningEffort: vi.fn(),
resetReasoningEffortToConfig: vi.fn(),
activeGoal: vi.fn(() => stateStore.getState().activeThread.goal),
setGoalObjective: vi.fn().mockResolvedValue(true),
setGoalStatus: vi.fn().mockResolvedValue(true),
clearGoal: vi.fn().mockResolvedValue(true),
runtimeSettings: {
toggleFastMode: vi.fn(),
toggleCollaborationMode: vi.fn(),
toggleAutoReview: vi.fn(),
requestModel: vi.fn(),
resetModelToConfig: vi.fn(),
requestReasoningEffort: vi.fn(),
resetReasoningEffortToConfig: vi.fn(),
},
goals: {
activeGoal: vi.fn(() => stateStore.getState().activeThread.goal),
setObjective: vi.fn().mockResolvedValue(true),
setStatus: vi.fn().mockResolvedValue(true),
clear: vi.fn().mockResolvedValue(true),
},
addSystemMessage: vi.fn(),
addStructuredSystemMessage: vi.fn(),
setStatus: vi.fn(),
@ -126,7 +132,7 @@ describe("executeSlashCommandWithState", () => {
await executeSlashCommandWithState(host, "goal", "set Ship this");
expect(host.startThreadForGoal).toHaveBeenCalledWith("Ship this");
expect(host.setGoalObjective).toHaveBeenCalledWith("thread-new", "Ship this", null);
expect(host.goals.setObjective).toHaveBeenCalledWith("thread-new", "Ship this", null);
});
it("runs reconnect even when there is no current app-server client", async () => {

View file

@ -3,9 +3,8 @@ import { describe, expect, it, vi } from "vitest";
import { createChatStateStore } from "../../../../src/features/chat/application/state/store";
import { ThreadRenameEditorController } from "../../../../src/features/chat/application/threads/rename-editor-controller";
import type { AppServerClient } from "../../../../src/app-server/connection/client";
import type { Thread } from "../../../../src/domain/threads/model";
import { normalizeExplicitThreadName, type Thread } from "../../../../src/domain/threads/model";
import type { TurnItem, TurnRecord } from "../../../../src/app-server/protocol/turn";
import { threadRenameFromValue } from "../../../../src/app-server/services/thread-rename";
import { deferred } from "../../../support/async";
describe("ThreadRenameEditorController", () => {
@ -163,16 +162,14 @@ function controllerFixture(
addSystemMessage: overrides.addSystemMessage ?? vi.fn(),
operations: {
renameThread: async (threadId: string, value: string) => {
const rename = threadRenameFromValue(value);
if (!rename) return false;
await currentClient().setThreadName(threadId, rename.name);
const name = normalizeExplicitThreadName(value);
if (!name) return false;
await currentClient().setThreadName(threadId, name);
stateStore.dispatch({
type: "thread-list/applied",
threads: stateStore
.getState()
.threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name: rename.name } : thread)),
threads: stateStore.getState().threadList.listedThreads.map((thread) => (thread.id === threadId ? { ...thread, name } : thread)),
});
notifyThreadRenamed(threadId, rename.name);
notifyThreadRenamed(threadId, name);
return true;
},
},

View file

@ -4,7 +4,7 @@ import type { AppServerClient } from "../../../../src/app-server/connection/clie
import type { ThreadRecord } from "../../../../src/app-server/protocol/thread";
import { archiveThreadOnAppServer } from "../../../../src/app-server/services/thread-archive";
import type { ArchiveExportAdapter } from "../../../../src/app-server/services/thread-archive-markdown";
import { threadRenameFromValue } from "../../../../src/app-server/services/thread-rename";
import { normalizeExplicitThreadName } from "../../../../src/domain/threads/model";
import { createChatStateStore } from "../../../../src/features/chat/application/state/store";
import {
createThreadManagementActions,
@ -451,11 +451,11 @@ function hostMock({
return result;
}),
renameThread: vi.fn(async (threadId: string, value: string) => {
const rename = threadRenameFromValue(value);
if (!rename) return false;
const name = normalizeExplicitThreadName(value);
if (!name) return false;
await ensureConnected();
await client.setThreadName(threadId, rename.name);
notifyThreadRenamed(threadId, rename.name);
await client.setThreadName(threadId, name);
notifyThreadRenamed(threadId, name);
return true;
}),
},

View file

@ -505,28 +505,6 @@ describe("CodexPanelPlugin boot restored panel loading", () => {
expect(threadCatalog(plugin).activeThreadsSnapshot()).toEqual([thread("cached")]);
});
it("refreshes shared thread lists from a connected chat panel", async () => {
const { CodexChatView } = await import("../src/features/chat/host/view");
const disconnectedLeaf = leaf();
disconnectedLeaf.view = chatView(CodexChatView, disconnectedLeaf);
const disconnectedView = disconnectedLeaf.view as CodexChatView;
vi.spyOn(disconnectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "disconnected", connected: false }));
const disconnectedRefresh = vi.spyOn(disconnectedView.surface, "refreshSharedThreadList").mockResolvedValue(undefined);
const connectedLeaf = leaf();
connectedLeaf.view = chatView(CodexChatView, connectedLeaf);
const connectedView = connectedLeaf.view as CodexChatView;
vi.spyOn(connectedView.surface, "openPanelSnapshot").mockReturnValue(panelSnapshot({ viewId: "connected", connected: true }));
const connectedRefresh = vi.spyOn(connectedView.surface, "refreshSharedThreadList").mockResolvedValue(undefined);
const plugin = await pluginWithLeaves([disconnectedLeaf, connectedLeaf]);
plugin.runtime.settingTabHost().threadCatalog.invalidateThreadsFromOpenSurface();
expect(disconnectedRefresh).not.toHaveBeenCalled();
expect(connectedRefresh).toHaveBeenCalledOnce();
});
it("refreshes shared thread lists from a remaining connected panel after the archived panel is detached", async () => {
const { CodexChatView } = await import("../src/features/chat/host/view");
const sourceLeaf = leaf();

View file

@ -6,7 +6,7 @@ import type { CatalogHookMetadata, CatalogModel } from "../../src/app-server/pro
import type { ThreadRecord } from "../../src/app-server/protocol/thread";
import type { ModelMetadata, ReasoningEffort } from "../../src/domain/catalog/metadata";
import { modelMetadataFromCatalogModels } from "../../src/app-server/protocol/catalog";
import { SettingsDynamicDataController } from "../../src/settings/dynamic-data-controller";
import { SettingsDynamicDataController, type SettingsDynamicDataSnapshot } from "../../src/settings/dynamic-data-controller";
import { CodexPanelSettingTab } from "../../src/settings/tab";
import type { CodexPanelSettingTabHost } from "../../src/settings/tab";
import type { Thread } from "../../src/domain/threads/model";
@ -357,9 +357,49 @@ describe("settings tab", () => {
expect(controller.snapshot().hooks.map((item) => item.currentHash)).toEqual(["newhash"]);
});
it("keeps full refresh models and archived threads current when hook operations overlap", async () => {
const models = deferred<readonly ModelMetadata[]>();
const fullRefreshClient = settingsClient({
hooks: [hook({ key: "hook-full", command: "full refresh hook", currentHash: "fullhash" })],
threads: [appServerThread({ id: "thread-full", preview: "Full archived" })],
});
const trustClient = {
trustHook: vi.fn().mockResolvedValue({}),
};
const hookReloadClient = settingsClient({
hooks: [hook({ key: "hook-new", command: "new hook", currentHash: "newhash" })],
});
withShortLivedAppServerClientMock
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(fullRefreshClient),
)
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(trustClient),
)
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(hookReloadClient),
);
const refreshModels = vi.fn().mockReturnValue(models.promise);
const controller = new SettingsDynamicDataController(settingsTabHost({ refreshModels }), { display: vi.fn(), notify: vi.fn() });
const fullRefresh = controller.refreshSettingsData();
await flushPromises();
await controller.trustHook(hook({ key: "hook-initial", command: "initial hook", currentHash: "initialhash" }));
models.resolve(modelMetadataFromCatalogModels([model("gpt-refreshed")]));
await fullRefresh;
const snapshot = controller.snapshot();
expect(snapshot.models.map((item) => item.model)).toEqual(["gpt-refreshed"]);
expect(snapshot.modelsLifecycle.kind).toBe("loaded");
expect(snapshot.archivedThreads.map((thread) => thread.preview)).toEqual(["Full archived"]);
expect(snapshot.archivedThreadsLifecycle.kind).toBe("loaded");
expect(snapshot.hooks.map((item) => item.currentHash)).toEqual(["newhash"]);
});
it("ignores stale archived restore results after a newer dynamic operation completes", async () => {
const staleRestore = deferred<{ thread: ThreadRecord }>();
const invalidateThreadsFromOpenSurface = vi.fn();
const refreshActiveThreads = vi.fn().mockResolvedValue([]);
const initialClient = settingsClient({
threads: [appServerThread({ id: "thread-old", preview: "Old archived" })],
});
@ -379,7 +419,7 @@ describe("settings tab", () => {
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(newerClient),
);
const controller = new SettingsDynamicDataController(settingsTabHost({ invalidateThreadsFromOpenSurface }), {
const controller = new SettingsDynamicDataController(settingsTabHost({ refreshActiveThreads }), {
display: vi.fn(),
notify: vi.fn(),
});
@ -394,10 +434,77 @@ describe("settings tab", () => {
staleRestore.resolve({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) });
await restore;
expect(invalidateThreadsFromOpenSurface).not.toHaveBeenCalled();
expect(refreshActiveThreads).not.toHaveBeenCalled();
expect(controller.snapshot().archivedThreads.map((thread) => thread.preview)).toEqual(["New archived"]);
});
it("keeps restored archived threads removed when active thread refresh fails", async () => {
const notify = vi.fn();
const refreshActiveThreads = vi.fn().mockRejectedValue(new Error("offline"));
const initialClient = settingsClient({
threads: [appServerThread({ id: "thread-old", preview: "Old archived" })],
});
const restoreClient = {
unarchiveThread: vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }),
};
withShortLivedAppServerClientMock
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(initialClient),
)
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(restoreClient),
);
const controller = new SettingsDynamicDataController(settingsTabHost({ refreshActiveThreads }), { display: vi.fn(), notify });
await controller.refreshSettingsData();
await controller.restoreArchivedThread("thread-old");
const snapshot = controller.snapshot();
expect(snapshot.archivedThreads).toEqual([]);
expect(snapshot.archivedThreadsLifecycle.kind).toBe("loaded");
expect(refreshActiveThreads).toHaveBeenCalledOnce();
expect(notify).toHaveBeenCalledWith("Could not refresh active Codex threads.");
expect(notify).not.toHaveBeenCalledWith("Could not restore archived Codex thread.");
});
it("displays restored archived thread state before active thread refresh completes", async () => {
const activeRefresh = deferred<readonly Thread[]>();
const snapshots: SettingsDynamicDataSnapshot[] = [];
const initialClient = settingsClient({
threads: [appServerThread({ id: "thread-old", preview: "Old archived" })],
});
const restoreClient = {
unarchiveThread: vi.fn().mockResolvedValue({ thread: appServerThread({ id: "thread-old", preview: "Restored old" }) }),
};
withShortLivedAppServerClientMock
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(initialClient),
)
.mockImplementationOnce((_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) =>
operation(restoreClient),
);
const controllerRef: { current: SettingsDynamicDataController | null } = { current: null };
const controller = new SettingsDynamicDataController(settingsTabHost({ refreshActiveThreads: () => activeRefresh.promise }), {
display: () => {
const snapshot = controllerRef.current?.snapshot();
if (snapshot) snapshots.push(snapshot);
},
notify: vi.fn(),
});
controllerRef.current = controller;
await controller.refreshSettingsData();
snapshots.length = 0;
const restore = controller.restoreArchivedThread("thread-old");
await flushPromises();
expect(snapshots.at(-1)?.archivedThreads).toEqual([]);
expect(snapshots.at(-1)?.archivedThreadsLifecycle.kind).toBe("loaded");
activeRefresh.resolve([]);
await restore;
});
it("uses cached models initially and publishes refreshed models", async () => {
const fetchModels = vi.fn().mockResolvedValue(modelMetadataFromCatalogModels([model("gpt-5.5")]));
const client = settingsClient({ models: [model("gpt-5.5")] });
@ -572,14 +679,13 @@ describe("settings tab", () => {
});
it("permanently deletes an archived thread from the confirmed settings row", async () => {
const invalidateThreadsFromOpenSurface = vi.fn();
const client = settingsClient({
threads: [appServerThread({ id: "thread-archived", preview: "Archived thread" })],
});
withShortLivedAppServerClientMock.mockImplementation(
(_codexPath: string, _cwd: string, operation: (client: unknown) => Promise<unknown>) => operation(client),
);
const tab = newSettingsTab({ invalidateThreadsFromOpenSurface });
const tab = newSettingsTab();
tab.display();
await flushPromises();
@ -590,7 +696,6 @@ describe("settings tab", () => {
await flushPromises();
expect(client.deleteThread).toHaveBeenCalledWith("thread-archived");
expect(invalidateThreadsFromOpenSurface).toHaveBeenCalledOnce();
expect(tab.containerEl.textContent).toContain("No archived threads.");
expect(tab.containerEl.querySelectorAll(".codex-panel-settings__archived-list .setting-item")).toHaveLength(0);
});
@ -709,7 +814,7 @@ function newSettingsTab(
observeModels?: CodexPanelSettingTabHost["appServerData"]["observeModelsResult"];
notifyContextChanged?: () => void;
refreshOpenViews?: () => void;
invalidateThreadsFromOpenSurface?: () => void;
refreshActiveThreads?: () => Promise<readonly Thread[]>;
settings?: Partial<{
threadNamingModel: string | null;
threadNamingEffort: string | null;
@ -731,7 +836,7 @@ function settingsTabHost(
observeModels?: CodexPanelSettingTabHost["appServerData"]["observeModelsResult"];
notifyContextChanged?: () => void;
refreshOpenViews?: () => void;
invalidateThreadsFromOpenSurface?: () => void;
refreshActiveThreads?: () => Promise<readonly Thread[]>;
settings?: Partial<{
threadNamingModel: string | null;
threadNamingEffort: string | null;
@ -766,7 +871,7 @@ function settingsTabHost(
notifyContextChanged: options.notifyContextChanged ?? vi.fn(),
},
threadCatalog: {
invalidateThreadsFromOpenSurface: options.invalidateThreadsFromOpenSurface ?? vi.fn(),
refreshActiveThreads: options.refreshActiveThreads ?? vi.fn().mockResolvedValue([]),
},
};
}

View file

@ -7,7 +7,6 @@ import { createSharedThreadCatalog } from "../../src/workspace/shared-thread-cat
interface MockSurfaceActions {
refreshOpenViews: Mock<() => void>;
invalidateThreadsFromOpenSurface: Mock<() => void>;
applyThreadArchived: Mock<(threadId: string, options?: { closeOpenPanels?: boolean }) => void>;
applyThreadRenamed: Mock<(threadId: string, name: string | null) => void>;
refreshThreadsViewLiveState: Mock<() => void>;
@ -107,7 +106,6 @@ function cacheWithThreads(
function surfaceActions(): MockSurfaceActions {
return {
refreshOpenViews: vi.fn(),
invalidateThreadsFromOpenSurface: vi.fn(),
applyThreadArchived: vi.fn(),
applyThreadRenamed: vi.fn(),
refreshThreadsViewLiveState: vi.fn(),