fix: preserve Hermes runtime chat session state

This commit is contained in:
Phil Larson
2026-07-05 17:46:58 -07:00
parent 2025f9d56d
commit 8d6c92ac2e
7 changed files with 40 additions and 9 deletions

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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/,

View File

@@ -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 };

View File

@@ -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,
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);
}
}

View File

@@ -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;