mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Tighten app-server helper and notification boundaries
This commit is contained in:
parent
6a85d4f6c6
commit
697f7a59d2
23 changed files with 534 additions and 216 deletions
|
|
@ -379,8 +379,8 @@ export class AppServerClient {
|
|||
return this.request("thread/archive", { threadId });
|
||||
}
|
||||
|
||||
deleteThread(threadId: string): Promise<ThreadDeleteResponse> {
|
||||
return this.request("thread/delete", { threadId });
|
||||
deleteThread(threadId: string, options: { timeoutMs?: number } = {}): Promise<ThreadDeleteResponse> {
|
||||
return this.request("thread/delete", { threadId }, options);
|
||||
}
|
||||
|
||||
readThread(threadId: string, includeTurns = true): Promise<ThreadReadResponse> {
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@ type StructuredTurnRuntimeOverride = NonNullable<AppServerStartStructuredTurnOpt
|
|||
|
||||
type StructuredTurnProgressEvent = { type: "agent-message-delta"; delta: string } | { type: "reasoning-activity" };
|
||||
|
||||
const EPHEMERAL_THREAD_CLEANUP_TIMEOUT_MS = 5_000;
|
||||
|
||||
interface EphemeralStructuredTurnTimers {
|
||||
setTimeout(callback: () => void, delayMs: number): ReturnType<Window["setTimeout"]>;
|
||||
clearTimeout(timer: ReturnType<Window["setTimeout"]>): void;
|
||||
|
|
@ -33,6 +35,7 @@ export interface EphemeralStructuredTurnClient {
|
|||
disconnect(): void;
|
||||
startEphemeralThread(options: AppServerStartEphemeralThreadOptions): Promise<{ thread: { id: string } }>;
|
||||
startStructuredTurn(options: AppServerStartStructuredTurnOptions): Promise<{ turn: TurnRecord }>;
|
||||
deleteThread(threadId: string, options?: { timeoutMs?: number }): Promise<unknown>;
|
||||
}
|
||||
|
||||
type EphemeralStructuredTurnRuntimeCapableClient = EphemeralStructuredTurnClient & ModelMetadataClient;
|
||||
|
|
@ -98,6 +101,7 @@ export async function runEphemeralStructuredTurn(options: RunEphemeralStructured
|
|||
});
|
||||
|
||||
const clientFactory = options.clientFactory ?? ((codexPath, cwd, handlers) => new AppServerClient(codexPath, cwd, handlers));
|
||||
let threadId: string | null = null;
|
||||
const client = clientFactory(options.codexPath, options.cwd, {
|
||||
onNotification: (notification) => {
|
||||
handleNotification(notification);
|
||||
|
|
@ -124,16 +128,17 @@ export async function runEphemeralStructuredTurn(options: RunEphemeralStructured
|
|||
developerInstructions: options.developerInstructions,
|
||||
}),
|
||||
);
|
||||
threadId = threadResponse.thread.id;
|
||||
state = {
|
||||
...state,
|
||||
lifecycle: transitionEphemeralStructuredTurnLifecycle(state.lifecycle, {
|
||||
type: "thread-started",
|
||||
threadId: threadResponse.thread.id,
|
||||
threadId,
|
||||
}),
|
||||
};
|
||||
const turnResponse = await abortableOperation(
|
||||
client.startStructuredTurn({
|
||||
threadId: threadResponse.thread.id,
|
||||
threadId,
|
||||
cwd: options.cwd,
|
||||
text: options.prompt,
|
||||
outputSchema: options.outputSchema,
|
||||
|
|
@ -144,7 +149,7 @@ export async function runEphemeralStructuredTurn(options: RunEphemeralStructured
|
|||
...state,
|
||||
lifecycle: transitionEphemeralStructuredTurnLifecycle(state.lifecycle, {
|
||||
type: "turn-started",
|
||||
threadId: threadResponse.thread.id,
|
||||
threadId,
|
||||
turnId: turnResponse.turn.id,
|
||||
}),
|
||||
};
|
||||
|
|
@ -154,6 +159,7 @@ export async function runEphemeralStructuredTurn(options: RunEphemeralStructured
|
|||
} finally {
|
||||
state = completeEphemeralStructuredTurnState(state);
|
||||
timers.clearTimeout(timeout);
|
||||
await deleteEphemeralStructuredTurnThread(client, threadId);
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
|
|
@ -256,6 +262,15 @@ function turnWithCollectedItems(turn: TurnRecord, completedItems: readonly TurnI
|
|||
return { ...turn, items: [...completedItems], itemsView: "full" };
|
||||
}
|
||||
|
||||
async function deleteEphemeralStructuredTurnThread(client: EphemeralStructuredTurnClient, threadId: string | null): Promise<void> {
|
||||
if (!threadId) return;
|
||||
try {
|
||||
await client.deleteThread(threadId, { timeoutMs: EPHEMERAL_THREAD_CLEANUP_TIMEOUT_MS });
|
||||
} catch {
|
||||
// Ephemeral helpers must not fail visible workflows because cleanup raced app-server shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
function throwIfAborted(signal: AbortSignal | undefined, message: string | undefined): void {
|
||||
throwIfSignalAborted(signal, () => ephemeralStructuredTurnAbortError(message));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ function cannotRejectServerRequestMessage(): string {
|
|||
|
||||
export interface ChatInboundHandlerActions {
|
||||
refreshActiveThreads: () => void;
|
||||
refreshServerDiagnostics: (options?: { forceResourceProbes?: boolean }) => void;
|
||||
applyAppServerResourceEvent: (event: AppServerResourceEvent) => void;
|
||||
maybeNameThread: (threadId: string, turnId: string, completedSummary: ThreadConversationSummary | null) => void;
|
||||
applyThreadCatalogEvent: (event: ThreadCatalogEvent) => void;
|
||||
|
|
@ -314,6 +315,9 @@ function runNotificationEffect(context: ChatInboundHandlerContext, effect: ChatN
|
|||
case "refresh-threads":
|
||||
context.actions.refreshActiveThreads();
|
||||
return;
|
||||
case "refresh-server-diagnostics":
|
||||
context.actions.refreshServerDiagnostics({ forceResourceProbes: effect.forceResourceProbes === true });
|
||||
return;
|
||||
case "apply-app-server-resource-event":
|
||||
context.actions.applyAppServerResourceEvent(effect.event);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import {
|
|||
|
||||
export type ChatNotificationEffect =
|
||||
| { type: "refresh-threads" }
|
||||
| { type: "refresh-server-diagnostics"; forceResourceProbes?: boolean }
|
||||
| { type: "apply-app-server-resource-event"; event: AppServerResourceEvent }
|
||||
| { type: "maybe-name-thread"; threadId: string; turnId: string; completedSummary: ThreadConversationSummary | null }
|
||||
| { type: "apply-thread-catalog-event"; event: ThreadCatalogEvent };
|
||||
|
|
@ -99,6 +100,14 @@ const DIAGNOSTIC_STATUS_PLANNERS = {
|
|||
actions: [],
|
||||
effects: [{ type: "apply-app-server-resource-event", event: { type: "skills-changed", forceReload: true } }],
|
||||
}),
|
||||
"app/list/updated": () => ({
|
||||
actions: [],
|
||||
effects: [{ type: "refresh-server-diagnostics" }],
|
||||
}),
|
||||
"mcpServer/oauthLogin/completed": () => ({
|
||||
actions: [],
|
||||
effects: [{ type: "refresh-server-diagnostics", forceResourceProbes: true }],
|
||||
}),
|
||||
"mcpServer/startupStatus/updated": (notification) => ({
|
||||
actions: [],
|
||||
effects: [
|
||||
|
|
@ -122,6 +131,9 @@ const USER_VISIBLE_NOTICE_PLANNERS = {
|
|||
error: jsonNoticePlan,
|
||||
warning: jsonNoticePlan,
|
||||
configWarning: jsonNoticePlan,
|
||||
"windows/worldWritableWarning": jsonNoticePlan,
|
||||
"windowsSandbox/setupCompleted": (notification, localItemId) =>
|
||||
notification.params.success ? EMPTY_PLAN : jsonNoticePlan(notification, localItemId),
|
||||
} satisfies ServerNotificationLocalPlannerMap<UserVisibleNoticeNotificationMethod>;
|
||||
|
||||
const STREAM_UPDATE_PLANNERS = {
|
||||
|
|
@ -312,7 +324,7 @@ export const PLANNED_SERVER_NOTIFICATION_METHODS_BY_ROUTE_KIND = {
|
|||
diagnosticStatus: plannerMethods(DIAGNOSTIC_STATUS_PLANNERS),
|
||||
userVisibleNotice: plannerMethods(USER_VISIBLE_NOTICE_PLANNERS),
|
||||
} satisfies Record<
|
||||
Exclude<ReturnType<typeof routeServerNotification>["kind"], "inactive" | "unhandled">,
|
||||
Exclude<ReturnType<typeof routeServerNotification>["kind"], "inactive" | "unhandled" | "ignored">,
|
||||
readonly ServerNotification["method"][]
|
||||
>;
|
||||
|
||||
|
|
@ -327,6 +339,7 @@ export function planChatNotification(
|
|||
});
|
||||
switch (route.kind) {
|
||||
case "inactive":
|
||||
case "ignored":
|
||||
case "unhandled":
|
||||
return EMPTY_PLAN;
|
||||
case "streamUpdate":
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export type ServerNotificationRoute =
|
|||
| { kind: "requestResolved"; notification: RequestResolvedNotification }
|
||||
| { kind: "diagnosticStatus"; notification: DiagnosticStatusNotification }
|
||||
| { kind: "userVisibleNotice"; notification: UserVisibleNoticeNotification }
|
||||
| { kind: "ignored"; notification: ServerNotification }
|
||||
| { kind: "unhandled"; notification: ServerNotification }
|
||||
| { kind: "inactive"; notification: ServerNotification };
|
||||
|
||||
|
|
@ -82,6 +83,8 @@ const DIAGNOSTIC_STATUS_NOTIFICATION_METHODS = [
|
|||
"thread/tokenUsage/updated",
|
||||
"account/rateLimits/updated",
|
||||
"skills/changed",
|
||||
"app/list/updated",
|
||||
"mcpServer/oauthLogin/completed",
|
||||
"mcpServer/startupStatus/updated",
|
||||
] as const;
|
||||
|
||||
|
|
@ -94,10 +97,43 @@ const USER_VISIBLE_NOTICE_NOTIFICATION_METHODS = [
|
|||
"error",
|
||||
"warning",
|
||||
"configWarning",
|
||||
"windows/worldWritableWarning",
|
||||
"windowsSandbox/setupCompleted",
|
||||
] as const;
|
||||
|
||||
export type UserVisibleNoticeNotificationMethod = (typeof USER_VISIBLE_NOTICE_NOTIFICATION_METHODS)[number];
|
||||
|
||||
export const IGNORED_SERVER_NOTIFICATION_METHODS = [
|
||||
"thread/status/changed",
|
||||
"thread/closed",
|
||||
"rawResponseItem/completed",
|
||||
"command/exec/outputDelta",
|
||||
"process/outputDelta",
|
||||
"process/exited",
|
||||
"item/commandExecution/terminalInteraction",
|
||||
"account/updated",
|
||||
"remoteControl/status/changed",
|
||||
"externalAgentConfig/import/progress",
|
||||
"externalAgentConfig/import/completed",
|
||||
"fs/changed",
|
||||
"model/verification",
|
||||
"turn/moderationMetadata",
|
||||
"model/safetyBuffering/updated",
|
||||
"fuzzyFileSearch/sessionUpdated",
|
||||
"fuzzyFileSearch/sessionCompleted",
|
||||
"thread/realtime/started",
|
||||
"thread/realtime/itemAdded",
|
||||
"thread/realtime/transcript/delta",
|
||||
"thread/realtime/transcript/done",
|
||||
"thread/realtime/outputAudio/delta",
|
||||
"thread/realtime/sdp",
|
||||
"thread/realtime/error",
|
||||
"thread/realtime/closed",
|
||||
"account/login/completed",
|
||||
] as const satisfies readonly ServerNotificationMethod[];
|
||||
|
||||
type IgnoredServerNotificationMethod = (typeof IGNORED_SERVER_NOTIFICATION_METHODS)[number];
|
||||
|
||||
export const ROUTED_SERVER_NOTIFICATION_METHODS_BY_ROUTE_KIND = {
|
||||
streamUpdate: STREAM_UPDATE_NOTIFICATION_METHODS,
|
||||
turnLifecycle: TURN_LIFECYCLE_NOTIFICATION_METHODS,
|
||||
|
|
@ -105,7 +141,7 @@ export const ROUTED_SERVER_NOTIFICATION_METHODS_BY_ROUTE_KIND = {
|
|||
requestResolved: ["serverRequest/resolved"],
|
||||
diagnosticStatus: DIAGNOSTIC_STATUS_NOTIFICATION_METHODS,
|
||||
userVisibleNotice: USER_VISIBLE_NOTICE_NOTIFICATION_METHODS,
|
||||
} satisfies Record<Exclude<ServerNotificationRoute["kind"], "inactive" | "unhandled">, readonly ServerNotificationMethod[]>;
|
||||
} satisfies Record<Exclude<ServerNotificationRoute["kind"], "inactive" | "unhandled" | "ignored">, readonly ServerNotificationMethod[]>;
|
||||
|
||||
const SERVER_NOTIFICATION_SCOPE_EXTRACTORS: ServerNotificationScopeExtractors = {
|
||||
error: threadTurnNotificationScope,
|
||||
|
|
@ -191,6 +227,7 @@ export function routeServerNotification(notification: ServerNotification, scope:
|
|||
if (notification.method === "serverRequest/resolved") return { kind: "requestResolved", notification };
|
||||
if (isDiagnosticStatusNotification(notification)) return { kind: "diagnosticStatus", notification };
|
||||
if (isUserVisibleNoticeNotification(notification)) return { kind: "userVisibleNotice", notification };
|
||||
if (isIgnoredServerNotification(notification)) return { kind: "ignored", notification };
|
||||
return { kind: "unhandled", notification };
|
||||
}
|
||||
|
||||
|
|
@ -252,6 +289,12 @@ function isUserVisibleNoticeNotification(notification: ServerNotification): noti
|
|||
return notificationMethodIn(notification.method, USER_VISIBLE_NOTICE_NOTIFICATION_METHODS);
|
||||
}
|
||||
|
||||
function isIgnoredServerNotification(
|
||||
notification: ServerNotification,
|
||||
): notification is RoutedNotification<IgnoredServerNotificationMethod> {
|
||||
return notificationMethodIn(notification.method, IGNORED_SERVER_NOTIFICATION_METHODS);
|
||||
}
|
||||
|
||||
function notificationMethodIn<M extends ServerNotificationMethod>(method: ServerNotificationMethod, methods: readonly M[]): method is M {
|
||||
return (methods as readonly ServerNotificationMethod[]).includes(method);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import type { ChatConnectionPhase } from "../application/state/root-reducer";
|
|||
import type { ChatStateStore } from "../application/state/store";
|
||||
import type { AutoTitleCoordinator } from "../application/threads/auto-title-coordinator";
|
||||
import type { createThreadGoalSyncActions } from "../application/threads/goal-actions";
|
||||
import type { ChatPanelEnvironment } from "./runtime";
|
||||
import type { ChatPanelEnvironment } from "./environment";
|
||||
|
||||
export type CurrentAppServerClient = () => AppServerClient | null;
|
||||
|
||||
|
|
@ -141,6 +141,11 @@ export function createConnectionBundle(
|
|||
refreshActiveThreads: () => {
|
||||
refreshSharedThreadsQuietly();
|
||||
},
|
||||
refreshServerDiagnostics: (options) => {
|
||||
void serverDiagnostics.refreshServerDiagnostics(options).catch((error: unknown) => {
|
||||
status.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
},
|
||||
applyAppServerResourceEvent: (event) => {
|
||||
void serverMetadata.applyAppServerResourceEvent(event).catch((error: unknown) => {
|
||||
status.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
|
|
|
|||
139
src/features/chat/host/panel-surfaces.ts
Normal file
139
src/features/chat/host/panel-surfaces.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import type { ConnectionManager } from "../../../app-server/connection/connection-manager";
|
||||
import type { ConversationTurnActions } from "../application/conversation/composition";
|
||||
import type { PendingRequestActions } from "../application/pending-requests/pending-request-actions";
|
||||
import type { ChatStateStore } from "../application/state/store";
|
||||
import type { createGoalActions } from "../application/threads/goal-actions";
|
||||
import type { HistoryController } from "../application/threads/history-controller";
|
||||
import type { ThreadRenameEditorActions } from "../application/threads/rename-editor-actions";
|
||||
import type { createSelectionActions } from "../application/threads/selection-actions";
|
||||
import type { createThreadManagementActions } from "../application/threads/thread-management-actions";
|
||||
import { type ChatPanelGoalSurface, createChatPanelGoalSurface } from "../panel/surface/goal-projection";
|
||||
import { MessageStreamPresenter } from "../panel/surface/message-stream-presenter";
|
||||
import type { ChatMessageScrollController } from "../panel/surface/message-stream-scroll";
|
||||
import type { ChatPanelToolbarSurface } from "../panel/surface/toolbar-projection";
|
||||
import { createChatPanelToolbarActions, type ToolbarPanelActions } from "../panel/toolbar-actions";
|
||||
import type { ToolbarActions } from "../ui/toolbar";
|
||||
import type { ChatPanelConnectionBundle } from "./connection-bundle";
|
||||
import type { ChatPanelEnvironment } from "./environment";
|
||||
|
||||
type ChatPanelGoalActions = ReturnType<typeof createGoalActions>;
|
||||
type ChatPanelThreadActions = ReturnType<typeof createThreadManagementActions>;
|
||||
type ChatPanelSelectionActions = ReturnType<typeof createSelectionActions>;
|
||||
type ChatPanelConversationTurnActions = ConversationTurnActions;
|
||||
|
||||
export interface ChatPanelSurfacesHost {
|
||||
environment: ChatPanelEnvironment;
|
||||
stateStore: ChatStateStore;
|
||||
messageScrollController: ChatMessageScrollController;
|
||||
}
|
||||
|
||||
export interface ChatPanelSurfacesInput {
|
||||
connection: ConnectionManager;
|
||||
connectionController: ChatPanelConnectionBundle["connection"]["controller"];
|
||||
goals: ChatPanelGoalActions;
|
||||
rename: ThreadRenameEditorActions;
|
||||
threadActions: ChatPanelThreadActions;
|
||||
toolbarPanels: ToolbarPanelActions;
|
||||
selection: ChatPanelSelectionActions;
|
||||
reconnect: () => Promise<void>;
|
||||
history: HistoryController;
|
||||
pendingRequests: PendingRequestActions;
|
||||
turnActions: ChatPanelConversationTurnActions;
|
||||
startNewThread: () => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ChatPanelSurfaces {
|
||||
toolbarActions: ToolbarActions;
|
||||
toolbarSurface: ChatPanelToolbarSurface;
|
||||
goalSurface: ChatPanelGoalSurface;
|
||||
messageStreamPresenter: MessageStreamPresenter;
|
||||
}
|
||||
|
||||
export function createChatPanelSurfaces(host: ChatPanelSurfacesHost, input: ChatPanelSurfacesInput): ChatPanelSurfaces {
|
||||
const {
|
||||
connection,
|
||||
connectionController,
|
||||
goals,
|
||||
rename,
|
||||
threadActions,
|
||||
toolbarPanels,
|
||||
selection,
|
||||
reconnect,
|
||||
history,
|
||||
pendingRequests,
|
||||
turnActions,
|
||||
startNewThread,
|
||||
} = input;
|
||||
const { environment, stateStore } = host;
|
||||
const toolbarActions = createChatPanelToolbarActions(
|
||||
{
|
||||
startNewThread,
|
||||
},
|
||||
{
|
||||
connectionController,
|
||||
reconnectPanel: reconnect,
|
||||
threadActions,
|
||||
goals,
|
||||
toolbarPanels,
|
||||
rename,
|
||||
selection,
|
||||
},
|
||||
);
|
||||
const toolbarSurface: ChatPanelToolbarSurface = {
|
||||
state: {
|
||||
connected: () => connection.isConnected(),
|
||||
nowMs: () => Date.now(),
|
||||
},
|
||||
settings: {
|
||||
vaultPath: () => environment.plugin.settingsRef.vaultPath,
|
||||
configuredCommand: () => environment.plugin.settingsRef.settings.codexPath,
|
||||
archiveExportEnabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled,
|
||||
},
|
||||
};
|
||||
const goalSurface = createChatPanelGoalSurface(
|
||||
{
|
||||
sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut,
|
||||
},
|
||||
{
|
||||
goals,
|
||||
},
|
||||
);
|
||||
const messageStreamPresenter = new MessageStreamPresenter({
|
||||
obsidian: {
|
||||
app: environment.obsidian.app,
|
||||
owner: environment.obsidian.owner,
|
||||
},
|
||||
state: {
|
||||
store: stateStore,
|
||||
},
|
||||
workspace: {
|
||||
vaultPath: environment.plugin.settingsRef.vaultPath,
|
||||
},
|
||||
scroll: {
|
||||
controller: host.messageScrollController,
|
||||
dispose: () => {
|
||||
host.messageScrollController.dispose();
|
||||
},
|
||||
},
|
||||
history: {
|
||||
loadOlderTurns: () => void history.loadOlder(),
|
||||
},
|
||||
actions: {
|
||||
rollbackThread: (threadId) => void threadActions.rollbackThread(threadId),
|
||||
forkThreadFromTurn: (threadId, turnId, archiveSource) => void threadActions.forkThreadFromTurn(threadId, turnId, archiveSource),
|
||||
implementPlan: (itemId) => void turnActions.planImplementation.implement(itemId),
|
||||
openTurnDiff: (state) => void environment.plugin.workspace.openTurnDiff(state),
|
||||
},
|
||||
requests: {
|
||||
pendingActions: () => pendingRequests.actions(),
|
||||
consumePendingAutoFocus: () => pendingRequests.consumeAutoFocus(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
toolbarActions,
|
||||
toolbarSurface,
|
||||
goalSurface,
|
||||
messageStreamPresenter,
|
||||
};
|
||||
}
|
||||
|
|
@ -2,11 +2,7 @@ import { Notice } from "obsidian";
|
|||
import type { AppServerClientAccess } from "../../../app-server/connection/client-access";
|
||||
import { ConnectionManager } from "../../../app-server/connection/connection-manager";
|
||||
import { isStaleAppServerSharedQueryContextError } from "../../../app-server/query/shared-queries";
|
||||
import type { ModelMetadata } from "../../../domain/catalog/metadata";
|
||||
import type { ObservedDataResult } from "../../../domain/observed-data";
|
||||
import { observedData } from "../../../domain/observed-data";
|
||||
import type { SharedServerMetadata } from "../../../domain/server/metadata";
|
||||
import { normalizeExplicitThreadName, type Thread } from "../../../domain/threads/model";
|
||||
import { normalizeExplicitThreadName } from "../../../domain/threads/model";
|
||||
import { createLocalIdSource, type LocalIdSource } from "../../../shared/id/local-id";
|
||||
import type { ConnectionWorkTracker } from "../../../shared/lifecycle/connection-work";
|
||||
import { createThreadOperations, type ThreadOperations } from "../../threads/thread-operations";
|
||||
|
|
@ -53,11 +49,11 @@ import { collaborationModeLabel as formatCollaborationModeLabel } from "../domai
|
|||
import type { RuntimeSnapshot } from "../domain/runtime/snapshot";
|
||||
import { ChatComposerController } from "../panel/composer-controller";
|
||||
import { type ChatPanelComposerSurface, chatPanelComposerProjection } from "../panel/surface/composer-projection";
|
||||
import { type ChatPanelGoalSurface, createChatPanelGoalSurface } from "../panel/surface/goal-projection";
|
||||
import { MessageStreamPresenter } from "../panel/surface/message-stream-presenter";
|
||||
import type { ChatPanelGoalSurface } from "../panel/surface/goal-projection";
|
||||
import type { MessageStreamPresenter } from "../panel/surface/message-stream-presenter";
|
||||
import type { ChatMessageScrollController } from "../panel/surface/message-stream-scroll";
|
||||
import type { ChatPanelToolbarSurface } from "../panel/surface/toolbar-projection";
|
||||
import { createChatPanelToolbarActions, createToolbarPanelActions, type ToolbarPanelActions } from "../panel/toolbar-actions";
|
||||
import { createToolbarPanelActions, type ToolbarPanelActions } from "../panel/toolbar-actions";
|
||||
import { VaultNoteCandidateProvider } from "../panel/vault-note-candidate-provider";
|
||||
import {
|
||||
effortStatusLines as buildEffortStatusLines,
|
||||
|
|
@ -66,7 +62,9 @@ import {
|
|||
} from "../presentation/runtime/status";
|
||||
import type { ToolbarActions } from "../ui/toolbar";
|
||||
import { type ChatPanelConnectionBundle, type CurrentAppServerClient, createConnectionBundle } from "./connection-bundle";
|
||||
import type { ChatPanelEnvironment } from "./runtime";
|
||||
import type { ChatPanelEnvironment } from "./environment";
|
||||
import { createChatPanelSurfaces } from "./panel-surfaces";
|
||||
import { type ChatPanelSharedStateBinding, createChatPanelSharedStateBinding } from "./shared-state-binding";
|
||||
|
||||
export interface ChatPanelSessionGraph {
|
||||
connection: {
|
||||
|
|
@ -114,12 +112,6 @@ export interface ChatPanelSessionGraph {
|
|||
};
|
||||
}
|
||||
|
||||
interface ChatPanelSharedStateBinding {
|
||||
applyCached(): void;
|
||||
subscribe(): void;
|
||||
unsubscribe(): void;
|
||||
}
|
||||
|
||||
interface ChatPanelSessionStatus {
|
||||
set: (statusText: string, phase?: ChatConnectionPhase) => void;
|
||||
addSystemMessage: (text: string) => void;
|
||||
|
|
@ -185,28 +177,6 @@ interface ChatPanelComposerAndTurnInput {
|
|||
runtimeProjection: ChatPanelRuntimeProjection;
|
||||
}
|
||||
|
||||
interface ChatPanelSurfacePresenterInput {
|
||||
connection: ConnectionManager;
|
||||
connectionController: ChatPanelConnectionBundle["connection"]["controller"];
|
||||
goals: ChatPanelGoalActions;
|
||||
rename: ThreadRenameEditorActions;
|
||||
threadActions: ChatPanelThreadActions;
|
||||
toolbarPanels: ToolbarPanelActions;
|
||||
selection: ChatPanelSelectionActions;
|
||||
reconnect: () => Promise<void>;
|
||||
history: HistoryController;
|
||||
pendingRequests: PendingRequestActions;
|
||||
turnActions: ChatPanelConversationTurnActions;
|
||||
startNewThread: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface ChatPanelSurfacePresenterParts {
|
||||
toolbarActions: ToolbarActions;
|
||||
toolbarSurface: ChatPanelToolbarSurface;
|
||||
goalSurface: ChatPanelGoalSurface;
|
||||
messageStreamPresenter: MessageStreamPresenter;
|
||||
}
|
||||
|
||||
export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): ChatPanelSessionGraph {
|
||||
const { environment, stateStore } = host;
|
||||
const localItemIds = createLocalIdSource();
|
||||
|
|
@ -314,7 +284,7 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch
|
|||
startNewThread,
|
||||
runtimeProjection: createSessionRuntimeProjection(host, connection),
|
||||
});
|
||||
const surfaceParts = createSurfacesAndPresenter(host, {
|
||||
const surfaces = createChatPanelSurfaces(host, {
|
||||
connection,
|
||||
connectionController,
|
||||
goals,
|
||||
|
|
@ -337,10 +307,18 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch
|
|||
}
|
||||
};
|
||||
const dispose = (): void => {
|
||||
surfaceParts.messageStreamPresenter.dispose();
|
||||
surfaces.messageStreamPresenter.dispose();
|
||||
composerController.dispose();
|
||||
};
|
||||
const sharedState = createChatPanelSharedStateBinding(host, connectionBundle.serverActions);
|
||||
const sharedState = createChatPanelSharedStateBinding({
|
||||
stateStore: host.stateStore,
|
||||
threadCatalog: host.environment.plugin.threadCatalog,
|
||||
appServerData: host.environment.plugin.appServerData,
|
||||
serverActions: connectionBundle.serverActions,
|
||||
refreshTabHeader: () => {
|
||||
refreshTabHeader(host);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
connection: {
|
||||
|
|
@ -357,18 +335,18 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch
|
|||
},
|
||||
toolbar: {
|
||||
panels: threadActionParts.toolbarPanels,
|
||||
actions: surfaceParts.toolbarActions,
|
||||
actions: surfaces.toolbarActions,
|
||||
},
|
||||
composer: {
|
||||
controller: composerController,
|
||||
submission: composerAndTurn.turnActions.composerSubmit,
|
||||
},
|
||||
render: {
|
||||
messageStreamPresenter: surfaceParts.messageStreamPresenter,
|
||||
messageStreamPresenter: surfaces.messageStreamPresenter,
|
||||
},
|
||||
surface: {
|
||||
toolbar: surfaceParts.toolbarSurface,
|
||||
goal: surfaceParts.goalSurface,
|
||||
toolbar: surfaces.toolbarSurface,
|
||||
goal: surfaces.goalSurface,
|
||||
composer: composerSurface,
|
||||
},
|
||||
actions: {
|
||||
|
|
@ -393,63 +371,6 @@ function createConnectionManager(environment: ChatPanelEnvironment): ConnectionM
|
|||
return new ConnectionManager(() => environment.plugin.settingsRef.settings.codexPath, environment.plugin.settingsRef.vaultPath);
|
||||
}
|
||||
|
||||
function createChatPanelSharedStateBinding(
|
||||
host: ChatPanelSessionGraphHost,
|
||||
serverActions: ChatPanelConnectionBundle["serverActions"],
|
||||
): ChatPanelSharedStateBinding {
|
||||
const unsubscribers: (() => void)[] = [];
|
||||
|
||||
const receiveThreads = (threads: readonly Thread[]): void => {
|
||||
serverActions.threads.applyThreadList(threads);
|
||||
refreshTabHeader(host);
|
||||
};
|
||||
const receiveThreadResult = (result: ObservedDataResult<readonly Thread[]>): void => {
|
||||
const data = observedData(result);
|
||||
if (data) receiveThreads(data);
|
||||
};
|
||||
const receiveAppServerMetadata = (metadata: SharedServerMetadata): void => {
|
||||
serverActions.metadata.applyAppServerMetadata(metadata);
|
||||
};
|
||||
const receiveAppServerMetadataResult = (result: ObservedDataResult<SharedServerMetadata>): void => {
|
||||
const data = observedData(result);
|
||||
if (data) receiveAppServerMetadata(data);
|
||||
};
|
||||
const receiveModels = (models: readonly ModelMetadata[]): void => {
|
||||
dispatch(host.stateStore, { type: "connection/metadata-applied", availableModels: models });
|
||||
};
|
||||
const receiveModelsResult = (result: ObservedDataResult<readonly ModelMetadata[]>): void => {
|
||||
const data = observedData(result);
|
||||
if (data) receiveModels(data);
|
||||
};
|
||||
const unsubscribe = (): void => {
|
||||
while (unsubscribers.length > 0) {
|
||||
unsubscribers.pop()?.();
|
||||
}
|
||||
};
|
||||
const applyCached = (): void => {
|
||||
const threads = host.environment.plugin.threadCatalog.activeSnapshot();
|
||||
if (threads) serverActions.threads.applyThreadList(threads);
|
||||
const metadata = host.environment.plugin.appServerData.appServerMetadataSnapshot();
|
||||
if (metadata) serverActions.metadata.applyAppServerMetadata(metadata);
|
||||
const models = host.environment.plugin.appServerData.modelsSnapshot();
|
||||
if (models) receiveModels(models);
|
||||
};
|
||||
|
||||
return {
|
||||
applyCached,
|
||||
subscribe: () => {
|
||||
unsubscribe();
|
||||
applyCached();
|
||||
unsubscribers.push(
|
||||
host.environment.plugin.threadCatalog.observeActive(receiveThreadResult, { emitCurrent: false }),
|
||||
host.environment.plugin.appServerData.observeAppServerMetadataResult(receiveAppServerMetadataResult, { emitCurrent: false }),
|
||||
host.environment.plugin.appServerData.observeModelsResult(receiveModelsResult, { emitCurrent: false }),
|
||||
);
|
||||
},
|
||||
unsubscribe,
|
||||
};
|
||||
}
|
||||
|
||||
function createSessionThreadTitleService(host: ChatPanelSessionGraphHost, currentClient: CurrentAppServerClient): ThreadTitleService {
|
||||
const { environment, stateStore } = host;
|
||||
return createThreadTitleService({
|
||||
|
|
@ -887,98 +808,6 @@ function createComposerAndTurnActions(
|
|||
};
|
||||
}
|
||||
|
||||
function createSurfacesAndPresenter(
|
||||
host: ChatPanelSessionGraphHost,
|
||||
input: ChatPanelSurfacePresenterInput,
|
||||
): ChatPanelSurfacePresenterParts {
|
||||
const {
|
||||
connection,
|
||||
connectionController,
|
||||
goals,
|
||||
rename,
|
||||
threadActions,
|
||||
toolbarPanels,
|
||||
selection,
|
||||
reconnect,
|
||||
history,
|
||||
pendingRequests,
|
||||
turnActions,
|
||||
startNewThread,
|
||||
} = input;
|
||||
const { environment, stateStore } = host;
|
||||
const toolbarActions = createChatPanelToolbarActions(
|
||||
{
|
||||
startNewThread,
|
||||
},
|
||||
{
|
||||
connectionController,
|
||||
reconnectPanel: reconnect,
|
||||
threadActions,
|
||||
goals,
|
||||
toolbarPanels,
|
||||
rename,
|
||||
selection,
|
||||
},
|
||||
);
|
||||
const toolbarSurface: ChatPanelToolbarSurface = {
|
||||
state: {
|
||||
connected: () => connection.isConnected(),
|
||||
nowMs: () => Date.now(),
|
||||
},
|
||||
settings: {
|
||||
vaultPath: () => environment.plugin.settingsRef.vaultPath,
|
||||
configuredCommand: () => environment.plugin.settingsRef.settings.codexPath,
|
||||
archiveExportEnabled: () => environment.plugin.settingsRef.settings.archiveExportEnabled,
|
||||
},
|
||||
};
|
||||
const goalSurface = createChatPanelGoalSurface(
|
||||
{
|
||||
sendShortcut: () => environment.plugin.settingsRef.settings.sendShortcut,
|
||||
},
|
||||
{
|
||||
goals,
|
||||
},
|
||||
);
|
||||
const messageStreamPresenter = new MessageStreamPresenter({
|
||||
obsidian: {
|
||||
app: environment.obsidian.app,
|
||||
owner: environment.obsidian.owner,
|
||||
},
|
||||
state: {
|
||||
store: stateStore,
|
||||
},
|
||||
workspace: {
|
||||
vaultPath: environment.plugin.settingsRef.vaultPath,
|
||||
},
|
||||
scroll: {
|
||||
controller: host.messageScrollController,
|
||||
dispose: () => {
|
||||
host.messageScrollController.dispose();
|
||||
},
|
||||
},
|
||||
history: {
|
||||
loadOlderTurns: () => void history.loadOlder(),
|
||||
},
|
||||
actions: {
|
||||
rollbackThread: (threadId) => void threadActions.rollbackThread(threadId),
|
||||
forkThreadFromTurn: (threadId, turnId, archiveSource) => void threadActions.forkThreadFromTurn(threadId, turnId, archiveSource),
|
||||
implementPlan: (itemId) => void turnActions.planImplementation.implement(itemId),
|
||||
openTurnDiff: (state) => void environment.plugin.workspace.openTurnDiff(state),
|
||||
},
|
||||
requests: {
|
||||
pendingActions: () => pendingRequests.actions(),
|
||||
consumePendingAutoFocus: () => pendingRequests.consumeAutoFocus(),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
toolbarActions,
|
||||
toolbarSurface,
|
||||
goalSurface,
|
||||
messageStreamPresenter,
|
||||
};
|
||||
}
|
||||
|
||||
function collaborationModeLabel(stateStore: ChatStateStore): string {
|
||||
return formatCollaborationModeLabel(stateStore.getState().runtime.pending.collaborationMode);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import { type ChatStateStore, createChatStateStore } from "../application/state/
|
|||
import { renderChatPanelShell, unmountChatPanelShell } from "../panel/shell.dom";
|
||||
import { type ChatPanelSnapshot, openPanelTurnLifecycle, parseRestoredThreadState } from "../panel/snapshot";
|
||||
import { type ChatMessageScrollController, createChatMessageScrollController } from "../panel/surface/message-stream-scroll";
|
||||
import { createChatViewDeferredTasks } from "./lifecycle";
|
||||
import type { ChatPanelEnvironment } from "./runtime";
|
||||
import { createChatViewDeferredTasks } from "./deferred-tasks";
|
||||
import type { ChatPanelEnvironment } from "./environment";
|
||||
import { type ChatPanelSessionGraph, createChatPanelSessionGraph } from "./session-graph";
|
||||
import type { ChatSurfaceHandle } from "./surface-handle";
|
||||
|
||||
|
|
|
|||
92
src/features/chat/host/shared-state-binding.ts
Normal file
92
src/features/chat/host/shared-state-binding.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import type { ModelMetadata } from "../../../domain/catalog/metadata";
|
||||
import type { ObservedDataResult } from "../../../domain/observed-data";
|
||||
import { observedData } from "../../../domain/observed-data";
|
||||
import type { SharedServerMetadata } from "../../../domain/server/metadata";
|
||||
import type { Thread } from "../../../domain/threads/model";
|
||||
import type { ChatStateStore } from "../application/state/store";
|
||||
import type { ChatPanelConnectionBundle } from "./connection-bundle";
|
||||
|
||||
type ThreadObserver = (result: ObservedDataResult<readonly Thread[]>) => void;
|
||||
type MetadataObserver = (result: ObservedDataResult<SharedServerMetadata>) => void;
|
||||
type ModelsObserver = (result: ObservedDataResult<readonly ModelMetadata[]>) => void;
|
||||
|
||||
interface SharedStateThreadCatalog {
|
||||
activeSnapshot(): readonly Thread[] | null;
|
||||
observeActive(observer: ThreadObserver, options?: { emitCurrent?: boolean }): () => void;
|
||||
}
|
||||
|
||||
interface SharedStateAppServerData {
|
||||
appServerMetadataSnapshot(): SharedServerMetadata | null;
|
||||
modelsSnapshot(): readonly ModelMetadata[] | null;
|
||||
observeAppServerMetadataResult(observer: MetadataObserver, options?: { emitCurrent?: boolean }): () => void;
|
||||
observeModelsResult(observer: ModelsObserver, options?: { emitCurrent?: boolean }): () => void;
|
||||
}
|
||||
|
||||
export interface ChatPanelSharedStateBinding {
|
||||
applyCached(): void;
|
||||
subscribe(): void;
|
||||
unsubscribe(): void;
|
||||
}
|
||||
|
||||
export interface ChatPanelSharedStateBindingOptions {
|
||||
stateStore: ChatStateStore;
|
||||
threadCatalog: SharedStateThreadCatalog;
|
||||
appServerData: SharedStateAppServerData;
|
||||
serverActions: ChatPanelConnectionBundle["serverActions"];
|
||||
refreshTabHeader: () => void;
|
||||
}
|
||||
|
||||
export function createChatPanelSharedStateBinding(options: ChatPanelSharedStateBindingOptions): ChatPanelSharedStateBinding {
|
||||
const unsubscribers: (() => void)[] = [];
|
||||
const { stateStore, threadCatalog, appServerData, serverActions, refreshTabHeader } = options;
|
||||
|
||||
const receiveThreads = (threads: readonly Thread[]): void => {
|
||||
serverActions.threads.applyThreadList(threads);
|
||||
refreshTabHeader();
|
||||
};
|
||||
const receiveThreadResult = (result: ObservedDataResult<readonly Thread[]>): void => {
|
||||
const data = observedData(result);
|
||||
if (data) receiveThreads(data);
|
||||
};
|
||||
const receiveAppServerMetadata = (metadata: SharedServerMetadata): void => {
|
||||
serverActions.metadata.applyAppServerMetadata(metadata);
|
||||
};
|
||||
const receiveAppServerMetadataResult = (result: ObservedDataResult<SharedServerMetadata>): void => {
|
||||
const data = observedData(result);
|
||||
if (data) receiveAppServerMetadata(data);
|
||||
};
|
||||
const receiveModels = (models: readonly ModelMetadata[]): void => {
|
||||
stateStore.dispatch({ type: "connection/metadata-applied", availableModels: models });
|
||||
};
|
||||
const receiveModelsResult = (result: ObservedDataResult<readonly ModelMetadata[]>): void => {
|
||||
const data = observedData(result);
|
||||
if (data) receiveModels(data);
|
||||
};
|
||||
const unsubscribe = (): void => {
|
||||
while (unsubscribers.length > 0) {
|
||||
unsubscribers.pop()?.();
|
||||
}
|
||||
};
|
||||
const applyCached = (): void => {
|
||||
const threads = threadCatalog.activeSnapshot();
|
||||
if (threads) serverActions.threads.applyThreadList(threads);
|
||||
const metadata = appServerData.appServerMetadataSnapshot();
|
||||
if (metadata) serverActions.metadata.applyAppServerMetadata(metadata);
|
||||
const models = appServerData.modelsSnapshot();
|
||||
if (models) receiveModels(models);
|
||||
};
|
||||
|
||||
return {
|
||||
applyCached,
|
||||
subscribe: () => {
|
||||
unsubscribe();
|
||||
applyCached();
|
||||
unsubscribers.push(
|
||||
threadCatalog.observeActive(receiveThreadResult, { emitCurrent: false }),
|
||||
appServerData.observeAppServerMetadataResult(receiveAppServerMetadataResult, { emitCurrent: false }),
|
||||
appServerData.observeModelsResult(receiveModelsResult, { emitCurrent: false }),
|
||||
);
|
||||
},
|
||||
unsubscribe,
|
||||
};
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ import { ItemView, type ViewStateResult, type WorkspaceLeaf } from "obsidian";
|
|||
import { VIEW_TYPE_CODEX_PANEL } from "../../../constants";
|
||||
import { createLocalIdSource } from "../../../shared/id/local-id";
|
||||
import { createObsidianArchiveExportDestination } from "../../../shared/obsidian/archive-export-destination";
|
||||
import type { CodexChatHost } from "./runtime";
|
||||
import type { CodexChatHost } from "./environment";
|
||||
import { ChatPanelSession } from "./session";
|
||||
import type { ChatSurfaceHandle } from "./surface-handle";
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { AppServerSharedQueries } from "./app-server/query/shared-queries";
|
|||
import { VIEW_TYPE_CODEX_THREADS, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants";
|
||||
import type { ChatTurnDiffViewState } from "./features/chat/domain/turn-diff";
|
||||
import { persistedChatTurnDiffViewState } from "./features/chat/domain/turn-diff";
|
||||
import type { CodexChatHost, PluginSettingsRef } from "./features/chat/host/runtime";
|
||||
import type { CodexChatHost, PluginSettingsRef } from "./features/chat/host/environment";
|
||||
import type {
|
||||
ChatPanelClientSurface,
|
||||
ChatSharedThreadSurface,
|
||||
|
|
|
|||
|
|
@ -109,6 +109,50 @@ describe("runEphemeralStructuredTurn", () => {
|
|||
expect(timers.clearTimeout).toHaveBeenCalledWith(123);
|
||||
});
|
||||
|
||||
it("deletes the ephemeral thread before disconnecting after a completed turn", async () => {
|
||||
const { clientFactory, client } = fakeStructuredTurnClientFactory((fake) => {
|
||||
fake.startStructuredTurnImpl = async () => ({ turn: turn([agentMessage("answer", '{"ok":true}')]) });
|
||||
});
|
||||
|
||||
await runEphemeralStructuredTurn(runOptions(clientFactory));
|
||||
|
||||
const fake = expectPresent(client.current);
|
||||
expect(fake.deleteThread).toHaveBeenCalledWith("thread", { timeoutMs: 5_000 });
|
||||
expect(expectPresent(fake.disconnect.mock.invocationCallOrder[0])).toBeGreaterThan(
|
||||
expectPresent(fake.deleteThread.mock.invocationCallOrder[0]),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not try to delete when no ephemeral thread was started", async () => {
|
||||
const timers = timerHarness();
|
||||
const { clientFactory, client } = fakeStructuredTurnClientFactory((fake) => {
|
||||
fake.connectImpl = () => new Promise<InitializeResponse>(() => undefined);
|
||||
});
|
||||
|
||||
const running = runEphemeralStructuredTurn({
|
||||
...runOptions(clientFactory),
|
||||
timers,
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
timers.fireTimeout();
|
||||
|
||||
await expect(running).rejects.toThrow("Structured test timed out.");
|
||||
expect(expectPresent(client.current).deleteThread).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the completed turn result when deleting the ephemeral thread fails", async () => {
|
||||
const { clientFactory, client } = fakeStructuredTurnClientFactory((fake) => {
|
||||
fake.deleteThread.mockRejectedValueOnce(new Error("delete failed"));
|
||||
fake.startStructuredTurnImpl = async () => ({ turn: turn([agentMessage("answer", '{"ok":true}')]) });
|
||||
});
|
||||
|
||||
await expect(runEphemeralStructuredTurn(runOptions(clientFactory))).resolves.toMatchObject({
|
||||
items: [agentMessage("answer", '{"ok":true}')],
|
||||
});
|
||||
expect(expectPresent(client.current).disconnect).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects server requests with the configured message", async () => {
|
||||
const { clientFactory, client } = fakeStructuredTurnClientFactory((fake) => {
|
||||
fake.connectImpl = async () => {
|
||||
|
|
@ -215,6 +259,24 @@ describe("runEphemeralStructuredTurn", () => {
|
|||
expect(expectPresent(client.current).disconnect).toHaveBeenCalledOnce();
|
||||
expect(timers.clearTimeout).toHaveBeenCalledWith(123);
|
||||
});
|
||||
|
||||
it("deletes a started ephemeral thread when structured turn startup times out", async () => {
|
||||
const timers = timerHarness();
|
||||
const { clientFactory, client } = fakeStructuredTurnClientFactory((fake) => {
|
||||
fake.startStructuredTurnImpl = () => new Promise<TurnStartResponse>(() => undefined);
|
||||
});
|
||||
|
||||
const running = runEphemeralStructuredTurn({
|
||||
...runOptions(clientFactory),
|
||||
timers,
|
||||
});
|
||||
await expectPresent(client.current).structuredTurnStarted;
|
||||
|
||||
timers.fireTimeout();
|
||||
|
||||
await expect(running).rejects.toThrow("Structured test timed out.");
|
||||
expect(expectPresent(client.current).deleteThread).toHaveBeenCalledWith("thread", { timeoutMs: 5_000 });
|
||||
});
|
||||
});
|
||||
|
||||
function runOptions(clientFactory: EphemeralStructuredTurnClientFactory): Parameters<typeof runEphemeralStructuredTurn>[0] {
|
||||
|
|
@ -256,6 +318,7 @@ class FakeStructuredTurnClient implements EphemeralStructuredTurnClient {
|
|||
startStructuredTurnOptions: AppServerStartStructuredTurnOptions | null = null;
|
||||
readonly listModels = vi.fn(async (): Promise<ModelListResponse> => ({ data: [], nextCursor: null }));
|
||||
readonly rejectServerRequest = vi.fn();
|
||||
readonly deleteThread = vi.fn(async () => undefined);
|
||||
readonly disconnect = vi.fn();
|
||||
readonly structuredTurnStarted: Promise<void>;
|
||||
private resolveStructuredTurnStarted!: () => void;
|
||||
|
|
|
|||
|
|
@ -242,6 +242,8 @@ class FakeThreadTitleClient implements EphemeralStructuredTurnClient {
|
|||
|
||||
disconnect(): void {}
|
||||
|
||||
async deleteThread(): Promise<void> {}
|
||||
|
||||
async listModels(): Promise<ModelListResponse> {
|
||||
return { data: this.modelList, nextCursor: null };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import type { ModelMetadata } from "../../../../src/domain/catalog/metadata";
|
|||
import type { Thread } from "../../../../src/domain/threads/model";
|
||||
import { ChatResumeWorkTracker } from "../../../../src/features/chat/application/lifecycle";
|
||||
import { type ChatStateStore, createChatStateStore } from "../../../../src/features/chat/application/state/store";
|
||||
import { createChatViewDeferredTasks } from "../../../../src/features/chat/host/lifecycle";
|
||||
import type { ChatPanelEnvironment } from "../../../../src/features/chat/host/runtime";
|
||||
import { createChatViewDeferredTasks } from "../../../../src/features/chat/host/deferred-tasks";
|
||||
import type { ChatPanelEnvironment } from "../../../../src/features/chat/host/environment";
|
||||
import { createChatPanelSessionGraph } from "../../../../src/features/chat/host/session-graph";
|
||||
import { ChatComposerController } from "../../../../src/features/chat/panel/composer-controller";
|
||||
import { MessageStreamPresenter } from "../../../../src/features/chat/panel/surface/message-stream-presenter";
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ function handlerForState(
|
|||
store,
|
||||
{
|
||||
refreshActiveThreads: vi.fn(),
|
||||
refreshServerDiagnostics: vi.fn(),
|
||||
applyAppServerResourceEvent: vi.fn(),
|
||||
maybeNameThread: vi.fn(),
|
||||
applyThreadCatalogEvent: vi.fn(),
|
||||
|
|
@ -717,6 +718,32 @@ describe("ChatInboundHandler", () => {
|
|||
});
|
||||
expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]);
|
||||
});
|
||||
|
||||
it("refreshes tool inventory diagnostics when the app list changes", () => {
|
||||
const refreshServerDiagnostics = vi.fn();
|
||||
const handler = handlerForState(chatStateFixture(), { refreshServerDiagnostics });
|
||||
|
||||
handler.handleNotification({
|
||||
method: "app/list/updated",
|
||||
params: { data: [] },
|
||||
} satisfies Extract<ServerNotification, { method: "app/list/updated" }>);
|
||||
|
||||
expect(refreshServerDiagnostics).toHaveBeenCalledWith({ forceResourceProbes: false });
|
||||
expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]);
|
||||
});
|
||||
|
||||
it("refreshes resource probes after MCP OAuth login completes", () => {
|
||||
const refreshServerDiagnostics = vi.fn();
|
||||
const handler = handlerForState(chatStateFixture(), { refreshServerDiagnostics });
|
||||
|
||||
handler.handleNotification({
|
||||
method: "mcpServer/oauthLogin/completed",
|
||||
params: { name: "github", success: true },
|
||||
} satisfies Extract<ServerNotification, { method: "mcpServer/oauthLogin/completed" }>);
|
||||
|
||||
expect(refreshServerDiagnostics).toHaveBeenCalledWith({ forceResourceProbes: true });
|
||||
expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("interactive server requests", () => {
|
||||
|
|
@ -1197,6 +1224,59 @@ describe("ChatInboundHandler", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("keeps Windows world-writable warnings in the message stream", () => {
|
||||
const handler = handlerForState(chatStateFixture());
|
||||
|
||||
handler.handleNotification({
|
||||
method: "windows/worldWritableWarning",
|
||||
params: { samplePaths: ["C:\\tmp\\open"], extraCount: 2, failedScan: false },
|
||||
} satisfies Extract<ServerNotification, { method: "windows/worldWritableWarning" }>);
|
||||
|
||||
expect(chatStateMessageStreamItems(handler.currentState())).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "system",
|
||||
text: expect.stringContaining("windows/worldWritableWarning"),
|
||||
}),
|
||||
]);
|
||||
expect(chatStateMessageStreamItems(handler.currentState())[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining("open"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps failed Windows sandbox setup notices in the message stream", () => {
|
||||
const handler = handlerForState(chatStateFixture());
|
||||
|
||||
handler.handleNotification({
|
||||
method: "windowsSandbox/setupCompleted",
|
||||
params: { mode: "unelevated", success: false, error: "setup failed" },
|
||||
} satisfies Extract<ServerNotification, { method: "windowsSandbox/setupCompleted" }>);
|
||||
|
||||
expect(chatStateMessageStreamItems(handler.currentState())).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "system",
|
||||
text: expect.stringContaining("windowsSandbox/setupCompleted"),
|
||||
}),
|
||||
]);
|
||||
expect(chatStateMessageStreamItems(handler.currentState())[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
text: expect.stringContaining("setup failed"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("suppresses successful Windows sandbox setup notices", () => {
|
||||
const handler = handlerForState(chatStateFixture());
|
||||
|
||||
handler.handleNotification({
|
||||
method: "windowsSandbox/setupCompleted",
|
||||
params: { mode: "unelevated", success: true, error: null },
|
||||
} satisfies Extract<ServerNotification, { method: "windowsSandbox/setupCompleted" }>);
|
||||
|
||||
expect(chatStateMessageStreamItems(handler.currentState())).toEqual([]);
|
||||
});
|
||||
|
||||
it("clears all active-thread scoped state when the active thread is archived", () => {
|
||||
let state = activeRunningState();
|
||||
state = chatStateWith(state, { runtime: { active: { model: "gpt-5.5" } } });
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ServerNotification, ServerRequest } from "../../../../../src/app-server/connection/rpc-messages";
|
||||
import { routeServerRequest } from "../../../../../src/app-server/server-requests";
|
||||
|
|
@ -6,6 +8,7 @@ import {
|
|||
planChatNotification,
|
||||
} from "../../../../../src/features/chat/app-server/inbound/notification-plan";
|
||||
import {
|
||||
IGNORED_SERVER_NOTIFICATION_METHODS,
|
||||
ROUTED_SERVER_NOTIFICATION_METHODS_BY_ROUTE_KIND,
|
||||
routeServerNotification,
|
||||
} from "../../../../../src/features/chat/app-server/inbound/notification-routing";
|
||||
|
|
@ -23,6 +26,14 @@ describe("chat inbound routing", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("keeps generated app-server notifications explicitly routed or ignored", () => {
|
||||
const routed = flattenedMethods(ROUTED_SERVER_NOTIFICATION_METHODS_BY_ROUTE_KIND);
|
||||
const ignored = [...IGNORED_SERVER_NOTIFICATION_METHODS];
|
||||
|
||||
expect(intersection(routed, ignored)).toEqual([]);
|
||||
expect([...new Set([...routed, ...ignored])].sort()).toEqual(generatedServerNotificationMethods());
|
||||
});
|
||||
|
||||
it("routes turn-scoped app-server messages for the active scope", () => {
|
||||
const notification = {
|
||||
method: "turn/completed",
|
||||
|
|
@ -187,7 +198,7 @@ describe("chat inbound routing", () => {
|
|||
expectNotificationRouteKind(notification, kind);
|
||||
});
|
||||
|
||||
it("leaves unhandled app-server notifications explicit", () => {
|
||||
it("keeps ignored app-server notifications explicit", () => {
|
||||
const route = routeServerNotification(
|
||||
{
|
||||
method: "account/updated",
|
||||
|
|
@ -196,7 +207,7 @@ describe("chat inbound routing", () => {
|
|||
activeScope,
|
||||
);
|
||||
|
||||
expect(route.kind).toBe("unhandled");
|
||||
expect(route.kind).toBe("ignored");
|
||||
});
|
||||
|
||||
it("routes unknown runtime notifications to the unhandled fallback", () => {
|
||||
|
|
@ -232,8 +243,8 @@ describe("chat inbound routing", () => {
|
|||
{ name: "turn moderation metadata", notification: turnModerationMetadataNotification },
|
||||
{ name: "terminal interaction", notification: terminalInteractionNotification },
|
||||
{ name: "model verification", notification: modelVerificationNotification },
|
||||
])("still scopes unhandled turn notification $name", ({ notification }) => {
|
||||
expectNotificationRouteKind(notification("thread-active", "turn-active"), "unhandled");
|
||||
])("still scopes ignored turn notification $name", ({ notification }) => {
|
||||
expectNotificationRouteKind(notification("thread-active", "turn-active"), "ignored");
|
||||
expectNotificationRouteKind(notification("thread-other", "turn-active"), "inactive");
|
||||
expectNotificationRouteKind(notification("thread-active", "turn-other"), "inactive");
|
||||
});
|
||||
|
|
@ -241,8 +252,8 @@ describe("chat inbound routing", () => {
|
|||
it.each([
|
||||
{ name: "thread status changed", notification: threadStatusChangedNotification },
|
||||
{ name: "thread closed", notification: threadClosedNotification },
|
||||
])("still scopes unhandled thread lifecycle notification $name", ({ notification }) => {
|
||||
expectNotificationRouteKind(notification("thread-active"), "unhandled");
|
||||
])("still scopes ignored thread lifecycle notification $name", ({ notification }) => {
|
||||
expectNotificationRouteKind(notification("thread-active"), "ignored");
|
||||
expectNotificationRouteKind(notification("thread-other"), "inactive");
|
||||
});
|
||||
|
||||
|
|
@ -435,6 +446,26 @@ function sortedMethods(methodsByRouteKind: Record<string, readonly string[]>): R
|
|||
return Object.fromEntries(Object.entries(methodsByRouteKind).map(([kind, methods]) => [kind, [...methods].sort()]));
|
||||
}
|
||||
|
||||
function flattenedMethods(methodsByRouteKind: Record<string, readonly string[]>): string[] {
|
||||
return Object.values(methodsByRouteKind).flat().sort();
|
||||
}
|
||||
|
||||
function intersection(left: readonly string[], right: readonly string[]): string[] {
|
||||
const rightSet = new Set(right);
|
||||
return left.filter((method) => rightSet.has(method)).sort();
|
||||
}
|
||||
|
||||
function generatedServerNotificationMethods(): string[] {
|
||||
const generated = readFileSync(path.join(process.cwd(), "src/generated/app-server/ServerNotification.ts"), "utf8");
|
||||
return [...generated.matchAll(/"method": "([^"]+)"/g)]
|
||||
.map((match) => {
|
||||
const method = match[1];
|
||||
if (!method) throw new Error("Expected generated notification method match.");
|
||||
return method;
|
||||
})
|
||||
.sort();
|
||||
}
|
||||
|
||||
function threadArchivedNotification(threadId = "thread-active"): ServerNotification {
|
||||
return {
|
||||
method: "thread/archived",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { emptyRuntimeConfigSnapshot } from "../../../src/domain/runtime/config";
|
|||
import { createServerDiagnostics } from "../../../src/domain/server/diagnostics";
|
||||
import type { SharedServerMetadata } from "../../../src/domain/server/metadata";
|
||||
import type { Thread } from "../../../src/domain/threads/model";
|
||||
import type { CodexChatHost } from "../../../src/features/chat/host/runtime";
|
||||
import type { CodexChatHost } from "../../../src/features/chat/host/environment";
|
||||
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
|
||||
import type { ThreadCatalogEvent } from "../../../src/workspace/thread-catalog";
|
||||
import { notices } from "../../mocks/obsidian";
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ChatResumeWorkTracker, transitionRestoredThreadLifecycle } from "../../../src/features/chat/application/lifecycle";
|
||||
import { createChatViewDeferredTasks } from "../../../src/features/chat/host/lifecycle";
|
||||
import { createChatViewDeferredTasks } from "../../../src/features/chat/host/deferred-tasks";
|
||||
import { ConnectionWorkTracker } from "../../../src/shared/lifecycle/connection-work";
|
||||
|
||||
describe("createChatViewDeferredTasks", () => {
|
||||
|
|
|
|||
|
|
@ -812,6 +812,8 @@ class FakeSelectionRewriteClient implements SelectionRewriteClient {
|
|||
this.disconnected = true;
|
||||
}
|
||||
|
||||
async deleteThread(): Promise<void> {}
|
||||
|
||||
async listModels(): Promise<ModelListResponse> {
|
||||
return this.modelList;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
|
||||
import { VIEW_TYPE_CODEX_PANEL, VIEW_TYPE_CODEX_THREADS } from "../src/constants";
|
||||
import type { Thread } from "../src/domain/threads/model";
|
||||
import type { CodexChatHost } from "../src/features/chat/host/runtime";
|
||||
import type { CodexChatHost } from "../src/features/chat/host/environment";
|
||||
import type { CodexChatView } from "../src/features/chat/host/view";
|
||||
import type CodexPanelPlugin from "../src/main";
|
||||
import { DEFAULT_SETTINGS } from "../src/settings/model";
|
||||
|
|
|
|||
Loading…
Reference in a new issue