Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andClaude Opus 5.5 0273b6b614 fix(app): end bare URLs at CJK and fullwidth punctuation (#9799)
A bare URL that md4c does not autolink is linked by the text-link
pattern. That pattern stopped only at ASCII whitespace, quotes and
sentence punctuation, so "https://a.com、https://b.com!" became one
link, and "看这里:https://example.com/foo,还有" linked ",还有" too.
Opening the link went to a URL ending in the encoded punctuation.

CJK, fullwidth and typographic punctuation (、。,()【】「」“”…) now
ends the URL. CJK letters in paths still stay in the link.

Refs #9743

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 20:52:24 +00:00
paseo-bot[bot]andClaude Opus 5.5 8c88b83cd8 fix(server): preview the newest turn's reply in finished notifications (#9793)
A turn the provider starts on its own, such as Claude waking after a
background subagent, has no user message before its reply. The last
assistant message lookup joined every adjacent assistant row, so it
returned the previous turn's reply with the new one appended, and the
220-character notification preview showed only the old reply.

Stop the backward scan at the turn boundary so the lookup returns only
the newest turn's reply, while chunks of one turn are still joined.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 20:02:46 +00:00
tieoandClaude Opus 5.5 b55955541e fix(dictation): surface the daemon's stream error instead of a delivery timeout (#9784)
The daemon drops a dictation stream when its STT session errors and answers
every later chunk with dictation_stream_error. The app only listened for
acks, so finishing waited 30s and reported a generic timeout. The sender now
fails finish with the daemon's message, and the daemon logs the failure that
dropped the stream once instead of nothing.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 19:05:47 +00:00
paseo-bot[bot] 2c5f5a1485 test(e2e): keep the planted zombie until its shell has exec'd (#9808)
The daemon-lifecycle cleanup-failure test planted a zombie with
`sleep 0 & echo $!; exec sleep 600`. dash reaps any exited child after
a builtin, so when `sleep 0` exited before `echo` returned, the shell
reaped it and `ps -o stat= -p <pid>` failed. The child now reads fd 3
until the helper closes it, which it does only after the shell has
become `sleep`.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-15 18:38:16 +00:00
685e113358 Fail paseo agent wait when no agent matches the id (#9782)
* fix(cli): fail agent wait when the daemon resolved no agent

The daemon answers wait_for_finish with status "error" both when it could not
resolve the identifier to an agent (final: null) and when the agent it did
resolve finished in error (final: the agent payload). runWaitCommand handed
both to buildWaitResult, which returns a normal result row, so
`paseo agent wait <unknown id>` printed "Agent not found: ..." and exited 0
while every sibling command exits 1 with AGENT_NOT_FOUND (#9736).

Throw AGENT_NOT_FOUND when the state is error and the payload is null, and let
that command error through the inner catch instead of rewrapping it as
WAIT_FAILED. A genuine agent failure still carries its payload, so it keeps
printing its result row. The wire schema is unchanged.

* test(cli): cover the agent wait not-found mapping without module mocks

Move the not-found decision into buildWaitResult, which maps the daemon's
wait reply to the command result, and test that function directly with plain
reply objects instead of mocking the daemon client module. The e2e test in
tests/11-agent-wait.test.ts still covers the command against a real daemon.

Co-authored-by: Eapp1e <163811520+Eapp1e@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cli): keep daemon errors during agent wait reported as WAIT_FAILED

Map the wait reply to the result after the wait's try/catch, so the
AGENT_NOT_FOUND error leaves without the inner catch passing every coded
error through. Connection and RPC errors from waitForFinish are wrapped as
WAIT_FAILED again, as on main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Eapp1e <163811520+Eapp1e@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 17:54:27 +00:00
Huyvux12 28322f1519 fix(server): include local provider config when turn detection is local (#9766) (#9779) 2026-11-15 17:09:46 +00:00
16 changed files with 403 additions and 84 deletions
+2 -4
View File
@@ -156,10 +156,8 @@ 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 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.
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.
### 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"): Promise<string> {
async function psField(pid: number, field: "stat" | "lstart" | "comm"): Promise<string> {
const { stdout } = await execFileAsync("ps", ["-o", `${field}=`, "-p", String(pid)], {
env: { ...process.env, LC_ALL: "C" },
});
@@ -117,17 +117,22 @@ export interface UnclosableTerminalProgram {
export async function plantUnclosableTerminalProgram(
daemon: IsolatedHostDaemon,
): Promise<UnclosableTerminalProgram> {
const parent = spawn("/bin/sh", ["-c", "sleep 0 & echo $!; exec sleep 600"], {
stdio: ["ignore", "pipe", "ignore"],
// 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"],
});
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,56 +394,6 @@ 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,16 +499,11 @@ function createBottomAnchorControllerDriver(
cancelPendingAttempt();
setModeInternal("detached");
},
// 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.
// 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.
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,6 +562,34 @@ 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,9 +37,12 @@ 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.
const URL_CHARACTER = String.raw`[^\s<>()"'\x60]`;
// 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_GROUP = String.raw`\(${URL_CHARACTER}*\)`;
const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?]|${URL_GROUP})`;
const BARE_URL = String.raw`https?://(?:${URL_CHARACTER}|${URL_GROUP})*(?:[^\s<>()"'\x60.,;:!?${URL_BOUNDARY}]|${URL_GROUP})`;
const TEXT_PATTERN = createTextPattern(PROSE_PATH);
// One entry per distinct workspace forge remote; reuse keeps the engine's regex identity stable.
@@ -90,6 +90,16 @@ 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 () => {
@@ -314,6 +324,29 @@ 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,6 +8,10 @@ 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";
@@ -64,6 +68,7 @@ export class DictationStreamSender {
private startGeneration = 0;
private startPromise: Promise<void> | null = null;
private streamError: Error | null = null;
constructor(params: DictationStreamSenderParams) {
this.format = params.format;
@@ -88,10 +93,11 @@ export class DictationStreamSender {
}
this.clientCleanup = client.subscribeRawMessages((message) => {
if (message.type !== "dictation_stream_ack") {
return;
if (message.type === "dictation_stream_ack") {
this.handleAck(message);
} else if (message.type === "dictation_stream_error") {
this.handleStreamError(message);
}
this.handleAck(message);
});
}
@@ -127,6 +133,7 @@ export class DictationStreamSender {
this.acknowledgedSeq = -1;
this.streamReady = false;
this.startPromise = null;
this.streamError = null;
this.startGeneration += 1;
this.resolveDrainWaiters();
}
@@ -197,6 +204,7 @@ export class DictationStreamSender {
this.sendSeq = 0;
this.acknowledgedSeq = -1;
this.streamReady = false;
this.streamError = null;
const start = (async () => {
await client.startDictationStream(dictationId, this.format);
@@ -285,6 +293,25 @@ 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();
}
@@ -309,11 +336,13 @@ 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");
@@ -0,0 +1,49 @@
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",
});
});
});
+21 -6
View File
@@ -86,7 +86,12 @@ type WaitFinishState = Awaited<
ReturnType<Awaited<ReturnType<typeof connectToDaemon>>["waitForFinish"]>
>;
function buildWaitResult(args: {
/**
* 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: {
state: WaitFinishState;
resolvedAgentId: string;
recentActivity: string | null;
@@ -117,6 +122,13 @@ 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",
@@ -155,6 +167,7 @@ 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);
@@ -166,11 +179,7 @@ export async function runWaitCommand(
await client.close();
return {
type: "single",
data: buildWaitResult({ state, resolvedAgentId, recentActivity, timeoutLabel }),
schema: agentWaitSchema,
};
reply = { state, resolvedAgentId, recentActivity };
} catch (waitErr) {
await client.close().catch(() => {});
@@ -198,4 +207,10 @@ export async function runWaitCommand(
};
throw error;
}
return {
type: "single",
data: buildWaitResult({ ...reply, timeoutLabel }),
schema: agentWaitSchema,
};
}
+23
View File
@@ -20,6 +20,7 @@
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";
@@ -177,6 +178,28 @@ 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,6 +13487,51 @@ 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,23 +4049,33 @@ export class AgentManager {
private getLastAssistantMessageFromTimeline(
timeline: readonly AgentTimelineItem[],
): string | null {
return this.getLastAssistantMessageSegmentFromTimeline(timeline)?.text ?? null;
return (
this.getLastAssistantMessageSegmentFromTimeline(timeline.map((item) => ({ item })))?.text ??
null
);
}
private getLastAssistantMessageSegmentFromTimeline(
timeline: readonly AgentTimelineItem[],
timeline: readonly Pick<AgentTimelineRow, "item" | "turnId">[],
): { text: string; startsAtBeginning: boolean } | null {
// Collect the last contiguous assistant messages (Claude streams chunks)
// 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.
const chunks: string[] = [];
let replyTurnId: string | undefined;
let startsAtBeginning = false;
for (let i = timeline.length - 1; i >= 0; i--) {
const item = timeline[i];
const { item, turnId } = 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;
}
@@ -4081,8 +4091,9 @@ export class AgentManager {
}
private async getLastAssistantMessageFromStores(agentId: string): Promise<string | null> {
const liveTimeline = this.timelineStore.getItems(agentId);
const liveSegment = this.getLastAssistantMessageSegmentFromTimeline(liveTimeline);
const liveSegment = this.getLastAssistantMessageSegmentFromTimeline(
this.timelineStore.getRows(agentId),
);
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.failDictationStream(params.dictationId, "Dictation stream not started", true);
this.emitUnknownStreamError(params.dictationId);
return;
}
@@ -528,7 +528,17 @@ 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 },
@@ -543,6 +553,10 @@ 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,7 +87,12 @@ 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");
(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");
return (
localRequestedByFeature ||
@@ -168,6 +168,122 @@ 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: {