Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/proud-horses-chat.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 7 additions & 1 deletion packages/dashboard/src/chat-project-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,10 +90,16 @@ export function getOrCreateScopedChatManager(
store: TaskStore,
chatStore: ChatStore,
pluginRunner?: ConstructorParameters<typeof ChatManager>[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;
}
Comment thread
plarson marked this conversation as resolved.
const agentStore = new AgentStore({ rootDir: store.getFusionDir() });
const manager = new ChatManager(
chatStore,
Expand Down
8 changes: 8 additions & 0 deletions packages/dashboard/src/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof buildSessionSkillContextSync>[3] {
return this.pluginRunner?.getPluginSkills
? (this.pluginRunner as unknown as Parameters<typeof buildSessionSkillContextSync>[3])
Expand Down
5 changes: 4 additions & 1 deletion packages/dashboard/src/routes/register-chat-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,10 @@ 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 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;
Expand Down
5 changes: 5 additions & 0 deletions packages/engine/src/project-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
9 changes: 9 additions & 0 deletions packages/engine/src/runtimes/in-process-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,18 @@ 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");
});
Comment thread
plarson marked this conversation as resolved.

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/,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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();
});
});

Expand Down
15 changes: 8 additions & 7 deletions plugins/fusion-plugin-hermes-runtime/src/cli-spawn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 stdoutMatch = SESSION_ID_RE.exec(cleanedStdout);
const match = stdoutMatch ?? SESSION_ID_RE.exec(cleanedCombined);

Comment thread
plarson marked this conversation as resolved.
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 bodyRaw = stdoutMatch ? cleanedStdout.slice(0, stdoutMatch.index) : cleanedStdout;
const body = stripChrome(bodyRaw);

return { body, sessionId };
Expand Down
17 changes: 15 additions & 2 deletions plugins/fusion-plugin-hermes-runtime/src/runtime-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,12 @@ export class HermesRuntimeAdapter implements AgentRuntime {
}

async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
const messages: unknown[] = [];
const session: HermesStreamSession = {
model: undefined,
systemPrompt: options.systemPrompt,
messages: [],
messages,
state: { messages },
apiKey: undefined,
Comment thread
plarson marked this conversation as resolved.
thinkingLevel: undefined,
sessionId: "",
Expand Down Expand Up @@ -82,11 +84,22 @@ export class HermesRuntimeAdapter implements AgentRuntime {
const promptWithContext = resumeId
? prompt
: `${session.fusedSystemPrompt}\n\nUser request:\n${prompt}`;
const result = await invokeHermesCli(promptWithContext, this.settings, resumeId);
const userMessage = { role: "user", content: prompt };
session.messages.push(userMessage);
let result: Awaited<ReturnType<typeof invokeHermesCli>>;
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) {
Comment thread
greptile-apps[bot] marked this conversation as resolved.
session.callbacks.onText?.(result.body);
}
Expand Down
4 changes: 4 additions & 0 deletions plugins/fusion-plugin-hermes-runtime/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading