murashit_codex-panel/src/panel/controller.ts
2026-05-16 23:53:45 +09:00

414 lines
18 KiB
TypeScript

import { approvalResponse, toPendingApproval, type ApprovalAction, type PendingApproval } from "../approvals/model";
import { createAutoReviewResultItem, createReviewResultItem } from "../display/model";
import {
appendAssistantDelta,
appendItemOutput,
appendItemText,
appendPlanDelta,
appendToolOutput,
completeReasoningItems,
upsertDisplayItem,
} from "../display/stream-updates";
import {
createSystemItem,
displayItemFromThreadItem,
displayItemsFromTurns,
normalizeFileChanges,
planProgressDisplayItem,
shouldSuppressLifecycleItem,
shouldSuppressThreadItem,
} from "../display/model";
import type { DisplayItem, DisplayKind, MessageDisplayItem } from "../display/types";
import type { RequestId } from "../generated/app-server/RequestId";
import type { ServerNotification } from "../generated/app-server/ServerNotification";
import type { ServerRequest } from "../generated/app-server/ServerRequest";
import type { ThreadItem } from "../generated/app-server/v2/ThreadItem";
import type { Turn } from "../generated/app-server/v2/Turn";
import { clearActiveThreadState, type PanelState } from "./state";
import { toPendingUserInput, userInputResponse, type PendingUserInput } from "../user-input/model";
import { jsonPreview } from "../utils";
import { classifyAppServerLog } from "./app-server-logs";
import { hookRunDisplayItem } from "./hook-display";
import { clearUserInputDrafts, createApprovalResultItem, createUserInputResultItem } from "./request-state";
export interface PanelControllerActions {
refreshThreads: () => void;
refreshSkills: (forceReload?: boolean) => void;
maybeNameThread: (threadId: string, turn: Turn) => void;
respondToServerRequest: (requestId: RequestId, result: unknown) => boolean;
rejectServerRequest: (requestId: RequestId, code: number, message: string) => boolean;
}
export class PanelController {
constructor(
private readonly state: PanelState,
private readonly actions: PanelControllerActions,
) {}
handleNotification(notification: ServerNotification): void {
if (!this.isInActiveScope(notification)) return;
const { method, params } = notification;
if (method === "turn/started") {
this.state.activeThreadId = params.threadId;
this.state.activeTurnId = params.turn.id ?? this.state.activeTurnId;
this.state.busy = true;
this.state.status = "Turn running...";
} else if (method === "item/agentMessage/delta") {
this.state.displayItems = completeReasoningItems(this.state.displayItems, params.turnId);
this.state.displayItems = appendAssistantDelta(this.state.displayItems, params.itemId, params.turnId, params.delta ?? "");
} else if (method === "item/plan/delta") {
this.state.displayItems = appendPlanDelta(this.state.displayItems, params.itemId, params.turnId, params.delta ?? "");
} else if (method === "turn/plan/updated") {
this.state.displayItems = upsertDisplayItem(
this.state.displayItems,
planProgressDisplayItem(params.turnId, params.explanation ?? null, params.plan ?? []),
);
} else if (method === "item/reasoning/summaryTextDelta") {
this.appendToolText(params.itemId, params.turnId, "reasoning", params.delta ?? "", "reasoning");
} else if (method === "item/reasoning/textDelta") {
this.appendToolText(params.itemId, params.turnId, "reasoning", params.delta ?? "", "reasoning");
} else if (method === "item/reasoning/summaryPartAdded") {
this.appendToolText(params.itemId, params.turnId, "reasoning", "", "reasoning");
} else if (method === "item/started") {
this.handleStartedItem(params.item, params.turnId);
} else if (method === "item/completed") {
this.handleCompletedItem(params.item, params.turnId);
} else if (method === "item/commandExecution/outputDelta") {
this.state.displayItems = appendItemOutput(
this.state.displayItems,
params.itemId,
params.turnId,
params.delta ?? "",
"command",
"Command running",
);
} else if (method === "item/fileChange/patchUpdated") {
this.upsertFileChange(params.itemId, params.turnId, params.changes ?? [], "inProgress");
} else if (method === "item/fileChange/outputDelta") {
this.state.displayItems = appendItemOutput(
this.state.displayItems,
params.itemId,
params.turnId,
params.delta ?? "",
"fileChange",
"File change inProgress",
);
} else if (method === "turn/diff/updated") {
if (params.diff.trim().length > 0) {
this.state.turnDiffs.set(params.turnId, params.diff);
} else {
this.state.turnDiffs.delete(params.turnId);
}
} else if (method === "turn/completed") {
this.reconcileCompletedTurn(params.turn);
this.state.displayItems = completeReasoningItems(this.state.displayItems, params.turn.id);
this.state.busy = false;
this.state.activeTurnId = null;
this.state.status = `Turn ${params.turn.status ?? "completed"}.`;
this.actions.maybeNameThread(params.threadId, params.turn);
this.actions.refreshThreads();
} else if (method === "thread/tokenUsage/updated") {
this.state.tokenUsage = params.tokenUsage ?? null;
} else if (method === "account/rateLimits/updated") {
this.state.rateLimit = params.rateLimits ?? null;
} else if (method === "skills/changed") {
this.actions.refreshSkills(true);
} else if (method === "hook/started") {
this.upsertHookRun(params.run, params.turnId, "running");
} else if (method === "hook/completed") {
this.upsertHookRun(params.run, params.turnId, params.run?.status ?? "completed");
} else if (method === "item/mcpToolCall/progress") {
this.state.displayItems = appendToolOutput(
this.state.displayItems,
params.itemId,
params.turnId,
params.message ?? "",
"mcp progress",
);
} else if (method === "item/autoApprovalReview/started" || method === "item/autoApprovalReview/completed") {
const reviewItem = createAutoReviewResultItem(params);
this.state.displayItems = upsertDisplayItem(removeUnstructuredAutoReviewWarnings(this.state.displayItems), reviewItem);
} else if (method === "thread/started") {
if (params.thread) {
if (!this.state.activeThreadId || this.state.activeThreadId === params.thread.id) {
this.state.activeThreadCwd = params.thread.cwd ?? this.state.activeThreadCwd;
}
}
} else if (method === "thread/archived") {
this.state.listedThreads = this.state.listedThreads.filter((thread) => thread.id !== params.threadId);
if (this.state.activeThreadId === params.threadId) {
clearActiveThreadState(this.state);
}
} else if (method === "thread/unarchived") {
this.actions.refreshThreads();
} else if (method === "thread/name/updated") {
const name = typeof params.threadName === "string" && params.threadName.trim() ? params.threadName.trim() : null;
this.state.listedThreads = this.state.listedThreads.map((thread) => (thread.id === params.threadId ? { ...thread, name } : thread));
this.actions.refreshThreads();
} else if (method === "serverRequest/resolved") {
this.state.approvals = this.state.approvals.filter((approval) => approval.requestId !== params.requestId);
const resolvedInputs = this.state.pendingUserInputs.filter((input) => input.requestId === params.requestId);
this.state.pendingUserInputs = this.state.pendingUserInputs.filter((input) => input.requestId !== params.requestId);
for (const input of resolvedInputs) clearUserInputDrafts(this.state.userInputDrafts, input);
} else if (method === "thread/compacted") {
this.addSystemMessage("Context compacted.");
} else if (method === "guardianWarning") {
const item = createReviewResultItem(params.message);
if (!isUnstructuredAutoReviewWarning(item) || !hasStructuredAutoReviewResult(this.state.displayItems, this.state.activeTurnId)) {
this.state.displayItems = upsertDisplayItem(this.state.displayItems, item);
}
} else if (method === "model/rerouted" || method === "deprecationNotice") {
this.addSystemMessage(`${method}: ${jsonPreview(params)}`);
} else if (method === "mcpServer/startupStatus/updated") {
this.handleMcpStartupStatus(params);
} else if (method === "error" || method === "warning" || method === "configWarning") {
this.addSystemMessage(`${method}: ${jsonPreview(params)}`);
}
}
handleServerRequest(request: ServerRequest): void {
if (!this.isRequestInActiveScope(request)) {
this.rejectServerRequest(request, `Rejected inactive app-server request: ${request.method}`);
return;
}
const approval = toPendingApproval(request);
if (approval) {
this.queueApprovalRequest(approval);
return;
}
const userInput = toPendingUserInput(request);
if (userInput) {
this.queueUserInputRequest(userInput);
return;
}
this.rejectUnsupportedServerRequest(request);
}
handleAppServerLog(message: string): void {
const classified = classifyAppServerLog(message);
if (classified === null) return;
if (classified.kind === "plain") {
this.addDedupedSystemMessage(classified.text);
} else if (classified.kind === "error") {
this.addDedupedSystemMessage(`app-server error: ${classified.text}`);
}
}
resolveApproval(approval: PendingApproval, action: ApprovalAction): void {
if (!this.state.approvals.some((item) => item.requestId === approval.requestId)) return;
if (!this.actions.respondToServerRequest(approval.requestId, approvalResponse(approval, action))) {
this.addSystemMessage("Could not send approval response because Codex app-server is not connected.");
return;
}
this.state.approvals = this.state.approvals.filter((item) => item.requestId !== approval.requestId);
this.state.displayItems.push(createApprovalResultItem(approval, action));
}
resolveUserInput(input: PendingUserInput, answers: Record<string, string>): void {
if (!this.state.pendingUserInputs.some((item) => item.requestId === input.requestId)) return;
if (!this.actions.respondToServerRequest(input.requestId, userInputResponse(input, answers))) {
this.addSystemMessage("Could not send user input because Codex app-server is not connected.");
return;
}
this.state.pendingUserInputs = this.state.pendingUserInputs.filter((item) => item.requestId !== input.requestId);
clearUserInputDrafts(this.state.userInputDrafts, input);
this.state.displayItems.push(createUserInputResultItem(input, answers, "submitted"));
}
cancelUserInput(input: PendingUserInput): void {
if (!this.state.pendingUserInputs.some((item) => item.requestId === input.requestId)) return;
if (!this.actions.rejectServerRequest(input.requestId, -32000, "User cancelled input request.")) {
this.addSystemMessage("Could not cancel user input because Codex app-server is not connected.");
return;
}
this.state.pendingUserInputs = this.state.pendingUserInputs.filter((item) => item.requestId !== input.requestId);
clearUserInputDrafts(this.state.userInputDrafts, input);
this.state.displayItems.push(createUserInputResultItem(input, {}, "cancelled"));
}
addSystemMessage(text: string): void {
this.state.displayItems.push(createSystemItem(text));
}
addDedupedSystemMessage(text: string): void {
if (this.state.reportedLogs.has(text)) return;
this.state.reportedLogs.add(text);
this.addSystemMessage(text);
}
private queueApprovalRequest(approval: PendingApproval): void {
if (!this.state.approvals.some((existing) => existing.requestId === approval.requestId)) {
this.state.approvals.push(approval);
}
}
private queueUserInputRequest(userInput: PendingUserInput): void {
if (!this.state.pendingUserInputs.some((existing) => existing.requestId === userInput.requestId)) {
this.state.pendingUserInputs.push(userInput);
}
}
private rejectUnsupportedServerRequest(request: ServerRequest): void {
const message = `Rejected unsupported app-server request: ${request.method}`;
this.rejectServerRequest(request, message);
}
private rejectServerRequest(request: ServerRequest, message: string): void {
this.addSystemMessage(message);
if (!this.actions.rejectServerRequest(request.id, -32601, message)) {
this.addSystemMessage("Could not reject app-server request because Codex app-server is not connected.");
}
}
private isInActiveScope(notification: ServerNotification): boolean {
const threadId = notificationThreadId(notification);
if (threadId && this.state.activeThreadId && threadId !== this.state.activeThreadId) {
if (notification.method === "thread/started") return true;
return false;
}
const turnId = notificationTurnId(notification);
if (turnId && this.state.activeTurnId && turnId !== this.state.activeTurnId) return false;
return true;
}
private isRequestInActiveScope(request: ServerRequest): boolean {
const threadId = messageThreadId(request);
if (threadId && this.state.activeThreadId && threadId !== this.state.activeThreadId) return false;
const turnId = messageTurnId(request);
if (turnId && this.state.activeTurnId && turnId !== this.state.activeTurnId) return false;
return true;
}
private handleStartedItem(item: ThreadItem, turnId: string): void {
if (!item || shouldSuppressThreadItem(item) || shouldSuppressLifecycleItem(item)) return;
const displayItem = displayItemFromThreadItem(item, turnId);
if (displayItem) this.state.displayItems = upsertDisplayItem(this.state.displayItems, displayItem);
}
private handleCompletedItem(item: ThreadItem, turnId: string): void {
if (!item || shouldSuppressThreadItem(item) || item.type === "userMessage") return;
const displayItem = displayItemFromThreadItem(item, turnId);
if (displayItem) {
this.state.displayItems = upsertDisplayItem(this.state.displayItems, displayItem);
if (displayItem.kind === "reasoning") {
this.state.displayItems = completeReasoningItems(this.state.displayItems, turnId);
}
}
}
private reconcileCompletedTurn(turn: Turn): void {
const turnItems = displayItemsFromTurns([turn]);
if (turnItems.length === 0) return;
const serverUserTexts = new Set(turnItems.filter(isUserMessage).map((item) => item.text));
let mergedTurnItems = this.state.displayItems
.filter((item) => item.turnId === turn.id)
.filter((item) => !isOptimisticUserMessage(item, serverUserTexts));
for (const item of turnItems) {
mergedTurnItems = upsertDisplayItem(mergedTurnItems, item);
}
const retainedItems = this.state.displayItems
.filter((item) => item.turnId !== turn.id)
.filter((item) => !isOptimisticUserMessage(item, serverUserTexts));
this.state.displayItems = [...retainedItems, ...mergedTurnItems];
}
private upsertFileChange(itemId: string, turnId: string, changes: unknown[], status: string): void {
this.state.displayItems = upsertDisplayItem(this.state.displayItems, {
id: itemId,
kind: "fileChange",
role: "tool",
text: `File change ${status}`,
turnId,
itemId,
status,
changes: normalizeFileChanges(changes),
});
}
private appendToolText(
itemId: string,
turnId: string,
label: string,
delta: string,
kind: Extract<DisplayKind, "tool" | "hook" | "reasoning"> = "tool",
): void {
this.state.displayItems = appendItemText(this.state.displayItems, itemId, turnId, label, delta, kind);
}
private upsertHookRun(
run: Extract<ServerNotification, { method: "hook/started" }>["params"]["run"],
turnId: string | null,
status: string,
): void {
const item = hookRunDisplayItem(run, turnId, status);
if (item) this.state.displayItems = upsertDisplayItem(this.state.displayItems, item);
}
private handleMcpStartupStatus(params: Extract<ServerNotification, { method: "mcpServer/startupStatus/updated" }>["params"]): void {
if (!params?.name) return;
if (params.status === "failed") {
const key = `${params.name}:${params.error ?? ""}`;
if (this.state.reportedMcpFailures.has(key)) return;
this.state.reportedMcpFailures.add(key);
this.addSystemMessage(
`MCP server failed to start: ${params.name}\n${params.error ?? ""}\n\nThis is non-fatal for the Codex thread unless that MCP server is needed.`,
);
}
}
}
function notificationThreadId(notification: ServerNotification): string | null {
return messageThreadId(notification);
}
function notificationTurnId(notification: ServerNotification): string | null {
return messageTurnId(notification);
}
function messageThreadId(message: ServerNotification | ServerRequest): string | null {
const params = message.params as { threadId?: unknown };
if (typeof params.threadId === "string") return params.threadId;
return null;
}
function messageTurnId(message: ServerNotification | ServerRequest): string | null {
const params = message.params as { turnId?: unknown; turn?: { id?: unknown } };
if (typeof params.turnId === "string") return params.turnId;
return typeof params.turn?.id === "string" ? params.turn.id : null;
}
function removeUnstructuredAutoReviewWarnings(items: DisplayItem[]): DisplayItem[] {
return items.filter((item) => !isUnstructuredAutoReviewWarning(item));
}
function hasStructuredAutoReviewResult(items: DisplayItem[], activeTurnId: string | null): boolean {
return items.some(
(item) =>
item.kind === "reviewResult" &&
Boolean(item.turnId) &&
(!activeTurnId || item.turnId === activeTurnId) &&
isAutoReviewText(item.text),
);
}
function isUnstructuredAutoReviewWarning(item: DisplayItem): boolean {
return item.kind === "reviewResult" && !item.turnId && isAutoReviewText(item.text);
}
function isAutoReviewText(text: string): boolean {
return /^Auto-review\b/i.test(text.trim());
}
function isUserMessage(item: DisplayItem): item is MessageDisplayItem & { role: "user" } {
return item.kind === "message" && item.role === "user";
}
function isOptimisticUserMessage(item: DisplayItem, serverUserTexts: Set<string>): boolean {
return isUserMessage(item) && (item.id.startsWith("local-user-") || item.id.startsWith("local-steer-")) && serverUserTexts.has(item.text);
}