mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-16 05:03:26 +00:00
refactor(hooks): split session events into individual typed events
Major changes: - Replace monolithic SessionEvent with reason discriminator with individual event types: session_start, session_before_switch, session_switch, session_before_new, session_new, session_before_branch, session_branch, session_before_compact, session_compact, session_shutdown - Each event has dedicated result type (SessionBeforeSwitchResult, etc.) - HookHandler type now allows bare return statements (void in return type) - HookAPI.on() has proper overloads for each event with correct typing Additional fixes: - AgentSession now always subscribes to agent in constructor (was only subscribing when external subscribe() called, breaking internal handlers) - Standardize on undefined over null throughout codebase - HookUIContext methods return undefined instead of null - SessionManager methods return undefined instead of null - Simplify hook exports to 'export type * from types.js' - Add detailed JSDoc for skipConversationRestore vs cancel - Fix createBranchedSession to rebuild index in persist mode - newSession() now returns the session file path Updated all example hooks, tests, and emission sites to use new event types.
This commit is contained in:
parent
38d65dfe59
commit
d6283f99dc
43 changed files with 2129 additions and 640 deletions
|
|
@ -21,7 +21,7 @@ export class BashExecutionComponent extends Container {
|
|||
private command: string;
|
||||
private outputLines: string[] = [];
|
||||
private status: "running" | "complete" | "cancelled" | "error" = "running";
|
||||
private exitCode: number | null = null;
|
||||
private exitCode: number | undefined = undefined;
|
||||
private loader: Loader;
|
||||
private truncationResult?: TruncationResult;
|
||||
private fullOutputPath?: string;
|
||||
|
|
@ -90,13 +90,17 @@ export class BashExecutionComponent extends Container {
|
|||
}
|
||||
|
||||
setComplete(
|
||||
exitCode: number | null,
|
||||
exitCode: number | undefined,
|
||||
cancelled: boolean,
|
||||
truncationResult?: TruncationResult,
|
||||
fullOutputPath?: string,
|
||||
): void {
|
||||
this.exitCode = exitCode;
|
||||
this.status = cancelled ? "cancelled" : exitCode !== 0 && exitCode !== null ? "error" : "complete";
|
||||
this.status = cancelled
|
||||
? "cancelled"
|
||||
: exitCode !== 0 && exitCode !== undefined && exitCode !== null
|
||||
? "error"
|
||||
: "complete";
|
||||
this.truncationResult = truncationResult;
|
||||
this.fullOutputPath = fullOutputPath;
|
||||
|
||||
|
|
|
|||
|
|
@ -36,18 +36,18 @@ export class ModelSelectorComponent extends Container {
|
|||
private allModels: ModelItem[] = [];
|
||||
private filteredModels: ModelItem[] = [];
|
||||
private selectedIndex: number = 0;
|
||||
private currentModel: Model<any> | null;
|
||||
private currentModel?: Model<any>;
|
||||
private settingsManager: SettingsManager;
|
||||
private modelRegistry: ModelRegistry;
|
||||
private onSelectCallback: (model: Model<any>) => void;
|
||||
private onCancelCallback: () => void;
|
||||
private errorMessage: string | null = null;
|
||||
private errorMessage?: string;
|
||||
private tui: TUI;
|
||||
private scopedModels: ReadonlyArray<ScopedModelItem>;
|
||||
|
||||
constructor(
|
||||
tui: TUI,
|
||||
currentModel: Model<any> | null,
|
||||
currentModel: Model<any> | undefined,
|
||||
settingsManager: SettingsManager,
|
||||
modelRegistry: ModelRegistry,
|
||||
scopedModels: ReadonlyArray<ScopedModelItem>,
|
||||
|
|
|
|||
|
|
@ -67,14 +67,14 @@ export class InteractiveMode {
|
|||
private version: string;
|
||||
private isInitialized = false;
|
||||
private onInputCallback?: (text: string) => void;
|
||||
private loadingAnimation: Loader | null = null;
|
||||
private loadingAnimation: Loader | undefined = undefined;
|
||||
|
||||
private lastSigintTime = 0;
|
||||
private lastEscapeTime = 0;
|
||||
private changelogMarkdown: string | null = null;
|
||||
private changelogMarkdown: string | undefined = undefined;
|
||||
|
||||
// Streaming message tracking
|
||||
private streamingComponent: AssistantMessageComponent | null = null;
|
||||
private streamingComponent: AssistantMessageComponent | undefined = undefined;
|
||||
|
||||
// Tool execution tracking: toolCallId -> component
|
||||
private pendingTools = new Map<string, ToolExecutionComponent>();
|
||||
|
|
@ -92,22 +92,22 @@ export class InteractiveMode {
|
|||
private isBashMode = false;
|
||||
|
||||
// Track current bash execution component
|
||||
private bashComponent: BashExecutionComponent | null = null;
|
||||
private bashComponent: BashExecutionComponent | undefined = undefined;
|
||||
|
||||
// Track pending bash components (shown in pending area, moved to chat on submit)
|
||||
private pendingBashComponents: BashExecutionComponent[] = [];
|
||||
|
||||
// Auto-compaction state
|
||||
private autoCompactionLoader: Loader | null = null;
|
||||
private autoCompactionLoader: Loader | undefined = undefined;
|
||||
private autoCompactionEscapeHandler?: () => void;
|
||||
|
||||
// Auto-retry state
|
||||
private retryLoader: Loader | null = null;
|
||||
private retryLoader: Loader | undefined = undefined;
|
||||
private retryEscapeHandler?: () => void;
|
||||
|
||||
// Hook UI state
|
||||
private hookSelector: HookSelectorComponent | null = null;
|
||||
private hookInput: HookInputComponent | null = null;
|
||||
private hookSelector: HookSelectorComponent | undefined = undefined;
|
||||
private hookInput: HookInputComponent | undefined = undefined;
|
||||
|
||||
// Custom tools for custom rendering
|
||||
private customTools: Map<string, LoadedCustomTool>;
|
||||
|
|
@ -126,10 +126,10 @@ export class InteractiveMode {
|
|||
constructor(
|
||||
session: AgentSession,
|
||||
version: string,
|
||||
changelogMarkdown: string | null = null,
|
||||
changelogMarkdown: string | undefined = undefined,
|
||||
customTools: LoadedCustomTool[] = [],
|
||||
private setToolUIContext: (uiContext: HookUIContext, hasUI: boolean) => void = () => {},
|
||||
fdPath: string | null = null,
|
||||
fdPath: string | undefined = undefined,
|
||||
) {
|
||||
this.session = session;
|
||||
this.version = version;
|
||||
|
|
@ -350,7 +350,7 @@ export class InteractiveMode {
|
|||
await this.emitToolSessionEvent({
|
||||
entries,
|
||||
sessionFile: this.session.sessionFile,
|
||||
previousSessionFile: null,
|
||||
previousSessionFile: undefined,
|
||||
reason: "start",
|
||||
});
|
||||
|
||||
|
|
@ -395,10 +395,9 @@ export class InteractiveMode {
|
|||
this.chatContainer.addChild(new Spacer(1));
|
||||
}
|
||||
|
||||
// Emit session event
|
||||
// Emit session_start event
|
||||
await hookRunner.emit({
|
||||
type: "session",
|
||||
reason: "start",
|
||||
type: "session_start",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -442,7 +441,7 @@ export class InteractiveMode {
|
|||
/**
|
||||
* Show a selector for hooks.
|
||||
*/
|
||||
private showHookSelector(title: string, options: string[]): Promise<string | null> {
|
||||
private showHookSelector(title: string, options: string[]): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
this.hookSelector = new HookSelectorComponent(
|
||||
title,
|
||||
|
|
@ -453,7 +452,7 @@ export class InteractiveMode {
|
|||
},
|
||||
() => {
|
||||
this.hideHookSelector();
|
||||
resolve(null);
|
||||
resolve(undefined);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -470,7 +469,7 @@ export class InteractiveMode {
|
|||
private hideHookSelector(): void {
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.editor);
|
||||
this.hookSelector = null;
|
||||
this.hookSelector = undefined;
|
||||
this.ui.setFocus(this.editor);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
|
@ -486,7 +485,7 @@ export class InteractiveMode {
|
|||
/**
|
||||
* Show a text input for hooks.
|
||||
*/
|
||||
private showHookInput(title: string, placeholder?: string): Promise<string | null> {
|
||||
private showHookInput(title: string, placeholder?: string): Promise<string | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
this.hookInput = new HookInputComponent(
|
||||
title,
|
||||
|
|
@ -497,7 +496,7 @@ export class InteractiveMode {
|
|||
},
|
||||
() => {
|
||||
this.hideHookInput();
|
||||
resolve(null);
|
||||
resolve(undefined);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -514,7 +513,7 @@ export class InteractiveMode {
|
|||
private hideHookInput(): void {
|
||||
this.editorContainer.clear();
|
||||
this.editorContainer.addChild(this.editor);
|
||||
this.hookInput = null;
|
||||
this.hookInput = undefined;
|
||||
this.ui.setFocus(this.editor);
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
|
@ -874,7 +873,7 @@ export class InteractiveMode {
|
|||
}
|
||||
this.pendingTools.clear();
|
||||
}
|
||||
this.streamingComponent = null;
|
||||
this.streamingComponent = undefined;
|
||||
this.footer.invalidate();
|
||||
}
|
||||
this.ui.requestRender();
|
||||
|
|
@ -920,12 +919,12 @@ export class InteractiveMode {
|
|||
case "agent_end":
|
||||
if (this.loadingAnimation) {
|
||||
this.loadingAnimation.stop();
|
||||
this.loadingAnimation = null;
|
||||
this.loadingAnimation = undefined;
|
||||
this.statusContainer.clear();
|
||||
}
|
||||
if (this.streamingComponent) {
|
||||
this.chatContainer.removeChild(this.streamingComponent);
|
||||
this.streamingComponent = null;
|
||||
this.streamingComponent = undefined;
|
||||
}
|
||||
this.pendingTools.clear();
|
||||
this.ui.requestRender();
|
||||
|
|
@ -964,7 +963,7 @@ export class InteractiveMode {
|
|||
// Stop loader
|
||||
if (this.autoCompactionLoader) {
|
||||
this.autoCompactionLoader.stop();
|
||||
this.autoCompactionLoader = null;
|
||||
this.autoCompactionLoader = undefined;
|
||||
this.statusContainer.clear();
|
||||
}
|
||||
// Handle result
|
||||
|
|
@ -1018,7 +1017,7 @@ export class InteractiveMode {
|
|||
// Stop loader
|
||||
if (this.retryLoader) {
|
||||
this.retryLoader.stop();
|
||||
this.retryLoader = null;
|
||||
this.retryLoader = undefined;
|
||||
this.statusContainer.clear();
|
||||
}
|
||||
// Show error only on final failure (success shows normal response)
|
||||
|
|
@ -1228,10 +1227,9 @@ export class InteractiveMode {
|
|||
private async shutdown(): Promise<void> {
|
||||
// Emit shutdown event to hooks
|
||||
const hookRunner = this.session.hookRunner;
|
||||
if (hookRunner?.hasHandlers("session")) {
|
||||
if (hookRunner?.hasHandlers("session_shutdown")) {
|
||||
await hookRunner.emit({
|
||||
type: "session",
|
||||
reason: "shutdown",
|
||||
type: "session_shutdown",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -1265,7 +1263,7 @@ export class InteractiveMode {
|
|||
|
||||
private cycleThinkingLevel(): void {
|
||||
const newLevel = this.session.cycleThinkingLevel();
|
||||
if (newLevel === null) {
|
||||
if (newLevel === undefined) {
|
||||
this.showStatus("Current model does not support thinking");
|
||||
} else {
|
||||
this.footer.updateState(this.session.state);
|
||||
|
|
@ -1277,7 +1275,7 @@ export class InteractiveMode {
|
|||
private async cycleModel(direction: "forward" | "backward"): Promise<void> {
|
||||
try {
|
||||
const result = await this.session.cycleModel(direction);
|
||||
if (result === null) {
|
||||
if (result === undefined) {
|
||||
const msg = this.session.scopedModels.length > 0 ? "Only one model in scope" : "Only one model available";
|
||||
this.showStatus(msg);
|
||||
} else {
|
||||
|
|
@ -1612,13 +1610,13 @@ export class InteractiveMode {
|
|||
// Stop loading animation
|
||||
if (this.loadingAnimation) {
|
||||
this.loadingAnimation.stop();
|
||||
this.loadingAnimation = null;
|
||||
this.loadingAnimation = undefined;
|
||||
}
|
||||
this.statusContainer.clear();
|
||||
|
||||
// Clear UI state
|
||||
this.pendingMessagesContainer.clear();
|
||||
this.streamingComponent = null;
|
||||
this.streamingComponent = undefined;
|
||||
this.pendingTools.clear();
|
||||
|
||||
// Switch session via AgentSession (emits hook and tool session events)
|
||||
|
|
@ -1874,7 +1872,7 @@ export class InteractiveMode {
|
|||
// Stop loading animation
|
||||
if (this.loadingAnimation) {
|
||||
this.loadingAnimation.stop();
|
||||
this.loadingAnimation = null;
|
||||
this.loadingAnimation = undefined;
|
||||
}
|
||||
this.statusContainer.clear();
|
||||
|
||||
|
|
@ -1884,7 +1882,7 @@ export class InteractiveMode {
|
|||
// Clear UI state
|
||||
this.chatContainer.clear();
|
||||
this.pendingMessagesContainer.clear();
|
||||
this.streamingComponent = null;
|
||||
this.streamingComponent = undefined;
|
||||
this.pendingTools.clear();
|
||||
|
||||
this.chatContainer.addChild(new Spacer(1));
|
||||
|
|
@ -1962,12 +1960,12 @@ export class InteractiveMode {
|
|||
}
|
||||
} catch (error) {
|
||||
if (this.bashComponent) {
|
||||
this.bashComponent.setComplete(null, false);
|
||||
this.bashComponent.setComplete(undefined, false);
|
||||
}
|
||||
this.showError(`Bash command failed: ${error instanceof Error ? error.message : "Unknown error"}`);
|
||||
}
|
||||
|
||||
this.bashComponent = null;
|
||||
this.bashComponent = undefined;
|
||||
this.ui.requestRender();
|
||||
}
|
||||
|
||||
|
|
@ -1987,7 +1985,7 @@ export class InteractiveMode {
|
|||
// Stop loading animation
|
||||
if (this.loadingAnimation) {
|
||||
this.loadingAnimation.stop();
|
||||
this.loadingAnimation = null;
|
||||
this.loadingAnimation = undefined;
|
||||
}
|
||||
this.statusContainer.clear();
|
||||
|
||||
|
|
@ -2039,7 +2037,7 @@ export class InteractiveMode {
|
|||
stop(): void {
|
||||
if (this.loadingAnimation) {
|
||||
this.loadingAnimation.stop();
|
||||
this.loadingAnimation = null;
|
||||
this.loadingAnimation = undefined;
|
||||
}
|
||||
this.footer.dispose();
|
||||
if (this.unsubscribe) {
|
||||
|
|
|
|||
|
|
@ -45,10 +45,9 @@ export async function runPrintMode(
|
|||
hookRunner.setAppendEntryHandler((customType, data) => {
|
||||
session.sessionManager.appendCustomEntry(customType, data);
|
||||
});
|
||||
// Emit session event
|
||||
// Emit session_start event
|
||||
await hookRunner.emit({
|
||||
type: "session",
|
||||
reason: "start",
|
||||
type: "session_start",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -59,7 +58,7 @@ export async function runPrintMode(
|
|||
await tool.onSession({
|
||||
entries,
|
||||
sessionFile: session.sessionFile,
|
||||
previousSessionFile: null,
|
||||
previousSessionFile: undefined,
|
||||
reason: "start",
|
||||
});
|
||||
} catch (_err) {
|
||||
|
|
|
|||
|
|
@ -51,17 +51,17 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
* Create a hook UI context that uses the RPC protocol.
|
||||
*/
|
||||
const createHookUIContext = (): HookUIContext => ({
|
||||
async select(title: string, options: string[]): Promise<string | null> {
|
||||
async select(title: string, options: string[]): Promise<string | undefined> {
|
||||
const id = crypto.randomUUID();
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingHookRequests.set(id, {
|
||||
resolve: (response: RpcHookUIResponse) => {
|
||||
if ("cancelled" in response && response.cancelled) {
|
||||
resolve(null);
|
||||
resolve(undefined);
|
||||
} else if ("value" in response) {
|
||||
resolve(response.value);
|
||||
} else {
|
||||
resolve(null);
|
||||
resolve(undefined);
|
||||
}
|
||||
},
|
||||
reject,
|
||||
|
|
@ -89,17 +89,17 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
});
|
||||
},
|
||||
|
||||
async input(title: string, placeholder?: string): Promise<string | null> {
|
||||
async input(title: string, placeholder?: string): Promise<string | undefined> {
|
||||
const id = crypto.randomUUID();
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingHookRequests.set(id, {
|
||||
resolve: (response: RpcHookUIResponse) => {
|
||||
if ("cancelled" in response && response.cancelled) {
|
||||
resolve(null);
|
||||
resolve(undefined);
|
||||
} else if ("value" in response) {
|
||||
resolve(response.value);
|
||||
} else {
|
||||
resolve(null);
|
||||
resolve(undefined);
|
||||
}
|
||||
},
|
||||
reject,
|
||||
|
|
@ -144,10 +144,9 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
hookRunner.setAppendEntryHandler((customType, data) => {
|
||||
session.sessionManager.appendCustomEntry(customType, data);
|
||||
});
|
||||
// Emit session event
|
||||
// Emit session_start event
|
||||
await hookRunner.emit({
|
||||
type: "session",
|
||||
reason: "start",
|
||||
type: "session_start",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -159,7 +158,7 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
await tool.onSession({
|
||||
entries,
|
||||
sessionFile: session.sessionFile,
|
||||
previousSessionFile: null,
|
||||
previousSessionFile: undefined,
|
||||
reason: "start",
|
||||
});
|
||||
} catch (_err) {
|
||||
|
|
|
|||
|
|
@ -65,12 +65,12 @@ export type RpcCommand =
|
|||
// ============================================================================
|
||||
|
||||
export interface RpcSessionState {
|
||||
model: Model<any> | null;
|
||||
model?: Model<any>;
|
||||
thinkingLevel: ThinkingLevel;
|
||||
isStreaming: boolean;
|
||||
isCompacting: boolean;
|
||||
queueMode: "all" | "one-at-a-time";
|
||||
sessionFile: string | null;
|
||||
sessionFile?: string;
|
||||
sessionId: string;
|
||||
autoCompactionEnabled: boolean;
|
||||
messageCount: number;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue