Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 60a7625ad9 fix(pi): avoid delivery warnings for consumed image steers
Pi retains empty text entries in its steering queue after consuming image-only input. Require an unconsumed submission before reporting those entries as removed, and model that native behavior in the fake.
2026-11-03 22:03:52 +00:00
paseo-bot[bot] 2c3bd9e24b fix(pi): warn only for messages removed by Stop
Pi input extensions can rewrite a queued steer before it is consumed. Preserve the native clear_queue result instead of inferring removal from unmatched original text, so delivered rewrites never receive a removal warning.
2026-11-03 21:55:44 +00:00
paseo-bot[bot]andYEONGUK LEE 82d14d1e3c fix(pi): preserve steer warnings with async prompt preparation
Initialize foreground correlation only after prompt conversion completes and the turn still owns the session. This retains the stopped-steer warning without reading an uninitialized payload after integration with current main.

Co-authored-by: YEONGUK LEE <hashcode@kakao.com>
2026-11-03 21:42:57 +00:00
YEONGUK LEE 49b71c9473 refactor(pi): use explicit branches for image warning labels 2026-11-03 21:37:40 +00:00
YEONGUK LEE 860c55e59e fix(pi): distinguish foreground prompts and label image steers 2026-11-03 21:37:39 +00:00
YEONGUK LEE 19247ad6c7 fix(pi): warn when Stop clears queued steer messages 2026-11-03 21:37:39 +00:00
paseo-bot[bot] 4cd23a8f17 test(server): wait for worktree observation without racing filesystem setup (#8700)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-03 21:30:59 +00:00
3084f32852 Show effort controls for Claude settings models (#8699)
* fix(claude): show thinking options for models from settings.json

Models appended from Claude's settings.json were pushed to the catalog
without thinkingOptions, so the composer had no effort selector for them.
Resolve them like profile and additionalModels entries: manifest options for
built-in IDs, the standard Claude effort levels otherwise.

* docs(claude): clarify effort selection for settings models

Describe explicit effort selection without promising how every client initializes an unsaved value.

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
Co-authored-by: wangyahao <wangyahao@fxiaoke.com>

---------

Co-authored-by: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: wangyahao <wangyahao@fxiaoke.com>
2026-11-03 20:59:32 +00:00
12 changed files with 540 additions and 66 deletions
+2
View File
@@ -777,6 +777,8 @@ The built-in `claude` provider appends concrete model IDs from `~/.claude/settin
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
Paseo's effort selector does not inherit `effortLevel` from Claude's settings.json. Select an effort in Paseo to choose a specific value.
### Gotcha: `extends: "claude"` with third-party endpoints
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
@@ -365,7 +365,21 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
);
});
it("appends concrete models from Claude settings.json", async () => {
it("appends concrete models from Claude settings.json with thinking options", async () => {
const customModelThinkingOptions = [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High", isDefault: true },
{ id: "max", label: "Max" },
];
const fableThinkingOptions = [
{ id: "low", label: "Low" },
{ id: "medium", label: "Medium" },
{ id: "high", label: "High", isDefault: true },
{ id: "xhigh", label: "Extra High" },
{ id: "max", label: "Max" },
{ id: CLAUDE_ULTRACODE_THINKING_OPTION_ID, label: "Ultra Code" },
];
const configDir = await createClaudeConfigDir({
model: "us.anthropic.claude-opus-4-7[1m]",
env: {
@@ -393,42 +407,49 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
id: "us.anthropic.claude-opus-4-7[1m]",
label: "us.anthropic.claude-opus-4-7[1m]",
description: "From Claude settings.json model",
thinkingOptions: customModelThinkingOptions,
},
{
provider: "claude",
id: "openrouter/anthropic/claude-sonnet-4.5",
label: "openrouter/anthropic/claude-sonnet-4.5",
description: "From Claude settings.json env.ANTHROPIC_MODEL",
thinkingOptions: customModelThinkingOptions,
},
{
provider: "claude",
id: "ollama/qwen3-coder",
label: "ollama/qwen3-coder",
description: "From Claude settings.json env.ANTHROPIC_SMALL_FAST_MODEL",
thinkingOptions: customModelThinkingOptions,
},
{
provider: "claude",
id: "claude-fable-5-1[1M]",
label: "claude-fable-5-1[1M]",
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_FABLE_MODEL",
thinkingOptions: fableThinkingOptions,
},
{
provider: "claude",
id: "bedrock-opus-from-env",
label: "bedrock-opus-from-env",
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_OPUS_MODEL",
thinkingOptions: customModelThinkingOptions,
},
{
provider: "claude",
id: "glm-5.1",
label: "glm-5.1",
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_SONNET_MODEL",
thinkingOptions: customModelThinkingOptions,
},
{
provider: "claude",
id: "glm-5",
label: "glm-5",
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_HAIKU_MODEL",
thinkingOptions: customModelThinkingOptions,
},
]);
expect(models.filter((model) => model.id === "claude-fable-5-1[1M]")).toHaveLength(1);
@@ -83,7 +83,7 @@ export async function getClaudeModelsWithSettings(
const manifestModel = getClaudeModels(claudeCodeVersion).find(
(candidate) => candidate.id === model.id,
);
models.push({ ...manifestModel, ...model });
models.push(resolveConfiguredClaudeModel({ ...manifestModel, ...model }));
}
return models;
@@ -193,7 +193,8 @@ export class JsonlRpcProcess {
* Send a command whose goal an exited process has already met — stopping a turn,
* clearing a queue. Resolves once the child has exited instead of rejecting,
* because nothing is queued and nothing is running, which is what the caller
* asked for. Use `request` when the caller needs an answer from a live process.
* asked for. Returns the native reply when available, or undefined after exit.
* Use `request` when the caller requires an answer from a live process.
*
* Keyed on an observed exit rather than on `disposed`: `close()` disposes the
* transport before termination completes, and a child that outlives SIGKILL can
@@ -202,12 +203,12 @@ export class JsonlRpcProcess {
async requestStopWork(
command: { type: string; [key: string]: unknown },
timeoutMs?: number | null,
): Promise<void> {
): Promise<unknown> {
if (this.exited) {
return;
}
try {
await this.request(command, timeoutMs);
return await this.request(command, timeoutMs);
} catch (error) {
// A dying child breaks its stdin pipe a fraction before it emits `exit`, which
// fails this request and starts a termination. Wait for that termination to reach
@@ -307,8 +307,8 @@ class OmpCliRuntimeSession implements OmpRuntimeSession {
return this.process.request(OmpRpcCommandSchema.parse(command), timeoutMs);
}
private requestStopWork(command: OmpRpcCommand): Promise<void> {
return this.process.requestStopWork(OmpRpcCommandSchema.parse(command));
private async requestStopWork(command: OmpRpcCommand): Promise<void> {
await this.process.requestStopWork(OmpRpcCommandSchema.parse(command));
}
private emit(event: OmpRuntimeEvent): void {
@@ -2490,6 +2490,322 @@ describe("PiRpcAgentSession", () => {
});
describe("PiRpcAgentSession steering", () => {
test.each([false, true])(
"does not warn for Pi's stale consumed image queue entry (native echo: %s)",
async (echoEntry) => {
const { pi, session, events } = await createSession();
const native = pi.latestSession();
native.setModelResult = {
provider: "openai",
id: "gpt-4o",
input: ["text", "image"],
};
await session.setModel("openai/gpt-4o");
const { turnId } = await session.startTurn("work");
await session.steerActiveTurn(
[{ type: "image", data: ONE_BY_ONE_PNG_BASE64, mimeType: "image/png" }],
{ expectedTurnId: turnId },
);
if (echoEntry) {
native.finishSubmittedUserMessage({ id: "image-steer", parentId: null, text: "" });
} else {
native.emit({ type: "message_end", message: { role: "user", content: [] } });
}
await session.interrupt();
expect(events.timelineItems().filter((item) => item.type === "notification")).toEqual([]);
},
);
test("does not report a consumed steer rewritten by a Pi input extension", async () => {
const { pi, session, events } = await createSession();
pi.latestSession().steerTextTransform = (text) => text.replace("please ", "");
const { turnId } = await session.startTurn("work");
await session.steerActiveTurn("please continue", {
expectedTurnId: turnId,
clientMessageId: "client-steer-1",
});
pi.latestSession().finishSubmittedUserMessage({
id: "native-transformed-steer",
parentId: null,
text: "continue",
});
await session.interrupt();
expect(events.timelineItems().filter((item) => item.type === "notification")).toEqual([]);
});
test("names the rewritten steer that Pi actually removed", async () => {
const { pi, session, events } = await createSession();
pi.latestSession().steerTextTransform = (text) => text.replace("please ", "");
const { turnId } = await session.startTurn("work");
await session.steerActiveTurn("please continue", { expectedTurnId: turnId });
await session.interrupt();
expect(events.timelineItems()).toContainEqual({
type: "notification",
level: "warning",
message: "Not delivered (stopped): continue",
});
});
test("does not consume a queued steer when the outstanding foreground prompt has the same text", async () => {
const { pi, session, events } = await createSession();
const { turnId } = await session.startTurn("continue", {
clientMessageId: "client-prompt-1",
});
await session.steerActiveTurn("continue", {
expectedTurnId: turnId,
clientMessageId: "client-steer-1",
});
pi.latestSession().emit({ type: "agent_start" });
pi.latestSession().emit({ type: "turn_start" });
pi.latestSession().finishSubmittedUserMessage({
id: "native-prompt-1",
parentId: null,
text: "continue",
});
await session.interrupt();
expect(events.timelineItems()).toEqual([
{
type: "user_message",
text: "continue",
messageId: "native-prompt-1",
clientMessageId: "client-prompt-1",
},
{
type: "notification",
level: "warning",
message: "Not delivered (stopped): continue",
},
]);
});
test("keeps a consumed steer distinct from an identical foreground prompt", async () => {
const { pi, session, events } = await createSession();
const { turnId } = await session.startTurn("continue", { clientMessageId: "client-prompt-1" });
await session.steerActiveTurn("continue", {
expectedTurnId: turnId,
clientMessageId: "client-steer-1",
});
const native = pi.latestSession();
native.finishSubmittedUserMessage({ id: "native-prompt-1", parentId: null, text: "continue" });
native.finishSubmittedUserMessage({
id: "native-steer-1",
parentId: "native-prompt-1",
text: "continue",
});
await session.interrupt();
expect(events.timelineItems()).toEqual([
{
type: "user_message",
text: "continue",
messageId: "native-prompt-1",
clientMessageId: "client-prompt-1",
},
{
type: "user_message",
text: "continue",
messageId: "native-steer-1",
clientMessageId: "client-steer-1",
},
]);
});
test.each([1, 2])(
"labels a stopped image-only steer containing %i image(s)",
async (imageCount) => {
const { pi, session, events } = await createSession();
const native = pi.latestSession();
native.setModelResult = {
provider: "openai",
id: "gpt-4o",
name: "GPT-4o",
input: ["text", "image"],
};
await session.setModel("openai/gpt-4o");
const { turnId } = await session.startTurn([
{ type: "image", data: ONE_BY_ONE_PNG_BASE64, mimeType: "image/png" },
]);
await session.steerActiveTurn(
Array.from({ length: imageCount }, () => ({
type: "image" as const,
data: ONE_BY_ONE_PNG_BASE64,
mimeType: "image/png",
})),
{ expectedTurnId: turnId, clientMessageId: "client-steer-1" },
);
native.emit({ type: "message_end", message: { role: "user", content: [] } });
await session.interrupt();
expect(native.steerCalls).toEqual([{ message: "", imageCount }]);
expect(events.timelineItems()).toEqual([
{
type: "notification",
level: "warning",
message: `Not delivered (stopped): ${imageCount} ${imageCount === 1 ? "image" : "images"}`,
},
]);
},
);
test("reports an unconsumed steer as not delivered when Stop clears the queue", async () => {
const { session, events } = await createSession();
const { turnId } = await session.startTurn("work");
await session.steerActiveTurn("Reply STEER_CHECK_7391", {
expectedTurnId: turnId,
clientMessageId: "client-steer-1",
});
await session.interrupt();
expect(events.timelineItems()).toContainEqual({
type: "notification",
level: "warning",
message: "Not delivered (stopped): Reply STEER_CHECK_7391",
});
});
test("does not report a consumed steer before its native entry ID arrives", async () => {
const { pi, session, events } = await createSession();
const { turnId } = await session.startTurn("work");
await session.steerActiveTurn("already consumed", {
expectedTurnId: turnId,
clientMessageId: "client-steer-1",
});
pi.latestSession().emit({
type: "message_end",
message: { role: "user", content: "already consumed" },
});
await session.interrupt();
expect(events.timelineItems()).toEqual([]);
});
test("tracks native consumption before the steer RPC is acknowledged", async () => {
const { pi, session, events } = await createSession();
const { turnId } = await session.startTurn("work");
const native = pi.latestSession();
native.steer = async (message) => {
native.finishSubmittedUserMessage({ id: "native-entry-1", parentId: null, text: message });
};
await expect(
session.steerActiveTurn("already consumed", {
expectedTurnId: turnId,
clientMessageId: "client-steer-1",
}),
).resolves.toEqual({ status: "accepted" });
await session.interrupt();
expect(events.timelineItems()).toEqual([
{
type: "user_message",
text: "already consumed",
messageId: "native-entry-1",
clientMessageId: "client-steer-1",
},
]);
});
test("keeps identical steers distinct from native message IDs when one is consumed", async () => {
const { pi, session, events } = await createSession();
const { turnId } = await session.startTurn("work");
await session.steerActiveTurn("same text", {
expectedTurnId: turnId,
clientMessageId: "client-steer-1",
});
await session.steerActiveTurn("same text", {
expectedTurnId: turnId,
clientMessageId: "client-steer-2",
});
pi.latestSession().finishSubmittedUserMessage({
id: "native-entry-1",
parentId: null,
text: "same text",
});
await session.interrupt();
expect(events.timelineItems()).toEqual([
{
type: "user_message",
text: "same text",
messageId: "native-entry-1",
clientMessageId: "client-steer-1",
},
{
type: "notification",
level: "warning",
message: "Not delivered (stopped): same text",
},
]);
});
test("retains steer tracking when clear_queue fails so Stop can be retried", async () => {
const { pi, session, events } = await createSession();
const { turnId } = await session.startTurn("work");
await session.steerActiveTurn("queued", {
expectedTurnId: turnId,
clientMessageId: "client-steer-1",
});
const native = pi.latestSession();
native.clearQueueError = new Error("Queue clear failed");
await expect(session.interrupt()).rejects.toThrow("Queue clear failed");
expect(native.controlRequests).toEqual(["clear_queue"]);
expect(events.timelineItems()).toEqual([]);
native.clearQueueError = null;
await session.interrupt();
expect(events.timelineItems()).toEqual([
{
type: "notification",
level: "warning",
message: "Not delivered (stopped): queued",
},
]);
});
test("reports an acknowledged queue removal even when abort subsequently fails", async () => {
const { pi, session, events } = await createSession();
const { turnId } = await session.startTurn("work");
await session.steerActiveTurn("queued", { expectedTurnId: turnId });
const native = pi.latestSession();
native.abort = async () => {
throw new Error("Abort failed");
};
await expect(session.interrupt()).rejects.toThrow("Abort failed");
native.abort = async () => {};
await session.interrupt();
expect(events.timelineItems()).toEqual([
{
type: "notification",
level: "warning",
message: "Not delivered (stopped): queued",
},
]);
});
test("does not claim queue removal when an older Pi lacks clear_queue", async () => {
const { pi, session, events } = await createSession();
const { turnId } = await session.startTurn("work");
await session.steerActiveTurn("queued", { expectedTurnId: turnId });
pi.latestSession().clearQueueError = new Error("Unknown command: clear_queue");
await session.interrupt();
expect(pi.latestSession().controlRequests).toEqual(["clear_queue", "abort"]);
expect(events.timelineItems()).toEqual([]);
});
test("steers the active Pi turn and correlates the echoed user message", async () => {
const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession();
@@ -83,7 +83,12 @@ import {
listPiImportableSessions,
readPiImportSessionConfig,
} from "./session-descriptor.js";
import type { PiRuntime, PiRuntimeSession, PiStartSessionInput } from "./runtime.js";
import type {
PiClearedQueue,
PiRuntime,
PiRuntimeSession,
PiStartSessionInput,
} from "./runtime.js";
import type {
PiAgentSessionEvent,
PiAgentMessage,
@@ -232,7 +237,14 @@ interface StartTurnResult {
interface PiPendingSteerSubmission {
text: string;
label: string;
clientMessageId: string | null;
consumed: boolean;
}
interface PiPendingForegroundSubmission {
text: string;
consumed: boolean;
}
// COMPAT(piSteerFallback): added in v0.5.0, remove after 2027-03-01 once the pi floor
@@ -1210,6 +1222,7 @@ export class PiRpcAgentSession implements AgentSession {
private activePromptRequestId: string | null = null;
private readonly pendingPromptResults = new Map<string, boolean>();
private readonly pendingSteerSubmissions: PiPendingSteerSubmission[] = [];
private pendingForegroundSubmission: PiPendingForegroundSubmission | null = null;
currentLeafOverrideId: string | null | undefined;
private readonly treeUserEntriesById = new Map<string, PiCapturedEntry>();
private readonly pendingExtensionResults = new Map<string, PendingExtensionResult>();
@@ -1319,10 +1332,12 @@ export class PiRpcAgentSession implements AgentSession {
this.pendingSettledMessages = null;
this.activePromptRequestId = null;
this.pendingSteerSubmissions.length = 0;
this.pendingForegroundSubmission = null;
this.clearNoTurnBuffers();
const payload = await convertPromptInput(prompt, { model: this.state.model });
if (this.closed) throw new Error("Pi session is closed");
if (this.activeTurnId !== turnId) return { turnId };
this.pendingForegroundSubmission = { text: payload.text, consumed: false };
this.activeNoTurnPromptText = payload.text;
const shouldProbeForNoTurnPrompt = this.parseSlashCommandInput(payload.text) !== null;
@@ -1365,6 +1380,7 @@ export class PiRpcAgentSession implements AgentSession {
this.pendingSettledMessages = null;
this.activeAssistantMessageId = null;
this.pendingSteerSubmissions.length = 0;
this.pendingForegroundSubmission = null;
this.clearNoTurnBuffers();
if (isPiRequestAbortError(error)) {
this.emit({
@@ -1403,9 +1419,29 @@ export class PiRpcAgentSession implements AgentSession {
return { status: "unavailable" };
}
const imageCount = payload.images?.length ?? 0;
const isImageOnly = payload.text.trim().length === 0 && imageCount > 0;
let label = payload.text;
if (isImageOnly) {
if (imageCount === 1) {
label = "1 image";
} else {
label = `${imageCount} images`;
}
}
const submission: PiPendingSteerSubmission = {
text: payload.text,
label,
clientMessageId: options.clientMessageId ?? null,
consumed: false,
};
// Pi can consume the message before acknowledging the steer RPC.
this.pendingSteerSubmissions.push(submission);
try {
await this.runtimeSession.steer(payload.text, payload.images);
} catch (error) {
const index = this.pendingSteerSubmissions.indexOf(submission);
if (index >= 0) this.pendingSteerSubmissions.splice(index, 1);
if (isPiDefinitiveSteerRejection(error)) {
return { status: "unavailable" };
}
@@ -1416,10 +1452,6 @@ export class PiRpcAgentSession implements AgentSession {
if (this.closed || this.activeTurnId !== options.expectedTurnId) {
return { status: "unavailable" };
}
this.pendingSteerSubmissions.push({
text: payload.text,
clientMessageId: options.clientMessageId ?? null,
});
if (options.clearPendingPermissions) {
await this.clearPendingPermissionsForSteer();
}
@@ -1438,6 +1470,11 @@ export class PiRpcAgentSession implements AgentSession {
}
private takePendingSteerSubmission(text: string): PiPendingSteerSubmission | undefined {
// The original prompt is also echoed, possibly after a matching steer is queued.
if (this.pendingForegroundSubmission?.text === text) {
this.pendingForegroundSubmission = null;
return undefined;
}
const index = this.pendingSteerSubmissions.findIndex((submission) => submission.text === text);
if (index < 0) {
return undefined;
@@ -1534,6 +1571,34 @@ export class PiRpcAgentSession implements AgentSession {
};
}
private reportClearedSteers(
cleared: PiClearedQueue | undefined,
turnId: string | undefined,
): void {
if (!cleared) return;
// Pi returns what it actually removed, after any input-extension rewrites.
for (const text of cleared.steering) {
const index = this.pendingSteerSubmissions.findIndex(
(submission) => !submission.consumed && submission.text === text,
);
const submission = index >= 0 ? this.pendingSteerSubmissions.splice(index, 1)[0] : undefined;
// Pi retains consumed image-only entries in its text queue. An empty native
// entry proves removal only when we still have an unconsumed submission.
if (!text && !submission) continue;
const label = submission?.label ?? (text || "queued message");
this.emit({
type: "timeline",
provider: this.provider,
turnId,
item: {
type: "notification",
level: "warning",
message: `Not delivered (stopped): ${label}`,
},
});
}
}
async interrupt(): Promise<void> {
const turnId = this.activeTurnId ?? undefined;
const interruption: typeof this.interruptingTurn =
@@ -1541,7 +1606,8 @@ export class PiRpcAgentSession implements AgentSession {
this.interruptingTurn = interruption;
try {
try {
await this.runtimeSession.clearQueue();
const cleared = await this.runtimeSession.clearQueue();
this.reportClearedSteers(cleared, turnId);
} catch (error) {
// COMPAT(piClearQueueFallback): added in v0.5.0, remove after 2027-03-01 once
// the pi floor supports clear_queue (added in pi 0.84.4).
@@ -1564,6 +1630,7 @@ export class PiRpcAgentSession implements AgentSession {
this.pendingSettledMessages = null;
this.activeAssistantMessageId = null;
this.pendingSteerSubmissions.length = 0;
this.pendingForegroundSubmission = null;
this.clearNoTurnBuffers();
this.emit({
type: "turn_failed",
@@ -1588,6 +1655,7 @@ export class PiRpcAgentSession implements AgentSession {
this.pendingSettledMessages = null;
this.activeAssistantMessageId = null;
this.pendingSteerSubmissions.length = 0;
this.pendingForegroundSubmission = null;
this.clearNoTurnBuffers();
this.emit({
type: "turn_canceled",
@@ -2203,6 +2271,7 @@ export class PiRpcAgentSession implements AgentSession {
this.activeTurnStartedEmitted = false;
this.pendingSettledMessages = null;
this.pendingSteerSubmissions.length = 0;
this.pendingForegroundSubmission = null;
this.clearNoTurnBuffers();
this.emit({
type: "turn_failed",
@@ -2481,6 +2550,21 @@ export class PiRpcAgentSession implements AgentSession {
event: Extract<PiAgentSessionEvent, { type: "message_end" }>,
turnId: string | undefined,
): void {
if (event.message.role === "user") {
// Entry IDs arrive through the extension later; consumption is already known.
const text = getUserMessageText(event.message.content);
const foreground = this.pendingForegroundSubmission;
// prompt() runs asynchronously, so its first user event may follow steer().
if (foreground && !foreground.consumed && foreground.text === text) {
foreground.consumed = true;
return;
}
const submission = this.pendingSteerSubmissions.find(
(pending) => !pending.consumed && pending.text === text,
);
if (submission) submission.consumed = true;
return;
}
if (event.message.role === "assistant") {
this.activeAssistantMessageId = null;
return;
@@ -2563,6 +2647,7 @@ export class PiRpcAgentSession implements AgentSession {
this.activeTurnStartedEmitted = false;
this.pendingSettledMessages = null;
this.pendingSteerSubmissions.length = 0;
this.pendingForegroundSubmission = null;
this.clearNoTurnBuffers();
if (typeof errorMessage === "string" && errorMessage.length > 0) {
this.usagePoller.stopTurn();
@@ -419,11 +419,12 @@ describe("PiCliRuntime", () => {
test("sends the clear_queue RPC frame", async () => {
const child = createPiChild();
replyToCommands(child, () => ({}));
const removed = { steering: ["rewritten message"], followUp: [] };
replyToCommands(child, () => removed);
const session = await createRuntime(child).startSession({ cwd: "/workspace/project" });
const clearQueueCommand = capturePendingCommand(child, "clear_queue");
await session.clearQueue();
await expect(session.clearQueue()).resolves.toEqual(removed);
expect(await clearQueueCommand).toMatchObject({ type: "clear_queue" });
});
@@ -12,10 +12,12 @@ import {
} from "../jsonl-rpc-process.js";
import {
buildPiLaunch,
PiClearedQueueSchema,
type PiRuntime,
type PiRuntimeLaunch,
type PiRuntimeSession,
type PiStartSessionInput,
type PiClearedQueue,
} from "./runtime.js";
import type {
PiModel,
@@ -149,8 +151,10 @@ class PiCliRuntimeSession implements PiRuntimeSession {
});
}
async clearQueue(): Promise<void> {
await this.requestStopWork({ type: "clear_queue" });
async clearQueue(): Promise<PiClearedQueue | undefined> {
const result = await this.options.process.requestStopWork({ type: "clear_queue" });
if (result === undefined) return undefined;
return PiClearedQueueSchema.parse(result);
}
async compact(customInstructions?: string): Promise<void> {
@@ -268,8 +272,8 @@ class PiCliRuntimeSession implements PiRuntimeSession {
return this.options.process.request(command, timeoutMs);
}
private requestStopWork(command: PiRpcCommand): Promise<void> {
return this.options.process.requestStopWork(command);
private async requestStopWork(command: PiRpcCommand): Promise<void> {
await this.options.process.requestStopWork(command);
}
private async waitForCompletion(command: PiRpcCommand): Promise<void> {
@@ -1,4 +1,5 @@
import type { PiTranscript } from "./transcript.js";
import { z } from "zod";
import type {
PiModel,
PiPromptAck,
@@ -39,6 +40,12 @@ export interface PiStartSessionInput {
extraArgs?: string[];
}
export const PiClearedQueueSchema = z.object({
steering: z.array(z.string()),
followUp: z.array(z.string()),
});
export type PiClearedQueue = z.infer<typeof PiClearedQueueSchema>;
export interface PiRuntimeSession {
readonly environment: Record<string, string>;
onEvent(callback: (event: PiRuntimeEvent) => void): () => void;
@@ -50,7 +57,7 @@ export interface PiRuntimeSession {
message: string,
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<void>;
clearQueue(): Promise<void>;
clearQueue(): Promise<PiClearedQueue | undefined>;
compact(customInstructions?: string): Promise<void>;
setAutoCompaction(enabled: boolean): Promise<void>;
abort(): Promise<void>;
@@ -5,6 +5,7 @@ import type {
PiRuntimeLaunch,
PiRuntimeSession,
PiStartSessionInput,
PiClearedQueue,
} from "../runtime.js";
import type {
PiAgentMessage,
@@ -16,6 +17,7 @@ import type {
PiSessionStats,
} from "../rpc-types.js";
import { buildPiLaunch } from "../runtime.js";
import { getUserMessageText } from "../history-mapper.js";
type FakePiSubagentSubscriptionLevel = "off" | "progress" | "events";
type FakePiSubagentStatus = "pending" | "running" | "completed" | "failed" | "aborted";
@@ -135,6 +137,7 @@ export class FakePiSession implements PiRuntimeSession {
readonly prompts: Array<{ message: string; imageCount: number }> = [];
readonly steerCalls: Array<{ message: string; imageCount: number }> = [];
steerError: Error | null = null;
steerTextTransform: (text: string) => string = (text) => text;
readonly controlRequests: string[] = [];
clearQueueError: Error | null = null;
clearQueueCompletion: Promise<void> | null = null;
@@ -182,6 +185,8 @@ export class FakePiSession implements PiRuntimeSession {
state: PiSessionState;
private readonly subscribers = new Set<(event: PiRuntimeEvent) => void>();
private readonly steeringMessages: string[] = [];
private foregroundMessage: string | null = null;
private readonly subagentMessageResults = new Map<string, FakePiSubagentMessagesResult[]>();
private nextHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null = null;
private activeHeldPrompt: { promise: Promise<void>; reject: (error: Error) => void } | null =
@@ -217,6 +222,7 @@ export class FakePiSession implements PiRuntimeSession {
images?: Array<{ type: "image"; data: string; mimeType: string }>,
): Promise<PiPromptAck> {
this.prompts.push({ message, imageCount: images?.length ?? 0 });
if (!message.startsWith("/")) this.foregroundMessage = message;
const heldPrompt = this.nextHeldPrompt;
if (heldPrompt) {
this.nextHeldPrompt = null;
@@ -262,14 +268,16 @@ export class FakePiSession implements PiRuntimeSession {
if (this.steerError) {
throw this.steerError;
}
this.steeringMessages.push(this.steerTextTransform(message));
}
async clearQueue(): Promise<void> {
async clearQueue(): Promise<PiClearedQueue> {
this.controlRequests.push("clear_queue");
await this.clearQueueCompletion;
if (this.clearQueueError) {
throw this.clearQueueError;
}
return { steering: this.steeringMessages.splice(0), followUp: [] };
}
async compact(customInstructions?: string): Promise<void> {
@@ -430,6 +438,16 @@ export class FakePiSession implements PiRuntimeSession {
}
emit(event: PiRuntimeEvent): void {
const message = event.type === "message_end" ? (event.message as PiAgentMessage) : null;
if (message?.role === "user") {
const text = getUserMessageText(message.content);
if (this.foregroundMessage === text) {
this.foregroundMessage = null;
} else if (text) {
const index = this.steeringMessages.indexOf(text);
if (index >= 0) this.steeringMessages.splice(index, 1);
}
}
for (const subscriber of this.subscribers) {
subscriber(event);
}
@@ -1,6 +1,8 @@
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { existsSync } from "node:fs";
import { performance } from "node:perf_hooks";
import { setTimeout as delay } from "node:timers/promises";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path, { join } from "node:path";
@@ -421,20 +423,20 @@ function linkedWorktreeFacts(gitCommonDir: string) {
});
}
const SETTLE_ATTEMPTS = 1_000;
async function settle(assertion: () => void): Promise<void> {
let lastFailure: unknown = null;
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
// Observation setup resolves real filesystem paths even while the polling clock is frozen.
// Give that I/O wall-clock time without advancing the service's polling deadlines.
const deadline = performance.now() + 5_000;
while (true) {
try {
assertion();
return;
} catch (failure) {
lastFailure = failure;
if (performance.now() >= deadline) throw failure;
}
await delay(1);
await vi.advanceTimersByTimeAsync(0);
}
throw lastFailure;
}
async function waitForRefreshesToSettle(service: WorkspaceGitServiceImpl): Promise<void> {
@@ -1743,48 +1745,65 @@ describe("WorkspaceGitServiceImpl with workspace folders that no longer exist",
await service.dispose();
});
test("a deleted worktree leaves its shared repository polling and fetch to the surviving worktree", async () => {
const gitCommonDir = join(await createTemporaryFolder(), ".git");
const deletedWorktree = await createTemporaryFolder();
const survivingWorktree = await createTemporaryFolder();
const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) });
let survivingWorktreeEdits = 0;
git.deps.getCheckoutShortstat.mockImplementation(async (cwd: string) => ({
additions: cwd === survivingWorktree ? ++survivingWorktreeEdits : 1,
deletions: 0,
}));
const service = createService(git.deps);
const deletedSubscription = service.registerWorkspace({ cwd: deletedWorktree }, vi.fn());
await waitForObservationToSettle(service, 1);
const survivingSubscription = service.registerWorkspace({ cwd: survivingWorktree }, vi.fn());
await waitForObservationToSettle(service, 2);
await rm(deletedWorktree, { recursive: true, force: true });
await vi.advanceTimersByTimeAsync(60_000);
await waitForRefreshesToSettle(service);
const deletedWorktreeCommands = git.gitCommandsIn(deletedWorktree);
for (let tick = 0; tick < 3; tick += 1) {
const survivingWorktreePolls = git.pollsOf(survivingWorktree);
await vi.advanceTimersByTimeAsync(5_000);
await settle(() => {
expect(git.pollsOf(survivingWorktree)).toBe(survivingWorktreePolls + 1);
test.each([0, 250])(
"a deleted worktree leaves its shared repository polling and fetch to the surviving worktree (watch delay: %ims)",
async (watchDelayMs) => {
const gitCommonDir = join(await createTemporaryFolder(), ".git");
const deletedWorktree = await createTemporaryFolder();
const survivingWorktree = await createTemporaryFolder();
const watchReady = createDeferred<void>();
const git = createFolderBackedGit({
facts: linkedWorktreeFacts(gitCommonDir),
heldWatch: { directory: survivingWorktree, release: watchReady.promise },
});
}
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
let survivingWorktreeEdits = 0;
git.deps.getCheckoutShortstat.mockImplementation(async (cwd: string) => ({
additions: cwd === survivingWorktree ? ++survivingWorktreeEdits : 1,
deletions: 0,
}));
const service = createService(git.deps);
try {
const deletedSubscription = service.registerWorkspace({ cwd: deletedWorktree }, vi.fn());
await waitForObservationToSettle(service, 1);
const survivingSubscription = service.registerWorkspace(
{ cwd: survivingWorktree },
vi.fn(),
);
const pollingTime = Date.now();
void delay(watchDelayMs).then(() => watchReady.resolve());
await waitForObservationToSettle(service, 2);
expect(Date.now()).toBe(pollingTime);
const fetchesBefore = git.deps.runGitFetch.mock.calls.length;
await vi.advanceTimersByTimeAsync(2 * BACKGROUND_GIT_FETCH_INTERVAL_MS);
expect(git.deps.runGitFetch.mock.calls.slice(fetchesBefore).map(([cwd]) => cwd)).toEqual([
survivingWorktree,
survivingWorktree,
]);
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
await rm(deletedWorktree, { recursive: true, force: true });
await vi.advanceTimersByTimeAsync(60_000);
await waitForRefreshesToSettle(service);
const deletedWorktreeCommands = git.gitCommandsIn(deletedWorktree);
deletedSubscription.unsubscribe();
survivingSubscription.unsubscribe();
await service.dispose();
});
for (let tick = 0; tick < 3; tick += 1) {
const survivingWorktreePolls = git.pollsOf(survivingWorktree);
await vi.advanceTimersByTimeAsync(5_000);
await settle(() => {
expect(git.pollsOf(survivingWorktree)).toBe(survivingWorktreePolls + 1);
});
}
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
const fetchesBefore = git.deps.runGitFetch.mock.calls.length;
await vi.advanceTimersByTimeAsync(2 * BACKGROUND_GIT_FETCH_INTERVAL_MS);
expect(git.deps.runGitFetch.mock.calls.slice(fetchesBefore).map(([cwd]) => cwd)).toEqual([
survivingWorktree,
survivingWorktree,
]);
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
deletedSubscription.unsubscribe();
survivingSubscription.unsubscribe();
} finally {
watchReady.resolve();
await service.dispose();
}
},
);
test("deleting every worktree of a repository stops its polling and background fetch", async () => {
const gitCommonDir = join(await createTemporaryFolder(), ".git");