From 8d6c92ac2efd73e42b69700047e90bdf96b0fea7 Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Sun, 5 Jul 2026 17:46:58 -0700 Subject: [PATCH] fix: preserve Hermes runtime chat session state --- .../dashboard/src/routes/register-chat-routes.ts | 4 +++- packages/engine/src/project-engine.ts | 5 +++++ .../engine/src/runtimes/in-process-runtime.ts | 9 +++++++++ .../src/__tests__/cli-spawn.test.ts | 6 ++++++ .../fusion-plugin-hermes-runtime/src/cli-spawn.ts | 15 ++++++++------- .../src/runtime-adapter.ts | 6 +++++- plugins/fusion-plugin-hermes-runtime/src/types.ts | 4 ++++ 7 files changed, 40 insertions(+), 9 deletions(-) diff --git a/packages/dashboard/src/routes/register-chat-routes.ts b/packages/dashboard/src/routes/register-chat-routes.ts index 6fb09ce88e..8c1f7c0219 100644 --- a/packages/dashboard/src/routes/register-chat-routes.ts +++ b/packages/dashboard/src/routes/register-chat-routes.ts @@ -115,7 +115,9 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): } const projectStore = await getOrCreateProjectStore(projectId); const chatStore = getOrCreateScopedChatStore(projectStore); - return getOrCreateScopedChatManager(projectStore, chatStore, options?.pluginRunner); + const engine = options?.engineManager?.getEngine(projectId); + const pluginRunner = engine?.getPluginRunner?.() ?? options?.pluginRunner; + return getOrCreateScopedChatManager(projectStore, chatStore, pluginRunner); } function validateModelPair(modelProvider: unknown, modelId: unknown): { modelProvider?: string; modelId?: string } { let normalizedProvider: string | undefined; diff --git a/packages/engine/src/project-engine.ts b/packages/engine/src/project-engine.ts index 6e87f913c9..b741227e4d 100644 --- a/packages/engine/src/project-engine.ts +++ b/packages/engine/src/project-engine.ts @@ -1042,6 +1042,11 @@ export class ProjectEngine { return this.runtime.getChatStore(); } + /** Get the project-scoped PluginRunner (if initialized). */ + getPluginRunner() { + return this.runtime.getPluginRunner(); + } + attachChatStore(chatStore: NotificationChatStore): void { this.notificationService?.attachChatStore(chatStore); } diff --git a/packages/engine/src/runtimes/in-process-runtime.ts b/packages/engine/src/runtimes/in-process-runtime.ts index 92344fcf35..ecd66d01c9 100644 --- a/packages/engine/src/runtimes/in-process-runtime.ts +++ b/packages/engine/src/runtimes/in-process-runtime.ts @@ -1368,6 +1368,15 @@ export class InProcessRuntime return this.chatStore; } + /** + * Get the project-scoped PluginRunner (if initialized). + * Dashboard chat needs this runner, not the top-level PluginLoader, so + * runtime hints such as `hermes` can resolve plugin runtimes correctly. + */ + getPluginRunner(): PluginRunner | undefined { + return this.pluginRunner; + } + /** * Get the project's Scheduler instance. * @throws Error if runtime has not been started diff --git a/plugins/fusion-plugin-hermes-runtime/src/__tests__/cli-spawn.test.ts b/plugins/fusion-plugin-hermes-runtime/src/__tests__/cli-spawn.test.ts index d06d39e546..ff4c250d3b 100644 --- a/plugins/fusion-plugin-hermes-runtime/src/__tests__/cli-spawn.test.ts +++ b/plugins/fusion-plugin-hermes-runtime/src/__tests__/cli-spawn.test.ts @@ -224,6 +224,12 @@ describe("parseHermesOutput", () => { expect(result.body).toBe("line1\nline2"); }); + it("accepts session_id emitted on stderr while keeping stderr out of the body", () => { + const result = parseHermesOutput("OK\n", "session_id: 20260427_120000_abcd12\n"); + expect(result.sessionId).toBe("20260427_120000_abcd12"); + expect(result.body).toBe("OK"); + }); + it("throws when session_id line is missing", () => { expect(() => parseHermesOutput("some output without id", "stderr text")).toThrow( /missing session_id/, diff --git a/plugins/fusion-plugin-hermes-runtime/src/cli-spawn.ts b/plugins/fusion-plugin-hermes-runtime/src/cli-spawn.ts index b458dd9cd4..007ba5a592 100644 --- a/plugins/fusion-plugin-hermes-runtime/src/cli-spawn.ts +++ b/plugins/fusion-plugin-hermes-runtime/src/cli-spawn.ts @@ -309,18 +309,19 @@ function stripChrome(text: string): string { * Returns `{ body, sessionId }` on success or throws with a descriptive error. */ export function parseHermesOutput(rawStdout: string, rawStderr: string): HermesCliResult { - const cleaned = cleanText(rawStdout); - const match = SESSION_ID_RE.exec(cleaned); + const cleanedStdout = cleanText(rawStdout); + const cleanedCombined = cleanText([rawStdout, rawStderr].filter(Boolean).join("\n")); + const match = SESSION_ID_RE.exec(cleanedStdout) ?? SESSION_ID_RE.exec(cleanedCombined); if (!match) { - const combined = [rawStdout, rawStderr].filter(Boolean).join("\n"); - throw new Error(`hermes: missing session_id in output.\n${combined}`); + throw new Error(`hermes: missing session_id in output.\n${cleanedCombined}`); } const sessionId = match[1]!; - // Body is everything before the session_id line. - const sessionIdLineStart = cleaned.lastIndexOf("\nsession_id:"); - const bodyRaw = sessionIdLineStart >= 0 ? cleaned.slice(0, sessionIdLineStart) : cleaned; + // Body is stdout before the session_id line. Recent Hermes builds can emit + // the session_id marker on stderr, so do not treat stderr as assistant text. + const sessionIdLineStart = cleanedStdout.lastIndexOf("\nsession_id:"); + const bodyRaw = sessionIdLineStart >= 0 ? cleanedStdout.slice(0, sessionIdLineStart) : cleanedStdout; const body = stripChrome(bodyRaw); return { body, sessionId }; diff --git a/plugins/fusion-plugin-hermes-runtime/src/runtime-adapter.ts b/plugins/fusion-plugin-hermes-runtime/src/runtime-adapter.ts index 550a94fc03..472de4390e 100644 --- a/plugins/fusion-plugin-hermes-runtime/src/runtime-adapter.ts +++ b/plugins/fusion-plugin-hermes-runtime/src/runtime-adapter.ts @@ -51,10 +51,12 @@ export class HermesRuntimeAdapter implements AgentRuntime { } async createSession(options: AgentRuntimeOptions): Promise { + const messages: unknown[] = []; const session: HermesStreamSession = { model: undefined, systemPrompt: options.systemPrompt, - messages: [], + messages, + state: { messages }, apiKey: undefined, thinkingLevel: undefined, sessionId: "", @@ -82,12 +84,14 @@ export class HermesRuntimeAdapter implements AgentRuntime { const promptWithContext = resumeId ? prompt : `${session.fusedSystemPrompt}\n\nUser request:\n${prompt}`; + session.messages.push({ role: "user", content: prompt }); const result = await invokeHermesCli(promptWithContext, this.settings, resumeId); session.sessionId = result.sessionId; session.lastModelDescription = this.describeFromSettings(); if (result.body) { + session.messages.push({ role: "assistant", content: result.body }); session.callbacks.onText?.(result.body); } } diff --git a/plugins/fusion-plugin-hermes-runtime/src/types.ts b/plugins/fusion-plugin-hermes-runtime/src/types.ts index 55dabe2e4f..1b44d706b2 100644 --- a/plugins/fusion-plugin-hermes-runtime/src/types.ts +++ b/plugins/fusion-plugin-hermes-runtime/src/types.ts @@ -23,6 +23,10 @@ export interface HermesStreamSession { model: unknown; systemPrompt: string; messages: unknown[]; + state: { + messages: unknown[]; + errorMessage?: string; + }; apiKey: string | undefined; thinkingLevel: string | undefined; sessionId: string;