refactor(reasoning)!: remove Node-RED engine and integrations

BREAKING CHANGE: NODE_RED reasoning is no longer supported. Unsupported
mode values fall back to PROXY. PROXY and OPENAI remain available.
This commit is contained in:
2026-10-06 18:58:54 -03:00
parent 77b60622e9
commit e0304b2bdb
31 changed files with 129 additions and 1138 deletions
+2 -3
View File
@@ -115,7 +115,7 @@ by Git. The launcher serves source through Vite; it does not use `dist/` or
Tests cover session initialization, response sequencing, cumulative transcripts,
interruptions, queue polling, admission deadlines, cancellation, microphone-permission cancellation, tool
results, external reasoning and `LOG_ONLY`, late-response cleanup, the idle
results, external reasoning, late-response cleanup, the idle
timeout, settings, RAG, profiles, and sanitized Markdown/export behavior.
Launcher tests start temporary local children to check readiness, environment
forwarding, failures, and shutdown of complete process groups.
@@ -144,8 +144,7 @@ quick actions remain absent, matching their disabled state in the old UI.
Native speech stays at the current project's 16 kHz PCM rate. The backend
continues to own VAD, transcription, model inference, synthesis, upstream
credentials, and ApiPilot execution. Browser `SpeechSynthesis` is used only
when the configured external reasoning service returns an answer instead of
`log_only`.
when the configured external reasoning service returns an answer.
Session startup waits for audio initialization and `session.created`, then sends
`session.update` before microphone audio or the initial greeting. The speech
+1 -1
View File
@@ -79,7 +79,7 @@ export async function getTools(signal: AbortSignal, refresh = false) {
return result.data.tools;
}
export const reasoningSchema = z.object({ answer: z.string().default(""), log_only: z.boolean().default(false) });
export const reasoningSchema = z.object({ answer: z.string().default("") });
export const searchSchema = z.object({
answer: z.string().nullish(),
results: z.array(z.object({ title: z.string(), snippet: z.string().default(""), url: z.string() })).default([]),
+1 -1
View File
@@ -9,7 +9,7 @@ export const configSchema = z.object({
allowDirect: z.boolean().default(true),
s2sUrl: z.string().default(""),
primaryPrompt: z.string().default(""),
reasoningType: z.enum(["PROXY", "OPENAI", "NODE_RED"]).catch("PROXY"),
reasoningType: z.enum(["PROXY", "OPENAI"]).catch("PROXY"),
injectProfile: z.boolean().default(false),
debugPrompt: z.boolean().default(false),
startSessionMode: z.string().default(""),
+15 -4
View File
@@ -63,7 +63,7 @@ beforeEach(() => {
}
const pathname = new URL(path, "http://localhost").pathname;
const responses: Record<string, unknown> = {
"/api/config": { reasoningType: "NODE_RED", injectProfile: false, s2sUrl: "ws://localhost/v1/realtime" },
"/api/config": { reasoningType: "OPENAI", injectProfile: false, s2sUrl: "ws://localhost/v1/realtime" },
"/api/user/phone": { result: { name: "Ana" } },
"/api/user/reports": { count: 0 },
};
@@ -98,17 +98,28 @@ describe("voice application lifecycle", () => {
]);
});
it("keeps LOG_ONLY reasoning on the native voice path and preserves separate profile context", async () => {
it("renders an external answer and preserves separate profile context", async () => {
const client = await start();
client.emit("transcript", { id: "u1", role: "user", text: "Hola", partial: false });
pendingReasoning?.(new Response(JSON.stringify({ log_only: true, answer: "" })));
pendingReasoning?.(new Response(JSON.stringify({ answer: " Hola, Ana " })));
await vi.advanceTimersByTimeAsync(1);
const request = fetchMock.mock.calls.find(([path]) => path === "/api/reasoning");
expect(request?.[1]?.body).toContain('"phoneNumber":"1234"');
expect(request?.[1]?.body).toContain("Caller profile resolved");
expect(client.options.instructions).not.toContain("Caller profile resolved");
expect(client.renderExternalAnswer).toHaveBeenCalledWith("Hola, Ana");
expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual(["Hola", "Hola, Ana"]);
});
it.each(["PROXY", "UNSUPPORTED"])("keeps native reasoning for mode %s", async (reasoningType) => {
fetchMock.mockResolvedValueOnce(
new Response(JSON.stringify({ reasoningType, s2sUrl: "ws://localhost/v1/realtime" })),
);
const client = await start();
client.emit("transcript", { id: "u1", role: "user", text: "Hola", partial: false });
await vi.advanceTimersByTimeAsync(1);
expect(fetchMock.mock.calls.some(([path]) => path === "/api/reasoning")).toBe(false);
expect(client.renderExternalAnswer).not.toHaveBeenCalled();
expect(controller.getSnapshot().messages).toHaveLength(1);
expect(controller.getSnapshot().messages.map((message) => message.text)).toEqual(["Hola"]);
});
it("discards external reasoning that finishes after the session was stopped", async () => {
const client = await start();
+1 -1
View File
@@ -379,7 +379,7 @@ export class VoiceController {
},
this.requests.signal,
);
if (this.client !== client || result.log_only) {
if (this.client !== client) {
return;
}
const answer = result.answer.trim();