diff --git a/AGENTS.md b/AGENTS.md index 5881a28..bf526af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,7 +18,9 @@ src/ ├── tool.ts # Sequential Thinking core logic ├── tool-metadata.ts # Tool description and prompt content ├── schema.ts # Tool parameter validation schema -├── state.ts # Session state management +├── state.ts # State management interface and composite implementation +├── state-interface.ts # StateManagement and StateOperations interfaces +├── composite-state-management.ts # CompositeStateManagement implementation ├── config.ts # Plugin configuration processing ├── plugin.test.ts # Plugin integration tests ├── state.test.ts # State management unit tests @@ -36,7 +38,7 @@ index.ts ├─ config.ts → resolveConfig() ├─ tool.ts → SequentialThinkingTool class ├─ schema.ts → TOOL_PARAMETER_SCHEMA - ├─ state.ts → SessionStateManager class + ├─ state.ts → StateManagement interface and CompositeStateManagement implementation ├─ tool-metadata.ts → TOOL_DESCRIPTION & PREFER_SEQUENTIAL_THINKING_CONTEXT └─ hooks.ts → createHookHandlers() ├─ state.ts (for session management) @@ -53,7 +55,7 @@ index.ts | `src/schema.ts` | TypeBox schema for the public `sequential_thinking` tool input contract | | `src/tool-metadata.ts` | Tool description and prompt-injection system context for targeted models | | `src/tool.ts` | `SequentialThinkingTool` class — core thought processing with input validation & no mutation | -| `src/state.ts` | `SessionStateManager` class — encapsulated state lifecycle with SDK cleanup integration | +| `src/state.ts` | `StateManagement` interface and `CompositeStateManagement` class — encapsulated state lifecycle with SDK cleanup integration | | `src/hooks.ts` | SDK hook handlers — manages per-session state mapping and lifecycle events | | `src/plugin.ts` | Plugin orchestration — resolves config, registers tool, session extension, and SDK hooks | @@ -61,13 +63,13 @@ index.ts 1. **Registration Phase**: `registerSequentialThinkingPlugin()` registers tool and hooks 2. **Tool Execution**: `SequentialThinkingTool` processes thinking steps -3. **State Management**: `SessionStateManager` tracks thought history and branches +3. **State Management**: `CompositeStateManagement` tracks thought history and branches 4. **Hooks Processing**: `createHookHandlers()` handles session lifecycle events ### Key Classes - `SequentialThinkingTool`: Core thinking logic, handles `thought`, `branch`, `revision` operations -- `SessionStateManager`: Session state management, supports thought history and branching +- `CompositeStateManagement`: Session state management, supports thought history and branching through interface abstraction - `RunState`: Execution state type containing `thoughtHistory` and `branches` ## Code Style & Patterns diff --git a/README.md b/README.md index a0978ea..3c6f1e7 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,9 @@ index.ts ├─ config.ts → resolveConfig() ├─ tool.ts → SequentialThinkingTool class ├─ schema.ts → TOOL_PARAMETER_SCHEMA - ├─ state.ts → SessionStateManager class + ├─ state.ts → StateManagement interface and CompositeStateManagement implementation + ├─ state-interface.ts → StateManagement and StateOperations interfaces + └─ composite-state-management.ts → CompositeStateManagement class implementation ├─ tool-metadata.ts → TOOL_DESCRIPTION & PREFER_SEQUENTIAL_THINKING_CONTEXT └─ hooks.ts → createHookHandlers() ├─ state.ts (for session management) diff --git a/src/composite-state-management.test.ts b/src/composite-state-management.test.ts new file mode 100644 index 0000000..265ae8c --- /dev/null +++ b/src/composite-state-management.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { CompositeStateManagement } from "./composite-state-management.js"; +import { ThoughtData } from "./tool.js"; + +describe("CompositeStateManagement", () => { + let manager: CompositeStateManagement; + + beforeEach(() => { + manager = new CompositeStateManagement(); + }); + + it("should create a new instance", () => { + expect(manager).toBeInstanceOf(CompositeStateManagement); + }); + + it("should register tool calls and associate them with session keys", () => { + manager.registerToolCall("session1", "tool1"); + + // Check that the tool call is registered + const state = manager.getStateByToolCallId("tool1"); + expect(state).toBeDefined(); + expect(state?.thoughtHistory).toEqual([]); + expect(state?.branches).toEqual({}); + }); + + it("should get or create state for a session", () => { + const state = manager.getOrCreateState("session1"); + expect(state).toBeDefined(); + expect(state.thoughtHistory).toEqual([]); + expect(state.branches).toEqual({}); + + // Add some data to the state + const thought: ThoughtData = { + thought: "Test thought", + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }; + + manager.addThought("session1", thought); + + const updatedState = manager.getOrCreateState("session1"); + expect(updatedState.thoughtHistory).toHaveLength(1); + expect(updatedState.thoughtHistory[0].thought).toBe("Test thought"); + }); + + it("should handle branch operations", () => { + const thoughts: ThoughtData[] = [{ + thought: "Branch thought", + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }]; + + manager.addBranch("session1", "branch1", thoughts); + + const state = manager.getOrCreateState("session1"); + expect(state.branches["branch1"]).toBeDefined(); + expect(state.branches["branch1"]).toHaveLength(1); + expect(state.branches["branch1"][0].thought).toBe("Branch thought"); + + const branchIds = manager.getBranchIds("session1"); + expect(branchIds).toContain("branch1"); + }); + + it("should purge session state correctly", () => { + // Add some state + manager.registerToolCall("session1", "tool1"); + const thought: ThoughtData = { + thought: "Test thought", + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }; + manager.addThought("session1", thought); + + // Verify state exists + expect(manager.hasState("session1")).toBe(true); + expect(manager.getOrCreateState("session1").thoughtHistory).toHaveLength(1); + + // Purge the session + manager.purgeSessionState("session1"); + + // Verify state is cleared + expect(manager.hasState("session1")).toBe(false); + const newState = manager.getOrCreateState("session1"); + expect(newState.thoughtHistory).toEqual([]); + expect(newState.branches).toEqual({}); + }); + + it("should track state count", () => { + expect(manager.stateCount).toBe(0); + + manager.getOrCreateState("session1"); + expect(manager.stateCount).toBe(1); + + manager.getOrCreateState("session2"); + expect(manager.stateCount).toBe(2); + + manager.purgeSessionState("session1"); + expect(manager.stateCount).toBe(1); + }); + + it("should handle tool call mapping removal", () => { + manager.registerToolCall("session1", "tool1"); + expect(manager.getStateByToolCallId("tool1")).toBeDefined(); + + manager.removeToolCallMapping("tool1"); + expect(manager.getStateByToolCallId("tool1")).toBeUndefined(); + }); + + it("should provide cleanup callback", () => { + // Add some state and tool call mappings + manager.registerToolCall("session1", "tool1"); + const thought: ThoughtData = { + thought: "Test thought", + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }; + manager.addThought("session1", thought); + expect(manager.stateCount).toBe(1); + expect(manager.getStateByToolCallId("tool1")).toBeDefined(); + + // Execute cleanup + const cleanupFn = manager.getCleanupCallback(); + cleanupFn("reset"); + + // State should be cleared + expect(manager.stateCount).toBe(0); + expect(manager.getStateByToolCallId("tool1")).toBeUndefined(); + }); + + it("should reset all state correctly", () => { + // Add some state and tool call mappings + manager.registerToolCall("session1", "tool1"); + manager.registerToolCall("session2", "tool2"); + const thought1: ThoughtData = { + thought: "Test thought 1", + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }; + const thought2: ThoughtData = { + thought: "Test thought 2", + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }; + manager.addThought("session1", thought1); + manager.addThought("session2", thought2); + + // Verify state exists + expect(manager.stateCount).toBe(2); + expect(manager.getStateByToolCallId("tool1")).toBeDefined(); + expect(manager.getStateByToolCallId("tool2")).toBeDefined(); + + // Execute reset + manager.reset(); + + // All state should be cleared + expect(manager.stateCount).toBe(0); + expect(manager.getStateByToolCallId("tool1")).toBeUndefined(); + expect(manager.getStateByToolCallId("tool2")).toBeUndefined(); + }); + + it("should return a shallow copy of thought history that prevents array mutations", () => { + const thought: ThoughtData = { + thought: "Test thought", + thoughtNumber: 1, + totalThoughts: 1, + nextThoughtNeeded: false, + }; + manager.addThought("session1", thought); + + const history = manager.getThoughtHistory("session1"); + expect(history).toHaveLength(1); + expect(history[0].thought).toBe("Test thought"); + + // Modifying the returned array shouldn't affect internal state + history.push({ + thought: "Added externally", + thoughtNumber: 2, + totalThoughts: 2, + nextThoughtNeeded: false, + }); + + // The internal state should remain unchanged + const internalHistory = manager.getThoughtHistory("session1"); + expect(internalHistory).toHaveLength(1); + expect(internalHistory[0].thought).toBe("Test thought"); + }); +}); \ No newline at end of file diff --git a/src/composite-state-management.ts b/src/composite-state-management.ts new file mode 100644 index 0000000..58abdf5 --- /dev/null +++ b/src/composite-state-management.ts @@ -0,0 +1,203 @@ +import { RunState, ThoughtData } from './tool.js'; +import { StateManagement, StateOperations } from './state-interface.js'; + +/** + * Mapping service for associating tool calls with session keys + */ +class SessionMappingService { + private sessionKeyByToolCallId: Map; + + constructor() { + this.sessionKeyByToolCallId = new Map(); + } + + registerToolCall(sessionKey: string, toolCallId: string): void { + this.sessionKeyByToolCallId.set(toolCallId, sessionKey); + } + + getSessionKey(toolCallId: string): string | undefined { + return this.sessionKeyByToolCallId.get(toolCallId); + } + + removeToolCallMapping(toolCallId: string): void { + this.sessionKeyByToolCallId.delete(toolCallId); + } + + removeMappingsForSession(sessionKey: string): void { + for (const [toolCallId, sk] of this.sessionKeyByToolCallId) { + if (sk === sessionKey) { + this.sessionKeyByToolCallId.delete(toolCallId); + } + } + } + + clear(): void { + this.sessionKeyByToolCallId.clear(); + } + + getAllMappings(): Map { + return new Map(this.sessionKeyByToolCallId); + } +} + +/** + * Storage service for maintaining state data + */ +class StateStorageService { + private stateBySessionKey: Map; + + constructor() { + this.stateBySessionKey = new Map(); + } + + getOrCreateState(sessionKey: string): RunState { + if (!this.stateBySessionKey.has(sessionKey)) { + this.stateBySessionKey.set(sessionKey, { + thoughtHistory: [], + branches: {}, + }); + } + return this.stateBySessionKey.get(sessionKey)!; + } + + getState(sessionKey: string): RunState | undefined { + return this.stateBySessionKey.get(sessionKey); + } + + setState(sessionKey: string, state: RunState): void { + this.stateBySessionKey.set(sessionKey, state); + } + + hasState(sessionKey: string): boolean { + return this.stateBySessionKey.has(sessionKey); + } + + purgeSessionState(sessionKey: string): void { + this.stateBySessionKey.delete(sessionKey); + } + + getAllSessionKeys(): string[] { + return Array.from(this.stateBySessionKey.keys()); + } + + clear(): void { + this.stateBySessionKey.clear(); + } + + get stateCount(): number { + return this.stateBySessionKey.size; + } +} + +/** + * Lifecycle management service for handling cleanup operations + */ +class LifecycleManager { + private storageService: StateStorageService; + private mappingService: SessionMappingService; + + constructor(storageService: StateStorageService, mappingService: SessionMappingService) { + this.storageService = storageService; + this.mappingService = mappingService; + } + + getCleanupCallback(): (action: 'disable' | 'reset' | 'delete' | 'restart') => void { + return (action: 'disable' | 'reset' | 'delete' | 'restart') => { + this.storageService.clear(); + this.mappingService.clear(); + }; + } +} + +/** + * Composite implementation of StateManagement and StateOperations + */ +export class CompositeStateManagement implements StateManagement, StateOperations { + private mappingService: SessionMappingService; + private storageService: StateStorageService; + private lifecycleManager: LifecycleManager; + + constructor() { + this.mappingService = new SessionMappingService(); + this.storageService = new StateStorageService(); + this.lifecycleManager = new LifecycleManager(this.storageService, this.mappingService); + } + + registerToolCall(sessionKey: string, toolCallId: string): void { + this.mappingService.registerToolCall(sessionKey, toolCallId); + // Ensure state exists for the session + this.storageService.getOrCreateState(sessionKey); + } + + getOrCreateState(sessionKey: string): RunState { + return this.storageService.getOrCreateState(sessionKey); + } + + removeToolCallMapping(toolCallId: string): void { + this.mappingService.removeToolCallMapping(toolCallId); + } + + getStateByToolCallId(toolCallId: string): RunState | undefined { + const sessionKey = this.mappingService.getSessionKey(toolCallId); + if (!sessionKey) return undefined; + return this.storageService.getState(sessionKey); + } + + purgeSessionState(sessionKey: string): void { + // Clean up mappings that point to this session + this.mappingService.removeMappingsForSession(sessionKey); + + this.storageService.purgeSessionState(sessionKey); + } + + hasState(sessionKey: string): boolean { + return this.storageService.hasState(sessionKey); + } + + get stateCount(): number { + return this.storageService.stateCount; + } + + getCleanupCallback(): (action: 'disable' | 'reset' | 'delete' | 'restart') => void { + return this.lifecycleManager.getCleanupCallback(); + } + + reset(): void { + this.mappingService.clear(); + this.storageService.clear(); + } + + addThought(sessionKey: string, thought: ThoughtData): void { + const state = this.getOrCreateState(sessionKey); + + // Use local copy to avoid mutating input + const adjustedInput = { ...thought }; + if (adjustedInput.thoughtNumber > adjustedInput.totalThoughts) { + adjustedInput.totalThoughts = adjustedInput.thoughtNumber; + } + + state.thoughtHistory.push(adjustedInput); + + if (adjustedInput.branchFromThought && adjustedInput.branchId) { + if (!state.branches[adjustedInput.branchId]) { + state.branches[adjustedInput.branchId] = []; + } + state.branches[adjustedInput.branchId].push(adjustedInput); + } + } + + getThoughtHistory(sessionKey: string): ThoughtData[] { + const state = this.getOrCreateState(sessionKey); + return [...state.thoughtHistory]; // Return a shallow copy to prevent external array mutations (but not mutations of individual ThoughtData properties) + } + + addBranch(sessionKey: string, branchId: string, thoughts: ThoughtData[]): void { + const state = this.getOrCreateState(sessionKey); + state.branches[branchId] = [...thoughts]; + } + + getBranchIds(sessionKey: string): string[] { + const state = this.getOrCreateState(sessionKey); + return Object.keys(state.branches); + } +} \ No newline at end of file diff --git a/src/hooks.ts b/src/hooks.ts index e2fca42..38252e4 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -11,7 +11,7 @@ import type { } from "openclaw/plugin-sdk/types"; import { logger } from "../api.js"; import { resolveConfig, type SequentialThinkingConfig } from "./config.js"; -import { SessionStateManager } from "./state.js"; +import type { StateManagement } from "./state.js"; import { PREFER_SEQUENTIAL_THINKING_CONTEXT } from "./tool-metadata.js"; type HookConfigContext = { @@ -19,7 +19,7 @@ type HookConfigContext = { }; type HookHandlerDeps = { - manager: SessionStateManager; + manager: StateManagement; registrationConfig: SequentialThinkingConfig; toolName: string; }; @@ -121,7 +121,7 @@ function resolveHookConfig( } function purgeSessionState( - manager: SessionStateManager, + manager: StateManagement, sessionKey: string | undefined, hookName: string, toolName: string, @@ -136,7 +136,7 @@ function purgeSessionState( function createSessionPurgeHandler< TEvent, TContext extends { sessionKey?: string }, ->(manager: SessionStateManager, toolName: string, hookName: string) { +>(manager: StateManagement, toolName: string, hookName: string) { return async (_event: TEvent, ctx: TContext): Promise => { purgeSessionState(manager, ctx.sessionKey, hookName, toolName); }; diff --git a/src/plugin.ts b/src/plugin.ts index b16d91c..1beb819 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -2,7 +2,7 @@ import { type OpenClawPluginApi } from "../api.js"; import { resolveConfig } from "./config.js"; import { createHookHandlers } from "./hooks.js"; import { TOOL_PARAMETER_SCHEMA } from "./schema.js"; -import { SessionStateManager } from "./state.js"; +import { StateManagement, SessionStateManager } from "./state.js"; import { TOOL_DESCRIPTION } from "./tool-metadata.js"; import { SequentialThinkingTool, @@ -81,7 +81,7 @@ export function registerSequentialThinkingPlugin(api: OpenClawPluginApi): void { } function createSessionExtension( - manager: SessionStateManager, + manager: StateManagement, ): SessionExtensionRegistration { return { namespace: TOOL_NAME, diff --git a/src/state-interface.ts b/src/state-interface.ts new file mode 100644 index 0000000..5689357 --- /dev/null +++ b/src/state-interface.ts @@ -0,0 +1,76 @@ +import { RunState, ThoughtData } from './tool.js'; + +/** + * Interface for managing session state in the sequential thinking plugin + */ +export interface StateManagement { + /** + * Register a tool call with a session key + */ + registerToolCall(sessionKey: string, toolCallId: string): void; + + /** + * Get or create state for a session + */ + getOrCreateState(sessionKey: string): RunState; + + /** + * Remove tool call mapping + */ + removeToolCallMapping(toolCallId: string): void; + + /** + * Get state by tool call ID + */ + getStateByToolCallId(toolCallId: string): RunState | undefined; + + /** + * Purge all state for a session + */ + purgeSessionState(sessionKey: string): void; + + /** + * Check if state exists for a session + */ + hasState(sessionKey: string): boolean; + + /** + * Get the number of active states + */ + readonly stateCount: number; + + /** + * Get cleanup callback for session extension + */ + getCleanupCallback(): (action: 'disable' | 'reset' | 'delete' | 'restart') => void; + + /** + * Reset all state + */ + reset(): void; +} + +/** + * Additional interface for state operations + */ +export interface StateOperations { + /** + * Update state with a new thought + */ + addThought(sessionKey: string, thought: ThoughtData): void; + + /** + * Get current thought history + */ + getThoughtHistory(sessionKey: string): ThoughtData[]; + + /** + * Add a branch to the state + */ + addBranch(sessionKey: string, branchId: string, thoughts: ThoughtData[]): void; + + /** + * Get all branch IDs for a session + */ + getBranchIds(sessionKey: string): string[]; +} \ No newline at end of file diff --git a/src/state.ts b/src/state.ts index 2dfa3b5..9e62792 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,68 +1,16 @@ -import { RunState } from "./tool.js"; - -export class SessionStateManager { - private sessionKeyByToolCallId: Map; - private stateBySessionKey: Map; - - constructor() { - this.sessionKeyByToolCallId = new Map(); - this.stateBySessionKey = new Map(); - } - - registerToolCall(sessionKey: string, toolCallId: string): void { - this.sessionKeyByToolCallId.set(toolCallId, sessionKey); - this.getOrCreateState(sessionKey); - } - - getOrCreateState(sessionKey: string): RunState { - if (!this.stateBySessionKey.has(sessionKey)) { - this.stateBySessionKey.set(sessionKey, { - thoughtHistory: [], - branches: {}, - }); - } - return this.stateBySessionKey.get(sessionKey)!; - } - - removeToolCallMapping(toolCallId: string): void { - this.sessionKeyByToolCallId.delete(toolCallId); - } - - getStateByToolCallId(toolCallId: string): RunState | undefined { - const sessionKey = this.sessionKeyByToolCallId.get(toolCallId); - if (!sessionKey) return undefined; - return this.stateBySessionKey.get(sessionKey); - } - - purgeSessionState(sessionKey: string): void { - this.stateBySessionKey.delete(sessionKey); - for (const [toolCallId, sk] of this.sessionKeyByToolCallId) { - if (sk === sessionKey) { - this.sessionKeyByToolCallId.delete(toolCallId); - } - } - } - - hasState(sessionKey: string): boolean { - return this.stateBySessionKey.has(sessionKey); - } - - get stateCount(): number { - return this.stateBySessionKey.size; - } - - getCleanupCallback(): ( - action: "disable" | "reset" | "delete" | "restart", - ) => void { - return () => { - for (const [key] of this.stateBySessionKey) { - this.purgeSessionState(key); - } - }; - } - - reset(): void { - this.sessionKeyByToolCallId.clear(); - this.stateBySessionKey.clear(); - } -} +import { StateManagement } from "./state-interface.js"; +import { StateOperations } from "./state-interface.js"; +import { CompositeStateManagement } from "./composite-state-management.js"; + +// Export the interface for use in other modules +export type { StateManagement }; +export type { StateOperations }; + +// Export the concrete implementation for backward compatibility +export { CompositeStateManagement }; + +// For backward compatibility, we alias the new class as the old name +/** + * @deprecated Use CompositeStateManagement or StateManagement interface instead + */ +export class SessionStateManager extends CompositeStateManagement {}