Skip to content

Commit 25cfd2d

Browse files
TKassisclaude
andcommitted
Merge branch 'feature/mid-run-steering'
Mid-run steering: Enter steers the live agent run (Pi session.steer()), Alt+Enter queues a new run, Stop restores undelivered steers to the composer. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 parents b0b742b + 5c0166f commit 25cfd2d

9 files changed

Lines changed: 829 additions & 147 deletions

File tree

server/src/agent/events.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,24 @@ function resultText(s: unknown): string {
6464
return JSON.stringify(s ?? "");
6565
}
6666

67+
/** Flatten a user message's content (string or content-part array) to plain
68+
* text. Image parts are dropped — the UI renders steered messages as text. */
69+
function userMessageText(message: unknown): string {
70+
const content = (message as { content?: unknown }).content;
71+
if (typeof content === "string") return content;
72+
if (Array.isArray(content)) {
73+
return content
74+
.map((p) =>
75+
p && typeof p === "object" && (p as { type?: string }).type === "text"
76+
? String((p as { text?: unknown }).text ?? "")
77+
: "",
78+
)
79+
.filter(Boolean)
80+
.join("\n");
81+
}
82+
return "";
83+
}
84+
6785
function cap(s: unknown, max = 4000): string {
6886
const str = resultText(s);
6987
return str.length > max ? str.slice(0, max) + "…" : str;
@@ -87,8 +105,15 @@ export function toClientFrame(
87105
const usage = (ev.message as { usage?: unknown }).usage;
88106
return { type: "turn_end", usage };
89107
}
90-
case "message_start":
91-
return { type: "message_start", role: (ev.message as { role?: string }).role };
108+
case "message_start": {
109+
const role = (ev.message as { role?: string }).role;
110+
// User content marks the exact point a steered message was delivered
111+
// into the run, so the client can split the transcript there.
112+
if (role === "user") {
113+
return { type: "message_start", role, content: userMessageText(ev.message) };
114+
}
115+
return { type: "message_start", role };
116+
}
92117
case "message_end":
93118
return { type: "message_end", role: (ev.message as { role?: string }).role };
94119
case "message_update": {

server/src/api/sessions.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,10 +183,61 @@ export async function registerSessionRoutes(app: FastifyInstance): Promise<void>
183183

184184
app.post<{ Params: { id: string } }>("/sessions/:id/abort", async (req) => {
185185
const session = await getSession(currentProjectId(), activePaths(), req.params.id);
186-
if (session) await session.abort();
187-
return { ok: true };
186+
if (!session) return { ok: true, restored: [] };
187+
// Clear BEFORE abort so a pending steer can't be delivered into the
188+
// dying loop; the texts go back to the composer client-side.
189+
const cleared = session.clearQueue();
190+
await session.abort();
191+
return { ok: true, restored: [...cleared.steering, ...cleared.followUp] };
188192
});
189193

194+
// Steering side-channel: queue a message into the LIVE run (delivered by Pi
195+
// after the current tool calls, before the next LLM call). Never creates a
196+
// run or an SSE stream — the /run stream carries the delivery + queue_update
197+
// frames. 409 reason "not_streaming" tells the client to fall back to a
198+
// normal run.
199+
app.post<{ Params: { id: string }; Body: { message?: string } }>(
200+
"/sessions/:id/steer",
201+
async (req, reply) => {
202+
const projectId = currentProjectId();
203+
const session = await getSession(projectId, activePaths(), req.params.id);
204+
if (!session) {
205+
reply.code(404);
206+
return { detail: "No such session" };
207+
}
208+
const message = req.body?.message;
209+
if (!message || !message.trim()) {
210+
reply.code(400);
211+
return { detail: "message is required" };
212+
}
213+
if (!session.isStreaming) {
214+
reply.code(409);
215+
return { detail: "No run in flight", reason: "not_streaming" };
216+
}
217+
// A steer extends a live run's spend past what the run-start check
218+
// gated, so re-check the cap here.
219+
const budget = isBudgetExceeded(projectId);
220+
if (budget.exceeded) {
221+
reply.code(403);
222+
return {
223+
detail:
224+
`Project spend limit reached ($${budget.totalUsd.toFixed(2)} / ` +
225+
`$${(budget.limitUsd ?? 0).toFixed(2)}).`,
226+
reason: "budget",
227+
};
228+
}
229+
await session.steer(message);
230+
// The run can end between the guard and the queue write; a steer left
231+
// behind would silently deliver into the NEXT run, so pull it back out.
232+
if (!session.isStreaming) {
233+
session.clearQueue();
234+
reply.code(409);
235+
return { detail: "Run ended before the message was delivered", reason: "not_streaming" };
236+
}
237+
return { ok: true, pending: [...session.getSteeringMessages()] };
238+
},
239+
);
240+
190241
app.post<{ Params: { id: string }; Body: RunBody }>(
191242
"/sessions/:id/run",
192243
async (req, reply) => {

server/test/backend.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,38 @@ describe("events → client frames", () => {
306306
);
307307
expect(frame).toMatchObject({ type: "tool_start", args: { path: "notes.md" } });
308308
});
309+
310+
it("includes content on user message_start (string and content-array forms)", () => {
311+
expect(
312+
toClientFrame({
313+
type: "message_start",
314+
message: { role: "user", content: "exclude sample 7" },
315+
} as never),
316+
).toEqual({ type: "message_start", role: "user", content: "exclude sample 7" });
317+
318+
expect(
319+
toClientFrame({
320+
type: "message_start",
321+
message: {
322+
role: "user",
323+
content: [
324+
{ type: "text", text: "look at" },
325+
{ type: "image", data: "…", mimeType: "image/png" },
326+
{ type: "text", text: "plot.png" },
327+
],
328+
},
329+
} as never),
330+
).toEqual({ type: "message_start", role: "user", content: "look at\nplot.png" });
331+
});
332+
333+
it("omits content on assistant message_start", () => {
334+
expect(
335+
toClientFrame({
336+
type: "message_start",
337+
message: { role: "assistant", content: "internal" },
338+
} as never),
339+
).toEqual({ type: "message_start", role: "assistant" });
340+
});
309341
});
310342

311343
describe("web access bridge", () => {

server/test/steer-abort.test.ts

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/**
2+
* HTTP-level tests for the steering side-channel and abort queue restore.
3+
* The session registry is mocked so no real Pi session (auth, model) is
4+
* needed; the fake exposes exactly the surface the routes touch.
5+
*/
6+
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
7+
import fs from "node:fs";
8+
9+
const fakeSessions = new Map<string, FakeSession>();
10+
11+
class FakeSession {
12+
isStreaming = true;
13+
steered: string[] = [];
14+
aborted = false;
15+
clearQueueCalls = 0;
16+
calls: string[] = [];
17+
/** Called by steer(); lets a test flip isStreaming mid-call. */
18+
onSteer: (() => void) | null = null;
19+
20+
async steer(text: string): Promise<void> {
21+
this.calls.push("steer");
22+
this.steered.push(text);
23+
this.onSteer?.();
24+
}
25+
getSteeringMessages(): readonly string[] {
26+
return this.steered;
27+
}
28+
clearQueue(): { steering: string[]; followUp: string[] } {
29+
this.calls.push("clearQueue");
30+
this.clearQueueCalls += 1;
31+
const steering = [...this.steered];
32+
this.steered = [];
33+
return { steering, followUp: [] };
34+
}
35+
async abort(): Promise<void> {
36+
this.calls.push("abort");
37+
this.aborted = true;
38+
}
39+
}
40+
41+
vi.mock("../src/agent/session-registry.ts", () => ({
42+
getAuthStorage: vi.fn(),
43+
getModelRegistry: vi.fn(),
44+
createSession: vi.fn(),
45+
getSession: vi.fn(async (_projectId: string, _paths: unknown, id: string) =>
46+
fakeSessions.get(id) ?? null,
47+
),
48+
listSessions: vi.fn(async () => []),
49+
disposeSession: vi.fn(),
50+
}));
51+
52+
import { buildApp } from "../src/index.ts";
53+
import { PROJECTS_ROOT } from "../src/config.ts";
54+
import { createProject } from "../src/projects.ts";
55+
import { recordRun } from "../src/cost/ledger.ts";
56+
57+
const app = await buildApp();
58+
59+
beforeEach(() => {
60+
fakeSessions.clear();
61+
fs.rmSync(PROJECTS_ROOT, { recursive: true, force: true });
62+
fs.mkdirSync(PROJECTS_ROOT, { recursive: true });
63+
});
64+
65+
afterAll(async () => {
66+
await app.close();
67+
fs.rmSync(PROJECTS_ROOT, { recursive: true, force: true });
68+
});
69+
70+
function steer(id: string, body: unknown, projectId = "default") {
71+
return app.inject({
72+
method: "POST",
73+
url: `/sessions/${id}/steer`,
74+
headers: { "x-project-id": projectId, "content-type": "application/json" },
75+
payload: body as Record<string, unknown>,
76+
});
77+
}
78+
79+
describe("POST /sessions/:id/abort", () => {
80+
it("clears the queue before aborting and returns the texts", async () => {
81+
const s = new FakeSession();
82+
await s.steer("pending steer");
83+
fakeSessions.set("s1", s);
84+
const res = await app.inject({
85+
method: "POST",
86+
url: "/sessions/s1/abort",
87+
headers: { "x-project-id": "default" },
88+
});
89+
expect(res.statusCode).toBe(200);
90+
expect(res.json()).toEqual({ ok: true, restored: ["pending steer"] });
91+
expect(s.aborted).toBe(true);
92+
expect(s.clearQueueCalls).toBe(1);
93+
expect(s.calls).toEqual(["steer", "clearQueue", "abort"]);
94+
});
95+
96+
it("returns ok with empty restored for an unknown session", async () => {
97+
const res = await app.inject({
98+
method: "POST",
99+
url: "/sessions/nope/abort",
100+
headers: { "x-project-id": "default" },
101+
});
102+
expect(res.statusCode).toBe(200);
103+
expect(res.json()).toEqual({ ok: true, restored: [] });
104+
});
105+
});
106+
107+
describe("POST /sessions/:id/steer", () => {
108+
it("404s for an unknown session", async () => {
109+
const res = await steer("nope", { message: "hi" });
110+
expect(res.statusCode).toBe(404);
111+
});
112+
113+
it("400s for an empty message", async () => {
114+
fakeSessions.set("s1", new FakeSession());
115+
const res = await steer("s1", { message: " " });
116+
expect(res.statusCode).toBe(400);
117+
});
118+
119+
it("409s with reason not_streaming when no run is live", async () => {
120+
const s = new FakeSession();
121+
s.isStreaming = false;
122+
fakeSessions.set("s1", s);
123+
const res = await steer("s1", { message: "hi" });
124+
expect(res.statusCode).toBe(409);
125+
expect(res.json()).toMatchObject({ reason: "not_streaming" });
126+
expect(s.steered).toEqual([]);
127+
});
128+
129+
it("queues the message and returns the pending list", async () => {
130+
const s = new FakeSession();
131+
fakeSessions.set("s1", s);
132+
const res = await steer("s1", { message: "exclude sample 7" });
133+
expect(res.statusCode).toBe(200);
134+
expect(res.json()).toEqual({ ok: true, pending: ["exclude sample 7"] });
135+
expect(s.steered).toEqual(["exclude sample 7"]);
136+
});
137+
138+
it("409s and clears the queue when the run ends while queueing", async () => {
139+
const s = new FakeSession();
140+
s.onSteer = () => {
141+
s.isStreaming = false;
142+
};
143+
fakeSessions.set("s1", s);
144+
const res = await steer("s1", { message: "too late" });
145+
expect(res.statusCode).toBe(409);
146+
expect(res.json()).toMatchObject({ reason: "not_streaming" });
147+
// The stale steer must not leak into the next run.
148+
expect(s.clearQueueCalls).toBe(1);
149+
expect(s.steered).toEqual([]);
150+
});
151+
152+
it("403s with reason budget when the project cap is reached", async () => {
153+
const p = createProject({ name: "Capped", spendLimitUsd: 0.01 });
154+
const zero = { costUsd: 0, input: 0, output: 0, cacheRead: 0, total: 0 };
155+
recordRun({
156+
sessionId: "s1",
157+
projectId: p.id,
158+
model: "m",
159+
before: zero,
160+
after: { costUsd: 0.02, input: 10, output: 10, cacheRead: 0, total: 20 },
161+
});
162+
fakeSessions.set("s1", new FakeSession());
163+
const res = await steer("s1", { message: "hi" }, p.id);
164+
expect(res.statusCode).toBe(403);
165+
expect(res.json()).toMatchObject({ reason: "budget" });
166+
});
167+
});

0 commit comments

Comments
 (0)