Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 03b7bf9a92 fix(app): keep delayed navigation behind user scroll intent 2026-11-15 18:56:43 +01:00
Mohamed Boudra a01098ed31 fix(app): preserve submit scroll through existing momentum
A prompt sent during an active gesture requested the bottom, but the older gesture cancelled that request when momentum ended away. Honor the still-sticky pending request on release while allowing a newer gesture to take ownership.
2026-11-15 18:45:57 +01:00
16 changed files with 84 additions and 403 deletions
+4 -2
View File
@@ -156,8 +156,10 @@ keeps following.
Following resumes only for a gesture that ends within 1 px of the end, either when the finger lifts
or when momentum stops. A release past the end counts: iOS bounces back to the end, and lines that
land during the bounce must keep scrolling in. A release anywhere short of the end stays detached
with the scroll-to-bottom pill, so the rows the user let go of never move.
land during the bounce must keep scrolling in. A release short of the end stays detached with the scroll-to-bottom pill unless you submitted
another prompt during that gesture. That submit requests the end after the gesture finishes;
a new drag after submitting takes ownership again. Rewind and tail-fetch completions do not
override a scroll away because they can arrive after the action that requested them.
### Draw distance
@@ -96,7 +96,7 @@ export async function listTerminalIds(daemon: IsolatedHostDaemon): Promise<strin
const execFileAsync = promisify(execFile);
async function psField(pid: number, field: "stat" | "lstart" | "comm"): Promise<string> {
async function psField(pid: number, field: "stat" | "lstart"): Promise<string> {
const { stdout } = await execFileAsync("ps", ["-o", `${field}=`, "-p", String(pid)], {
env: { ...process.env, LC_ALL: "C" },
});
@@ -117,22 +117,17 @@ export interface UnclosableTerminalProgram {
export async function plantUnclosableTerminalProgram(
daemon: IsolatedHostDaemon,
): Promise<UnclosableTerminalProgram> {
// The shell reaps any child that has exited whenever it finishes a builtin, so the program
// reads fd 3 until we close it, which happens only once the shell has become `sleep`.
const parent = spawn("/bin/sh", ["-c", "cat <&3 >/dev/null & echo $!; exec sleep 600 3<&-"], {
stdio: ["ignore", "pipe", "ignore", "pipe"],
const parent = spawn("/bin/sh", ["-c", "sleep 0 & echo $!; exec sleep 600"], {
stdio: ["ignore", "pipe", "ignore"],
});
parent.unref();
const release = () => {
parent.stdio[3]?.destroy();
if (parent.exitCode === null && parent.signalCode === null) parent.kill("SIGKILL");
};
try {
const pid = await new Promise<number>((resolve) => {
parent.stdout!.once("data", (chunk: Buffer) => resolve(Number(chunk.toString().trim())));
});
await expect.poll(() => psField(parent.pid!, "comm")).toMatch(/(^|\/)sleep$/);
parent.stdio[3]!.destroy();
await expect.poll(async () => (await psField(pid, "stat")).startsWith("Z")).toBe(true);
const gone = spawn(process.execPath, ["-e", ""], { stdio: "ignore" });
await new Promise((resolve) => gone.once("exit", resolve));
@@ -394,6 +394,56 @@ describe("bottom anchor controller driver", () => {
expect(harness.scrollAttempts).toHaveLength(0);
});
it("honors a submit made during a gesture when that gesture ends away", () => {
const harness = createDriverHarness({ isNearBottom: false });
harness.driver.beginUserScroll();
harness.driver.requestLocalAnchor({ agentId: "agent-1", reason: "message-sent" });
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toEqual([]);
harness.driver.endUserScroll({ isAtEnd: false });
harness.scheduler.flushAll();
expect(harness.context.measurementState.offsetY).toBe(
harness.context.measurementState.contentHeight -
harness.context.measurementState.viewportHeight,
);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "sticky-bottom",
pendingRequest: null,
});
});
it.each(["rewind", "jump-to-bottom"] as const)(
"lets scrolling away supersede a late %s request",
(reason) => {
const harness = createDriverHarness({ isNearBottom: false });
harness.driver.beginUserScroll();
harness.driver.requestLocalAnchor({ agentId: "agent-1", reason });
harness.driver.endUserScroll({ isAtEnd: false });
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toEqual([]);
expect(harness.driver.getSnapshot()).toMatchObject({
mode: "detached",
pendingRequest: null,
});
},
);
it("lets a new gesture supersede a submit requested during the previous gesture", () => {
const harness = createDriverHarness({ isNearBottom: false });
harness.driver.beginUserScroll();
harness.driver.requestLocalAnchor({ agentId: "agent-1", reason: "message-sent" });
harness.driver.endUserScroll({ isAtEnd: false });
harness.driver.beginUserScroll();
harness.driver.endUserScroll({ isAtEnd: false });
harness.scheduler.flushAll();
expect(harness.scrollAttempts).toEqual([]);
expect(harness.driver.getSnapshot()).toMatchObject({ mode: "detached", pendingRequest: null });
});
it("resumes following when a user scroll ends at the end", () => {
const harness = createDriverHarness({
transportBehavior: {
@@ -499,11 +499,16 @@ function createBottomAnchorControllerDriver(
cancelPendingAttempt();
setModeInternal("detached");
},
// Following resumes only for a gesture that ends at the end. Resuming from short of it would
// scroll the rows the finger just let go of, so a release above the end, or with output that
// arrived below it during the gesture, stays detached with the pill.
// A gesture ending short stays detached unless a newer prompt submission requested the end.
// Passive output must not move the rows the finger just let go of.
endUserScroll(params) {
isUserScrollActive = false;
// Submit arrives with the user's action; rewind and tail jumps can arrive after a fetch.
// A newer gesture would have detached again, so only a still-sticky submit owns release.
if (pendingRequest?.reason === "message-sent" && mode === "sticky-bottom") {
evaluate(false, "user_scroll_end");
return;
}
if (params.isAtEnd) {
if (mode === "detached") {
setModeInternal("sticky-bottom");
@@ -562,34 +562,6 @@ describe("bare URL sentence punctuation", () => {
expect(tree.children?.[0]?.children?.[0]?.attributes?.url).toBe("https://example.com/a.");
},
);
it.each([
["https://a.com、https://b.com!", ["https://a.com", "、", "https://b.com", "!"]],
["https://a.com(说明)以及", ["https://a.com", "(说明)以及"]],
["看这里:https://example.com/foo,还有", ["看这里:", "https://example.com/foo", ",还有"]],
["(https://example.com/a)", ["(", "https://example.com/a", ")"]],
["见【https://example.com/a】。", ["见【", "https://example.com/a", "】。"]],
["“https://example.com/a”…", ["“", "https://example.com/a", "”…"]],
[
"见https://zh.wikipedia.org/wiki/人工智能。",
["见", "https://zh.wikipedia.org/wiki/人工智能", "。"],
],
])("ends a bare URL at CJK and typographic punctuation: %s", async (source, expected) => {
const prepared = prepareMarkdownSource(source);
const tree = recognizeTextLinks(
await parseMarkdown(prepared.render(), { latexMath: false }),
prepared.linkRecognition.text,
prepared.linkRecognition.inlineCode,
);
const parts = (tree.children?.[0]?.children ?? []).map((node) =>
node.type === "Link" ? String(node.attributes?.url) : String(node.content),
);
expect(parts).toEqual(expected);
expect(toNativeLinkRecognition(prepared.linkRecognition).text).toMatchObject({
isDisabled: false,
isDefault: false,
});
});
});
it("requires workspace context for relative inline paths and escapes literal regex characters", () => {
@@ -37,12 +37,9 @@ function createTextPattern(path: string, forgeReferencePattern?: string): RegExp
// Matches bare URLs that MD4C does not autolink, like single-label or bracketed hosts.
// Quotes and unbalanced closing parentheses end the URL. Trailing sentence punctuation stays outside.
// CJK, fullwidth and typographic punctuation (、。,()【】「」“”…) also ends it; CJK letters do not.
const URL_BOUNDARY =
"\\u2018\\u2019\\u201C\\u201D\\u2026\\u3000-\\u3003\\u3008-\\u3011\\u3014-\\u301F\\uFF01-\\uFF0F\\uFF1A-\\uFF20\\uFF3B-\\uFF40\\uFF5B-\\uFF65";
const URL_CHARACTER = String.raw`[^\s<>()"'\x60${URL_BOUNDARY}]`;
const URL_CHARACTER = String.raw`[^\s<>()"'\x60]`;
const URL_GROUP = String.raw`\(${URL_CHARACTER}*\)`;
const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?${URL_BOUNDARY}]|${URL_GROUP})`;
const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?]|${URL_GROUP})`;
const TEXT_PATTERN = createTextPattern(PROSE_PATH);
// One entry per distinct workspace forge remote; reuse keeps the engine's regex identity stable.
@@ -90,16 +90,6 @@ class FakeDaemonClient {
cancelDictationStream(dictationId: string): void {
this.cancels.push(dictationId);
}
emitStreamError(dictationId: string, error: string): void {
const message = {
type: "dictation_stream_error",
payload: { dictationId, error, retryable: true },
} as SessionOutboundMessage;
for (const listener of this.rawMessageListeners) {
listener(message);
}
}
}
const tick = async () => {
@@ -324,29 +314,6 @@ describe("DictationStreamSender", () => {
await expect(finish).resolves.toEqual({ dictationId: "d1", text: "ok" });
});
it("fails finish with the daemon's stream error instead of waiting for acks", async () => {
const client = new FakeDaemonClient();
client.autoAck = false;
let nextId = 0;
const sender = new DictationStreamSender({
client,
format: "audio/pcm;rate=16000;bits=16",
createDictationId: () => `d${++nextId}`,
});
sender.enqueueSegment("seg0");
await tick();
client.emitStreamError("d1", "Local speech worker request timed out: commit");
sender.enqueueSegment("seg1");
await tick();
const finish = sender.finish(1);
await expect(promiseOutcome(finish, 1000)).resolves.toBe("rejected");
await expect(finish).rejects.toThrow("Local speech worker request timed out");
expect(client.finishes).toEqual([]);
expect(sender.getSegmentCount()).toBe(2);
});
it("does not replay long buffered native dictation in one synchronous burst", async () => {
const client = new FakeDaemonClient();
client.isConnected = false;
@@ -8,10 +8,6 @@ const DICTATION_DRAIN_IDLE_TIMEOUT_MS = 30_000;
const waitForNextFlushTurn = (): Promise<void> => new Promise((resolve) => setTimeout(resolve, 0));
type DictationStreamAckMessage = Extract<SessionOutboundMessage, { type: "dictation_stream_ack" }>;
type DictationStreamErrorMessage = Extract<
SessionOutboundMessage,
{ type: "dictation_stream_error" }
>;
interface DrainWaitResult {
kind: "signaled" | "timed-out";
@@ -68,7 +64,6 @@ export class DictationStreamSender {
private startGeneration = 0;
private startPromise: Promise<void> | null = null;
private streamError: Error | null = null;
constructor(params: DictationStreamSenderParams) {
this.format = params.format;
@@ -93,11 +88,10 @@ export class DictationStreamSender {
}
this.clientCleanup = client.subscribeRawMessages((message) => {
if (message.type === "dictation_stream_ack") {
this.handleAck(message);
} else if (message.type === "dictation_stream_error") {
this.handleStreamError(message);
if (message.type !== "dictation_stream_ack") {
return;
}
this.handleAck(message);
});
}
@@ -133,7 +127,6 @@ export class DictationStreamSender {
this.acknowledgedSeq = -1;
this.streamReady = false;
this.startPromise = null;
this.streamError = null;
this.startGeneration += 1;
this.resolveDrainWaiters();
}
@@ -204,7 +197,6 @@ export class DictationStreamSender {
this.sendSeq = 0;
this.acknowledgedSeq = -1;
this.streamReady = false;
this.streamError = null;
const start = (async () => {
await client.startDictationStream(dictationId, this.format);
@@ -293,25 +285,6 @@ export class DictationStreamSender {
this.resolveDrainWaiters();
}
// The daemon drops a stream after an error and answers later chunks with
// errors instead of acks, so waiting for delivery can only time out.
private handleStreamError(message: DictationStreamErrorMessage): void {
if (message.payload.dictationId !== this.dictationId || this.streamError) {
return;
}
this.streamError = new Error(message.payload.error);
this.resolveDrainWaiters();
}
private throwStreamError(): void {
const error = this.streamError;
if (!error) {
return;
}
this.invalidateStream();
throw error;
}
private invalidateStream(): void {
this.resetStreamForReplay();
}
@@ -336,13 +309,11 @@ export class DictationStreamSender {
private async waitForFlushDrain(finalSeq: number): Promise<void> {
while (this.hasPendingDelivery(finalSeq)) {
this.throwStreamError();
const client = this.client;
if (!client?.isConnected || !this.dictationId || !this.streamReady) {
throw new Error("Failed to flush dictation stream");
}
const result = await this.waitForDrainSignal();
this.throwStreamError();
if (result.kind === "timed-out") {
this.invalidateStream();
throw new Error("Timed out waiting for dictation stream delivery");
@@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { buildWaitResult } from "./wait.js";
const agentId = "11111111-1111-4111-8111-111111111111";
describe("buildWaitResult", () => {
it("reports an identifier the daemon could not resolve as a missing agent", () => {
expect(() =>
buildWaitResult({
state: {
status: "error",
final: null,
error: "Agent not found: does-not-exist",
lastMessage: null,
},
resolvedAgentId: "does-not-exist",
recentActivity: null,
timeoutLabel: null,
}),
).toThrow(
expect.objectContaining({
code: "AGENT_NOT_FOUND",
message: "Agent not found: does-not-exist",
details: "Use `paseo ls` to list available agents",
}),
);
});
it("still reports an agent that finished in error as a result", () => {
const result = buildWaitResult({
state: {
status: "error",
final: { id: agentId, lastError: "The provider returned 500" } as AgentSnapshotPayload,
error: "The provider returned 500",
lastMessage: null,
},
resolvedAgentId: agentId,
recentActivity: null,
timeoutLabel: null,
});
expect(result).toEqual({
agentId,
status: "error",
message: "The provider returned 500",
});
});
});
+6 -21
View File
@@ -86,12 +86,7 @@ type WaitFinishState = Awaited<
ReturnType<Awaited<ReturnType<typeof connectToDaemon>>["waitForFinish"]>
>;
/**
* Turn the daemon's wait reply into the command result. Throws AGENT_NOT_FOUND when the
* daemon resolved no agent: it answers an unknown identifier with an error and no agent
* payload, while an agent that finished in error always comes with its payload.
*/
export function buildWaitResult(args: {
function buildWaitResult(args: {
state: WaitFinishState;
resolvedAgentId: string;
recentActivity: string | null;
@@ -122,13 +117,6 @@ export function buildWaitResult(args: {
}
if (state.status === "error") {
if (state.final === null) {
throw {
code: "AGENT_NOT_FOUND",
message: state.error ?? `Agent not found: ${resolvedAgentId}`,
details: "Use `paseo ls` to list available agents",
} satisfies CommandError;
}
return {
agentId: resolvedAgentId,
status: "error",
@@ -167,7 +155,6 @@ export async function runWaitCommand(
const client = await connectToDaemon({ target: options.daemonTarget });
let reply: Omit<Parameters<typeof buildWaitResult>[0], "timeoutLabel">;
try {
try {
const state = await client.waitForFinish(agentIdArg, timeoutMs);
@@ -179,7 +166,11 @@ export async function runWaitCommand(
await client.close();
reply = { state, resolvedAgentId, recentActivity };
return {
type: "single",
data: buildWaitResult({ state, resolvedAgentId, recentActivity, timeoutLabel }),
schema: agentWaitSchema,
};
} catch (waitErr) {
await client.close().catch(() => {});
@@ -207,10 +198,4 @@ export async function runWaitCommand(
};
throw error;
}
return {
type: "single",
data: buildWaitResult({ ...reply, timeoutLabel }),
schema: agentWaitSchema,
};
}
-23
View File
@@ -20,7 +20,6 @@
import assert from "node:assert";
import { getAvailablePort } from "./helpers/network.ts";
import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts";
import { $ } from "zx";
import { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
@@ -178,28 +177,6 @@ try {
);
console.log("timeout option documents no default limit\n");
}
// Test 13: wait reports AGENT_NOT_FOUND for an unknown ID
{
console.log("Test 13: wait reports AGENT_NOT_FOUND for an unknown ID");
const daemon = await startTestDaemon();
try {
const result = await runPaseoCli(daemon, [
"wait",
"does-not-exist",
"--host",
`127.0.0.1:${daemon.port}`,
"--json",
]);
assert.notStrictEqual(result.exitCode, 0, "wait should fail for an unknown ID");
const { error } = JSON.parse(result.stderr);
assert.strictEqual(error.code, "AGENT_NOT_FOUND", result.stderr);
assert.match(error.details, /paseo ls/);
} finally {
await daemon.stop();
}
console.log("✓ wait reports AGENT_NOT_FOUND for an unknown ID\n");
}
} finally {
// Clean up temp directory
await rm(paseoHome, { recursive: true, force: true });
@@ -13487,51 +13487,6 @@ test.each(["live", "history", "accepted"] as const)(
},
);
test("the last assistant message is the newest turn's reply, not joined to the previous turn's", async () => {
const session = new TestAgentSession({ provider: "codex", cwd: process.cwd() });
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-last-reply-"));
const client = new (class extends TestAgentClient {
override async createSession(): Promise<AgentSession> {
return session;
}
})();
const manager = new AgentManager({ clients: { codex: client }, logger });
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const reply = (turnId: string, messageId: string, text: string) =>
session.pushEvent({
type: "timeline",
provider: "codex",
turnId,
item: { type: "assistant_message", messageId, text },
});
try {
session.pushEvent({ type: "turn_started", provider: "codex", turnId: "turn-1" });
reply("turn-1", "message-1", "Earlier status, ");
reply("turn-1", "message-1", "streamed in two chunks.");
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "turn-1" });
await vi.waitFor(async () =>
expect(await manager.getLastAssistantMessage(agent.id)).toBe(
"Earlier status, streamed in two chunks.",
),
);
// A turn the provider starts on its own, such as a background task waking the agent,
// has no user message between its reply and the previous turn's.
session.pushEvent({ type: "turn_started", provider: "codex", turnId: "turn-2" });
reply("turn-2", "message-2", "New status.");
session.pushEvent({ type: "turn_completed", provider: "codex", turnId: "turn-2" });
await vi.waitFor(() =>
expect(manager.getTimeline(agent.id).at(-1)).toMatchObject({ messageId: "message-2" }),
);
expect(await manager.getLastAssistantMessage(agent.id)).toBe("New status.");
} finally {
await manager.closeAgent(agent.id);
rmSync(workdir, { recursive: true, force: true });
}
});
test("reads the last durable assistant response without loading a stored-only child", async () => {
const store = new RecordingTimelineStore();
await store.appendCommitted("stored-child", {
@@ -4049,33 +4049,23 @@ export class AgentManager {
private getLastAssistantMessageFromTimeline(
timeline: readonly AgentTimelineItem[],
): string | null {
return (
this.getLastAssistantMessageSegmentFromTimeline(timeline.map((item) => ({ item })))?.text ??
null
);
return this.getLastAssistantMessageSegmentFromTimeline(timeline)?.text ?? null;
}
private getLastAssistantMessageSegmentFromTimeline(
timeline: readonly Pick<AgentTimelineRow, "item" | "turnId">[],
timeline: readonly AgentTimelineItem[],
): { text: string; startsAtBeginning: boolean } | null {
// Collect the last contiguous assistant messages of one turn (Claude streams chunks).
// A turn the provider starts on its own has no user message before its reply.
// Collect the last contiguous assistant messages (Claude streams chunks)
const chunks: string[] = [];
let replyTurnId: string | undefined;
let startsAtBeginning = false;
for (let i = timeline.length - 1; i >= 0; i--) {
const { item, turnId } = timeline[i];
const item = timeline[i];
if (item.type !== "assistant_message") {
if (chunks.length) {
break;
}
continue;
}
if (chunks.length === 0) {
replyTurnId = turnId;
} else if (turnId !== replyTurnId) {
break;
}
chunks.push(item.text);
startsAtBeginning = i === 0;
}
@@ -4091,9 +4081,8 @@ export class AgentManager {
}
private async getLastAssistantMessageFromStores(agentId: string): Promise<string | null> {
const liveSegment = this.getLastAssistantMessageSegmentFromTimeline(
this.timelineStore.getRows(agentId),
);
const liveTimeline = this.timelineStore.getItems(agentId);
const liveSegment = this.getLastAssistantMessageSegmentFromTimeline(liveTimeline);
if (!this.durableTimelineStore) {
return liveSegment?.text ?? null;
}
@@ -345,7 +345,7 @@ export class DictationStreamManager {
}): Promise<void> {
const state = this.streams.get(params.dictationId);
if (!state) {
this.emitUnknownStreamError(params.dictationId);
this.failDictationStream(params.dictationId, "Dictation stream not started", true);
return;
}
@@ -528,17 +528,7 @@ export class DictationStreamManager {
return path;
}
// Chunks keep arriving for a stream the daemon already dropped, one error
// per chunk; the failure that dropped it is logged where it happened.
private emitUnknownStreamError(dictationId: string): void {
this.emit({
type: "dictation_stream_error",
payload: { dictationId, error: "Dictation stream not started", retryable: true },
});
}
private failDictationStream(dictationId: string, error: string, retryable: boolean): void {
this.logger.warn({ dictationId, error, retryable }, "Dictation stream failed");
this.emit({
type: "dictation_stream_error",
payload: { dictationId, error, retryable },
@@ -553,10 +543,6 @@ export class DictationStreamManager {
const state = this.streams.get(dictationId);
const debugRecordingPath = await this.maybePersistDictationStreamAudio(dictationId);
if (!state || this.streams.get(dictationId) !== state) return;
this.logger.warn(
{ dictationId, error, retryable, ackSeq: state.ackSeq },
"Dictation stream failed",
);
this.emit({
type: "dictation_stream_error",
payload: {
@@ -87,12 +87,7 @@ function shouldIncludeLocalProviderConfig(params: {
params.providers.dictationStt.provider === "local") ||
(params.providers.voiceStt.enabled !== false &&
params.providers.voiceStt.provider === "local") ||
(params.providers.voiceTts.enabled !== false &&
params.providers.voiceTts.provider === "local") ||
// Turn detection is always local, so an enabled local turn detector
// needs the local provider config even when no speech site uses it.
(params.providers.voiceTurnDetection.enabled !== false &&
params.providers.voiceTurnDetection.provider === "local");
(params.providers.voiceTts.enabled !== false && params.providers.voiceTts.provider === "local");
return (
localRequestedByFeature ||
@@ -168,122 +168,6 @@ describe("resolveSpeechConfig", () => {
},
);
test("includes local provider config for local turn detection with OpenAI voice STT/TTS", () => {
const persisted = PersistedConfigSchema.parse({
features: {
dictation: { enabled: false },
voiceMode: {
enabled: true,
stt: { provider: "openai", apiKey: "test-key", model: "gpt-4o-transcribe" },
tts: { provider: "openai", apiKey: "test-key", model: "gpt-4o-mini-tts", voice: "alloy" },
},
},
});
const result = resolveSpeechConfig({
paseoHome: "/tmp/paseo-home",
env: {} as NodeJS.ProcessEnv,
persisted,
});
expect(result.speech.providers.voiceTurnDetection).toEqual({
provider: "local",
explicit: false,
enabled: true,
});
expect(result.speech.providers.voiceStt).toEqual({
provider: "openai",
explicit: true,
enabled: true,
});
expect(result.speech.providers.voiceTts).toEqual({
provider: "openai",
explicit: true,
enabled: true,
});
expect(result.speech.providers.dictationStt).toEqual({
provider: "local",
explicit: false,
enabled: false,
});
// Turn detection is always local, so the local provider config must be
// present even when no speech site uses the local provider and no
// models directory is configured; otherwise voice mode reports
// turn_detection_unavailable.
expect(result.speech.local).toEqual({
modelsDir: path.join("/tmp/paseo-home", "models", "local-speech"),
models: {
dictationStt: "parakeet-tdt-0.6b-v2-int8",
voiceStt: "parakeet-tdt-0.6b-v2-int8",
voiceTts: "kokoro-en-v0_19",
voiceTtsSpeakerId: 0,
},
});
});
test("includes local provider config for local turn detection with dictation on OpenAI", () => {
const persisted = PersistedConfigSchema.parse({
features: {
dictation: {
enabled: true,
stt: { provider: "openai", apiKey: "test-key", model: "gpt-4o-transcribe" },
},
voiceMode: {
enabled: true,
stt: { provider: "openai", apiKey: "test-key", model: "gpt-4o-transcribe" },
tts: { provider: "openai", apiKey: "test-key", model: "gpt-4o-mini-tts", voice: "alloy" },
},
},
});
const result = resolveSpeechConfig({
paseoHome: "/tmp/paseo-home",
env: {} as NodeJS.ProcessEnv,
persisted,
});
expect(result.speech.providers.voiceTurnDetection).toEqual({
provider: "local",
explicit: false,
enabled: true,
});
expect(result.speech.local).toEqual({
modelsDir: path.join("/tmp/paseo-home", "models", "local-speech"),
models: {
dictationStt: "parakeet-tdt-0.6b-v2-int8",
voiceStt: "parakeet-tdt-0.6b-v2-int8",
voiceTts: "kokoro-en-v0_19",
voiceTtsSpeakerId: 0,
},
});
});
test("omits local provider config when voice mode is off and no speech site is local", () => {
const persisted = PersistedConfigSchema.parse({
features: {
dictation: { enabled: false },
voiceMode: {
enabled: false,
stt: { provider: "openai", apiKey: "test-key", model: "gpt-4o-transcribe" },
tts: { provider: "openai", apiKey: "test-key", model: "gpt-4o-mini-tts", voice: "alloy" },
},
},
});
const result = resolveSpeechConfig({
paseoHome: "/tmp/paseo-home",
env: {} as NodeJS.ProcessEnv,
persisted,
});
expect(result.speech.providers.voiceTurnDetection).toEqual({
provider: "local",
explicit: false,
enabled: false,
});
expect(result.speech.local).toBeUndefined();
});
test("respects disabled dictation and voice mode feature flags", () => {
const persisted = PersistedConfigSchema.parse({
features: {