mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-15 21:04:54 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0273b6b614 | ||
|
|
8c88b83cd8 | ||
|
|
b55955541e | ||
|
|
2c5f5a1485 | ||
|
|
685e113358 | ||
|
|
28322f1519 |
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user