diff --git a/packages/adapters/hermes/gateway-ui-parser.cjs b/packages/adapters/hermes/gateway-ui-parser.cjs index 4654dbdef8..5d67ff4ce0 100644 --- a/packages/adapters/hermes/gateway-ui-parser.cjs +++ b/packages/adapters/hermes/gateway-ui-parser.cjs @@ -23,6 +23,31 @@ function asString(value) { return typeof value === "string" ? value : ""; } +// Pull the reasoning text out of a `reasoning.available` event payload. +// The exact field name is defined by the external Hermes gateway, so this +// checks the plausible field names (mirroring the fallback chain used by +// extractOutput() in gateway/server/execute.ts) and recurses one level into +// nested `data` / `payload` records. +function extractDirectReasoningText(data) { + return ( + asString(data.reasoning).trim() || + asString(data.reasoning_text).trim() || + asString(data.thinking).trim() || + asString(data.text).trim() || + asString(data.summary).trim() || + asString(data.content).trim() + ); +} + +function extractReasoningText(data) { + if (!data) return ""; + const direct = extractDirectReasoningText(data); + if (direct) return stripAnsi(direct); + const nested = asRecord(data.data) || asRecord(data.payload); + const nestedDirect = nested ? extractDirectReasoningText(nested) : ""; + return nestedDirect ? stripAnsi(nestedDirect) : ""; +} + function parseStdoutLine(line, ts) { const cleaned = stripAnsi(line); const trimmed = cleaned.trim(); @@ -41,7 +66,8 @@ function parseStdoutLine(line, ts) { return [{ kind: "stderr", ts, text: message }]; } if (eventName === "reasoning.available") { - return [{ kind: "thinking", ts, text: "Hermes reasoning available" }]; + const reasoning = extractReasoningText(data); + return [{ kind: "thinking", ts, text: reasoning || "Hermes reasoning available" }]; } return [{ kind: "system", ts, text: `Hermes event: ${eventName}` }]; } diff --git a/packages/adapters/hermes/src/gateway/ui/parse-stdout.test.ts b/packages/adapters/hermes/src/gateway/ui/parse-stdout.test.ts new file mode 100644 index 0000000000..aa0b354166 --- /dev/null +++ b/packages/adapters/hermes/src/gateway/ui/parse-stdout.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from "vitest"; +import { parseHermesGatewayStdoutLine } from "./parse-stdout.js"; + +const TS = "2026-07-08T12:00:00.000Z"; + +function eventLine(eventName: string, data: unknown): string { + return `[hermes-gateway:event] run=run-123 event=${eventName} data=${JSON.stringify(data)}`; +} + +describe("parseHermesGatewayStdoutLine — reasoning.available payload extraction", () => { + // This is the assertion that FAILS on the old hardcoded-placeholder code + // and PASSES once the real reasoning text is extracted from `data`. + it("uses the real reasoning text from data.text instead of the hardcoded placeholder", () => { + const result = parseHermesGatewayStdoutLine( + eventLine("reasoning.available", { text: "Considering three approaches to the cache invalidation bug." }), + TS, + ); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + kind: "thinking", + ts: TS, + text: "Considering three approaches to the cache invalidation bug.", + }); + expect(result[0]).not.toHaveProperty("text", "Hermes reasoning available"); + }); + + it("uses the real reasoning text from data.summary", () => { + const result = parseHermesGatewayStdoutLine( + eventLine("reasoning.available", { summary: "Weighing tradeoffs between two refactor strategies." }), + TS, + ); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + kind: "thinking", + ts: TS, + text: "Weighing tradeoffs between two refactor strategies.", + }); + }); + + it("recurses one level into a nested data.data record to find the reasoning text", () => { + const result = parseHermesGatewayStdoutLine( + eventLine("reasoning.available", { data: { text: "Nested reasoning payload from gateway wrapper." } }), + TS, + ); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + kind: "thinking", + ts: TS, + text: "Nested reasoning payload from gateway wrapper.", + }); + }); + + it("recurses one level into a nested data.payload record to find the reasoning text", () => { + const result = parseHermesGatewayStdoutLine( + eventLine("reasoning.available", { payload: { reasoning: "Nested via payload wrapper instead of data." } }), + TS, + ); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + kind: "thinking", + ts: TS, + text: "Nested via payload wrapper instead of data.", + }); + }); + + it("falls back to the placeholder when data has no recognizable text field (preserves bare-signal behavior)", () => { + const result = parseHermesGatewayStdoutLine( + eventLine("reasoning.available", { unrelatedField: 42 }), + TS, + ); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ kind: "thinking", ts: TS, text: "Hermes reasoning available" }); + }); + + it("falls back to the placeholder when data is entirely absent/unparseable", () => { + const result = parseHermesGatewayStdoutLine( + "[hermes-gateway:event] run=run-123 event=reasoning.available data=not-json", + TS, + ); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ kind: "thinking", ts: TS, text: "Hermes reasoning available" }); + }); +}); + +describe("parseHermesGatewayStdoutLine — regression guards for unrelated handlers", () => { + it("still yields an assistant delta part for message.delta", () => { + const result = parseHermesGatewayStdoutLine( + eventLine("message.delta", { delta: "Hello there" }), + TS, + ); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ kind: "assistant", ts: TS, text: "Hello there", delta: true }); + }); + + it("still yields a stdout part for a plain non-event line", () => { + const result = parseHermesGatewayStdoutLine("just a plain line of output", TS); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ kind: "stdout", ts: TS, text: "just a plain line of output" }); + }); +}); diff --git a/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts b/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts index 2837bea15e..894f923515 100644 --- a/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts +++ b/packages/adapters/hermes/src/gateway/ui/parse-stdout.ts @@ -27,6 +27,33 @@ function asString(value: unknown): string { return typeof value === "string" ? value : ""; } +/** + * Pull the reasoning text out of a `reasoning.available` event payload. + * The exact field name is defined by the external Hermes gateway, so this + * checks the plausible field names (mirroring the fallback chain used by + * extractOutput() in gateway/server/execute.ts) and recurses one level into + * nested `data` / `payload` records. + */ +function extractDirectReasoningText(data: Record): string { + return ( + asString(data.reasoning).trim() || + asString(data.reasoning_text).trim() || + asString(data.thinking).trim() || + asString(data.text).trim() || + asString(data.summary).trim() || + asString(data.content).trim() + ); +} + +function extractReasoningText(data: Record | null): string { + if (!data) return ""; + const direct = extractDirectReasoningText(data); + if (direct) return stripAnsi(direct); + const nested = asRecord(data.data) ?? asRecord(data.payload); + const nestedDirect = nested ? extractDirectReasoningText(nested) : ""; + return nestedDirect ? stripAnsi(nestedDirect) : ""; +} + export function parseHermesGatewayStdoutLine(line: string, ts: string): TranscriptEntry[] { const cleaned = stripAnsi(line); const trimmed = cleaned.trim(); @@ -45,7 +72,8 @@ export function parseHermesGatewayStdoutLine(line: string, ts: string): Transcri return [{ kind: "stderr", ts, text: message }]; } if (eventName === "reasoning.available") { - return [{ kind: "thinking", ts, text: "Hermes reasoning available" }]; + const reasoning = extractReasoningText(data); + return [{ kind: "thinking", ts, text: reasoning || "Hermes reasoning available" }]; } return [{ kind: "system", ts, text: `Hermes event: ${eventName}` }]; }