From 8d6c92ac2efd73e42b69700047e90bdf96b0fea7 Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Sun, 5 Jul 2026 17:46:58 -0700 Subject: [PATCH 1/2] 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; From a734d9f0b93fed31a1f1bab7c92aa96adb5575ee Mon Sep 17 00:00:00 2001 From: Phil Larson Date: Sun, 5 Jul 2026 18:13:47 -0700 Subject: [PATCH 2/2] fix: address Hermes runtime PR review feedback --- .changeset/proud-horses-chat.md | 7 +++++++ packages/dashboard/src/chat-project-services.ts | 8 +++++++- packages/dashboard/src/chat.ts | 8 ++++++++ .../dashboard/src/routes/register-chat-routes.ts | 5 +++-- .../src/__tests__/cli-spawn.test.ts | 6 ++++++ .../src/__tests__/runtime-adapter.test.ts | 13 +++++++++++++ .../fusion-plugin-hermes-runtime/src/cli-spawn.ts | 6 +++--- .../src/runtime-adapter.ts | 15 ++++++++++++--- 8 files changed, 59 insertions(+), 9 deletions(-) create mode 100644 .changeset/proud-horses-chat.md diff --git a/.changeset/proud-horses-chat.md b/.changeset/proud-horses-chat.md new file mode 100644 index 0000000000..f27ce913a5 --- /dev/null +++ b/.changeset/proud-horses-chat.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Preserve Hermes chat session state and project runtime routing more reliably. +category: fix +dev: Refreshes cached project chat plugin runners and hardens Hermes CLI session/error handling. diff --git a/packages/dashboard/src/chat-project-services.ts b/packages/dashboard/src/chat-project-services.ts index 1b5dd19cfe..20bfe0642a 100644 --- a/packages/dashboard/src/chat-project-services.ts +++ b/packages/dashboard/src/chat-project-services.ts @@ -90,10 +90,16 @@ export function getOrCreateScopedChatManager( store: TaskStore, chatStore: ChatStore, pluginRunner?: ConstructorParameters[3], + refreshPluginRunner = false, ): ChatManager { const key = store.getFusionDir(); const cached = scopedChatManagerCache.get(key); - if (cached) return cached; + if (cached) { + if (refreshPluginRunner && pluginRunner) { + cached.setPluginRunner(pluginRunner); + } + return cached; + } const agentStore = new AgentStore({ rootDir: store.getFusionDir() }); const manager = new ChatManager( chatStore, diff --git a/packages/dashboard/src/chat.ts b/packages/dashboard/src/chat.ts index cb5a781c20..ec5ece0339 100644 --- a/packages/dashboard/src/chat.ts +++ b/packages/dashboard/src/chat.ts @@ -1075,6 +1075,14 @@ export class ChatManager { private taskStore?: TaskStore, ) {} + /** + * FNXC:ProjectChatRuntime 2026-07-05-18:10: + * Project chat managers can be created before a project engine finishes booting. Refreshing the plugin runner after construction prevents early requests from permanently binding Hermes/runtime hints to the global fallback runner; callers must only refresh from a confirmed project runner so transient engine unavailability cannot downgrade a scoped manager. + */ + setPluginRunner(pluginRunner: ChatManager["pluginRunner"] | undefined): void { + this.pluginRunner = pluginRunner; + } + private getPluginRunnerForSkillSelection(): Parameters[3] { return this.pluginRunner?.getPluginSkills ? (this.pluginRunner as unknown as Parameters[3]) diff --git a/packages/dashboard/src/routes/register-chat-routes.ts b/packages/dashboard/src/routes/register-chat-routes.ts index 8c1f7c0219..8499558f2e 100644 --- a/packages/dashboard/src/routes/register-chat-routes.ts +++ b/packages/dashboard/src/routes/register-chat-routes.ts @@ -116,8 +116,9 @@ export function registerChatRoutes(ctx: ApiRoutesContext, deps: ChatRouteDeps): const projectStore = await getOrCreateProjectStore(projectId); const chatStore = getOrCreateScopedChatStore(projectStore); const engine = options?.engineManager?.getEngine(projectId); - const pluginRunner = engine?.getPluginRunner?.() ?? options?.pluginRunner; - return getOrCreateScopedChatManager(projectStore, chatStore, pluginRunner); + const projectPluginRunner = engine?.getPluginRunner?.(); + const pluginRunner = projectPluginRunner ?? options?.pluginRunner; + return getOrCreateScopedChatManager(projectStore, chatStore, pluginRunner, Boolean(projectPluginRunner)); } function validateModelPair(modelProvider: unknown, modelId: unknown): { modelProvider?: string; modelId?: string } { let normalizedProvider: string | undefined; 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 ff4c250d3b..b3bbccfa23 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 @@ -230,6 +230,12 @@ describe("parseHermesOutput", () => { expect(result.body).toBe("OK"); }); + it("strips a leading stdout session_id marker from an empty assistant body", () => { + const result = parseHermesOutput("session_id: 20260427_120000_abcd12\n", ""); + expect(result.sessionId).toBe("20260427_120000_abcd12"); + expect(result.body).toBe(""); + }); + 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/__tests__/runtime-adapter.test.ts b/plugins/fusion-plugin-hermes-runtime/src/__tests__/runtime-adapter.test.ts index e0df7e89d3..aeeb155cb5 100644 --- a/plugins/fusion-plugin-hermes-runtime/src/__tests__/runtime-adapter.test.ts +++ b/plugins/fusion-plugin-hermes-runtime/src/__tests__/runtime-adapter.test.ts @@ -68,6 +68,12 @@ describe("HermesRuntimeAdapter — promptWithFallback", () => { expect(onText).toHaveBeenCalledWith("hello from hermes"); // Session id is captured for next call expect(session.sessionId).toBe("20260427_120000_abc123"); + expect(session.messages).toEqual([ + { role: "user", content: "first prompt" }, + { role: "assistant", content: "hello from hermes" }, + ]); + expect(session.state.messages).toBe(session.messages); + expect(session.state.errorMessage).toBeUndefined(); }); it("passes captured session id as --resume on subsequent calls", async () => { @@ -94,6 +100,8 @@ describe("HermesRuntimeAdapter — promptWithFallback", () => { await expect(adapter.promptWithFallback(session, "p")).rejects.toThrow( /missing session_id/, ); + expect(session.messages).toEqual([]); + expect(session.state.errorMessage).toBe("hermes: missing session_id"); }); it("does NOT call onText when body is empty", async () => { @@ -110,6 +118,11 @@ describe("HermesRuntimeAdapter — promptWithFallback", () => { }); await adapter.promptWithFallback(session, "p"); expect(onText).not.toHaveBeenCalled(); + expect(session.messages).toEqual([ + { role: "user", content: "p" }, + { role: "assistant", content: "" }, + ]); + expect(session.state.errorMessage).toBeUndefined(); }); }); diff --git a/plugins/fusion-plugin-hermes-runtime/src/cli-spawn.ts b/plugins/fusion-plugin-hermes-runtime/src/cli-spawn.ts index 007ba5a592..20be6950c9 100644 --- a/plugins/fusion-plugin-hermes-runtime/src/cli-spawn.ts +++ b/plugins/fusion-plugin-hermes-runtime/src/cli-spawn.ts @@ -311,7 +311,8 @@ function stripChrome(text: string): string { export function parseHermesOutput(rawStdout: string, rawStderr: string): HermesCliResult { 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); + const stdoutMatch = SESSION_ID_RE.exec(cleanedStdout); + const match = stdoutMatch ?? SESSION_ID_RE.exec(cleanedCombined); if (!match) { throw new Error(`hermes: missing session_id in output.\n${cleanedCombined}`); @@ -320,8 +321,7 @@ export function parseHermesOutput(rawStdout: string, rawStderr: string): HermesC const sessionId = match[1]!; // 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 bodyRaw = stdoutMatch ? cleanedStdout.slice(0, stdoutMatch.index) : 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 472de4390e..5643d066af 100644 --- a/plugins/fusion-plugin-hermes-runtime/src/runtime-adapter.ts +++ b/plugins/fusion-plugin-hermes-runtime/src/runtime-adapter.ts @@ -84,14 +84,23 @@ 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); + const userMessage = { role: "user", content: prompt }; + session.messages.push(userMessage); + let result: Awaited>; + try { + result = await invokeHermesCli(promptWithContext, this.settings, resumeId); + session.state.errorMessage = undefined; + } catch (err) { + session.messages.pop(); + session.state.errorMessage = err instanceof Error ? err.message : String(err); + throw err; + } session.sessionId = result.sessionId; session.lastModelDescription = this.describeFromSettings(); + session.messages.push({ role: "assistant", content: result.body }); if (result.body) { - session.messages.push({ role: "assistant", content: result.body }); session.callbacks.onText?.(result.body); } }