mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
72d9cef1a8 | ||
|
|
03d23c577a | ||
|
|
0e2870c413 | ||
|
|
9f6c394a8e |
@@ -890,6 +890,131 @@ test("unavailable steer interrupts once and starts one replacement turn", async
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("steers a send that arrives while the previous turn is still starting", async () => {
|
||||||
|
const startHeld = deferred<void>();
|
||||||
|
class SlowStartSteeringSession extends SteeringTestSession {
|
||||||
|
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||||
|
await startHeld.promise;
|
||||||
|
return await super.startTurn(prompt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const session = new SlowStartSteeringSession({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
});
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-starting-"));
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new (class extends TestAgentClient {
|
||||||
|
override async createSession(): Promise<AgentSession> {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
let agentId: string | null = null;
|
||||||
|
try {
|
||||||
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||||
|
workspaceId: undefined,
|
||||||
|
});
|
||||||
|
agentId = agent.id;
|
||||||
|
const steerOptions = (clientMessageId: string) => ({
|
||||||
|
replaceRunning: true,
|
||||||
|
activeTurnBehavior: "steer" as const,
|
||||||
|
runOptions: { clientMessageId },
|
||||||
|
});
|
||||||
|
await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client"));
|
||||||
|
const second = startAgentRun(
|
||||||
|
manager,
|
||||||
|
agent.id,
|
||||||
|
"SECOND",
|
||||||
|
logger,
|
||||||
|
steerOptions("second-client"),
|
||||||
|
);
|
||||||
|
|
||||||
|
startHeld.resolve();
|
||||||
|
await expect(second).resolves.toEqual({ disposition: "steered" });
|
||||||
|
|
||||||
|
expect(session.startPrompts).toEqual(["FIRST"]);
|
||||||
|
expect(session.interruptCount).toBe(0);
|
||||||
|
expect(
|
||||||
|
manager
|
||||||
|
.fetchTimeline(agent.id, { limit: 0 })
|
||||||
|
.rows.filter((row) => row.item.type === "user_message")
|
||||||
|
.map((row) => ({
|
||||||
|
text: row.item.type === "user_message" ? row.item.text : null,
|
||||||
|
turnId: row.turnId,
|
||||||
|
})),
|
||||||
|
).toEqual([
|
||||||
|
{ text: "FIRST", turnId: "active-turn-1" },
|
||||||
|
{ text: "SECOND", turnId: "active-turn-1" },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
startHeld.resolve();
|
||||||
|
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
||||||
|
rmSync(workdir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("starts its own turn when the previous turn fails to start under a steer send", async () => {
|
||||||
|
const startHeld = deferred<void>();
|
||||||
|
class FailingFirstStartSession extends SteeringTestSession {
|
||||||
|
override async startTurn(prompt: AgentPromptInput): Promise<{ turnId: string }> {
|
||||||
|
if (prompt === "FIRST") {
|
||||||
|
this.startPrompts.push(prompt);
|
||||||
|
await startHeld.promise;
|
||||||
|
throw new Error("first start failed");
|
||||||
|
}
|
||||||
|
return await super.startTurn(prompt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const session = new FailingFirstStartSession({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
});
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-steer-failed-start-"));
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: {
|
||||||
|
codex: new (class extends TestAgentClient {
|
||||||
|
override async createSession(): Promise<AgentSession> {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
},
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
let agentId: string | null = null;
|
||||||
|
try {
|
||||||
|
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||||
|
workspaceId: undefined,
|
||||||
|
});
|
||||||
|
agentId = agent.id;
|
||||||
|
const steerOptions = (clientMessageId: string) => ({
|
||||||
|
replaceRunning: true,
|
||||||
|
activeTurnBehavior: "steer" as const,
|
||||||
|
runOptions: { clientMessageId },
|
||||||
|
});
|
||||||
|
await startAgentRun(manager, agent.id, "FIRST", logger, steerOptions("first-client"));
|
||||||
|
const second = startAgentRun(
|
||||||
|
manager,
|
||||||
|
agent.id,
|
||||||
|
"SECOND",
|
||||||
|
logger,
|
||||||
|
steerOptions("second-client"),
|
||||||
|
);
|
||||||
|
|
||||||
|
startHeld.resolve();
|
||||||
|
await expect(second).resolves.toEqual({ disposition: "turn_started" });
|
||||||
|
await vi.waitFor(() => expect(session.startPrompts).toEqual(["FIRST", "SECOND"]));
|
||||||
|
expect(session.interruptCount).toBe(0);
|
||||||
|
} finally {
|
||||||
|
startHeld.resolve();
|
||||||
|
if (agentId) await manager.closeAgent(agentId).catch(() => undefined);
|
||||||
|
rmSync(workdir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("orders an accepted steer before output emitted while acknowledgement is pending", async () => {
|
test("orders an accepted steer before output emitted while acknowledgement is pending", async () => {
|
||||||
const entered = deferred<void>();
|
const entered = deferred<void>();
|
||||||
const release = deferred<void>();
|
const release = deferred<void>();
|
||||||
|
|||||||
@@ -2448,7 +2448,7 @@ export class AgentManager {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (isStaleProviderSessionError(error)) {
|
if (isStaleProviderSessionError(error)) {
|
||||||
pendingRun.start = { status: "failed", error: error.message };
|
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: error.message });
|
||||||
agent.pendingReplacement = false;
|
agent.pendingReplacement = false;
|
||||||
if (!agent.activeForegroundTurnId) agent.lifecycle = "idle";
|
if (!agent.activeForegroundTurnId) agent.lifecycle = "idle";
|
||||||
this.runs.settleForegroundRun(agentId, pendingRun.token);
|
this.runs.settleForegroundRun(agentId, pendingRun.token);
|
||||||
@@ -2456,14 +2456,18 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
agent.pendingReplacement = false;
|
agent.pendingReplacement = false;
|
||||||
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
|
const errorMsg = error instanceof Error ? error.message : "Failed to start turn";
|
||||||
pendingRun.start = { status: "failed", error: errorMsg };
|
this.runs.settleForegroundStart(pendingRun, { status: "failed", error: errorMsg });
|
||||||
await this.handleStreamEvent(agent, {
|
try {
|
||||||
type: "turn_failed",
|
await this.handleStreamEvent(agent, {
|
||||||
provider: agent.provider,
|
type: "turn_failed",
|
||||||
error: errorMsg,
|
provider: agent.provider,
|
||||||
});
|
error: errorMsg,
|
||||||
this.finalizeForegroundTurn(agent);
|
});
|
||||||
this.runs.settleForegroundRun(agentId, pendingRun.token);
|
this.finalizeForegroundTurn(agent);
|
||||||
|
} finally {
|
||||||
|
// A steer waiting on this start is released only once the run is cleared.
|
||||||
|
this.runs.settleForegroundRun(agentId, pendingRun.token);
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2524,7 +2528,7 @@ export class AgentManager {
|
|||||||
agent.pendingReplacement = false;
|
agent.pendingReplacement = false;
|
||||||
}
|
}
|
||||||
const turnStartedAt = new Date();
|
const turnStartedAt = new Date();
|
||||||
pendingRun.start = { status: "started", turnId };
|
this.runs.settleForegroundStart(pendingRun, { status: "started", turnId });
|
||||||
agent.activeForegroundTurnId = turnId;
|
agent.activeForegroundTurnId = turnId;
|
||||||
this.openActiveTurn(agent, turnId, turnStartedAt);
|
this.openActiveTurn(agent, turnId, turnStartedAt);
|
||||||
agent.lifecycle = "running";
|
agent.lifecycle = "running";
|
||||||
@@ -2730,6 +2734,9 @@ export class AgentManager {
|
|||||||
prompt: AgentPromptInput,
|
prompt: AgentPromptInput,
|
||||||
options?: AgentSteerOptions,
|
options?: AgentSteerOptions,
|
||||||
): Promise<ActiveTurnSteerDispatchResult> {
|
): Promise<ActiveTurnSteerDispatchResult> {
|
||||||
|
// A turn that is still starting has no id to steer yet. Replacing it would drop its
|
||||||
|
// prompt, so wait until it is accepted (or fails) and steer the turn it became.
|
||||||
|
await this.runs.waitForForegroundStart(agentId);
|
||||||
const agent = this.requireSessionAgent(agentId);
|
const agent = this.requireSessionAgent(agentId);
|
||||||
const expectedTurnId = agent.activeForegroundTurnId ?? agent.activeTurnId;
|
const expectedTurnId = agent.activeForegroundTurnId ?? agent.activeTurnId;
|
||||||
if (!expectedTurnId) {
|
if (!expectedTurnId) {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
|
import { AgentRunState } from "./agent-run-state.js";
|
||||||
|
|
||||||
|
function track(promise: Promise<void>): { readonly resolved: boolean } {
|
||||||
|
const state = { resolved: false };
|
||||||
|
void (async () => {
|
||||||
|
await promise;
|
||||||
|
state.resolved = true;
|
||||||
|
})();
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function flushMicrotasks(): Promise<void> {
|
||||||
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("waitForForegroundStart", () => {
|
||||||
|
test("resolves once the starting turn has an id", async () => {
|
||||||
|
const runs = new AgentRunState();
|
||||||
|
const run = runs.createPendingRun("agent");
|
||||||
|
const wait = track(runs.waitForForegroundStart("agent"));
|
||||||
|
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(wait.resolved).toBe(false);
|
||||||
|
|
||||||
|
runs.settleForegroundStart(run, { status: "started", turnId: "turn-1" });
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(wait.resolved).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("waits for a failed start's run to be cleared", async () => {
|
||||||
|
const runs = new AgentRunState();
|
||||||
|
const run = runs.createPendingRun("agent");
|
||||||
|
const wait = track(runs.waitForForegroundStart("agent"));
|
||||||
|
|
||||||
|
runs.settleForegroundStart(run, { status: "failed", error: "start failed" });
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(wait.resolved).toBe(false);
|
||||||
|
expect(runs.hasPendingRun("agent")).toBe(true);
|
||||||
|
|
||||||
|
runs.settleForegroundRun("agent", run.token);
|
||||||
|
await flushMicrotasks();
|
||||||
|
expect(wait.resolved).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("resolves at once when no turn is starting", async () => {
|
||||||
|
const runs = new AgentRunState();
|
||||||
|
await expect(runs.waitForForegroundStart("agent")).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -10,14 +10,18 @@ export interface ForegroundTurnWaiter {
|
|||||||
resolveSettled: () => void;
|
resolveSettled: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ForegroundRunStart =
|
||||||
|
| { status: "pending" }
|
||||||
|
| { status: "started"; turnId: string }
|
||||||
|
| { status: "failed"; error: string };
|
||||||
|
|
||||||
export interface PendingForegroundRun {
|
export interface PendingForegroundRun {
|
||||||
token: string;
|
token: string;
|
||||||
kind: "foreground";
|
kind: "foreground";
|
||||||
stagedEvents: AgentStreamEvent[];
|
stagedEvents: AgentStreamEvent[];
|
||||||
start:
|
start: ForegroundRunStart;
|
||||||
| { status: "pending" }
|
turnStartedPromise: Promise<void>;
|
||||||
| { status: "started"; turnId: string }
|
resolveTurnStarted: () => void;
|
||||||
| { status: "failed"; error: string };
|
|
||||||
settled: boolean;
|
settled: boolean;
|
||||||
settledPromise: Promise<void>;
|
settledPromise: Promise<void>;
|
||||||
resolveSettled: () => void;
|
resolveSettled: () => void;
|
||||||
@@ -54,6 +58,28 @@ export class AgentRunState {
|
|||||||
return run?.kind === "foreground" ? run : null;
|
return run?.kind === "foreground" ? run : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
settleForegroundStart(
|
||||||
|
run: PendingForegroundRun,
|
||||||
|
start: Exclude<ForegroundRunStart, { status: "pending" }>,
|
||||||
|
): void {
|
||||||
|
run.start = start;
|
||||||
|
if (start.status === "started") {
|
||||||
|
run.resolveTurnStarted();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves once the agent's foreground run has a turn id or has ended. A failed start
|
||||||
|
* resolves only once its run is cleared, so callers never see a failed run still tracked.
|
||||||
|
*/
|
||||||
|
async waitForForegroundStart(agentId: string): Promise<void> {
|
||||||
|
const run = this.getPendingRun(agentId);
|
||||||
|
if (run?.start.status !== "pending") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await Promise.race([run.turnStartedPromise, run.settledPromise]);
|
||||||
|
}
|
||||||
|
|
||||||
hasPendingRun(agentId: string): boolean {
|
hasPendingRun(agentId: string): boolean {
|
||||||
return this.getPendingRun(agentId) !== null;
|
return this.getPendingRun(agentId) !== null;
|
||||||
}
|
}
|
||||||
@@ -277,10 +303,16 @@ export class ForegroundTurnStream {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createPendingForegroundRun(): PendingForegroundRun {
|
function createPendingForegroundRun(): PendingForegroundRun {
|
||||||
|
let resolveTurnStarted!: () => void;
|
||||||
|
const turnStartedPromise = new Promise<void>((resolvePromise) => {
|
||||||
|
resolveTurnStarted = resolvePromise;
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
...createTrackedRunState(),
|
...createTrackedRunState(),
|
||||||
kind: "foreground",
|
kind: "foreground",
|
||||||
start: { status: "pending" },
|
start: { status: "pending" },
|
||||||
|
turnStartedPromise,
|
||||||
|
resolveTurnStarted,
|
||||||
stagedEvents: [],
|
stagedEvents: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user