feat(core,server,web,cli): mid-run steering messages and square-bracket markers (#63)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
abd5b13d52
commit
7584171482
81 files changed
+3139
-930
No files matched your search
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Integration tests for the follow-up queue (POST /api/sessions/:id/tasks with
|
||||
* `queueIfBusy`):
|
||||
* - busy session → 202 `{queued: true}`, input held server-side, auto-started as an
|
||||
* ordinary task once the current run finishes;
|
||||
* - idle session → the flag is a no-op (`queued: false`, task starts directly).
|
||||
* (The queued count on task_state events is covered by the session-manager unit tests.)
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { approvalDecision, assistantText, toolCall } from "@prismshadow/penguin-core";
|
||||
import type { ApproveFn, OmniMessage } from "@prismshadow/penguin-core";
|
||||
import type { TaskCreateResponse } from "../src/api/types.js";
|
||||
import type { SessionRow } from "../src/db/repos/sessions.js";
|
||||
import type { RuntimeSession } from "../src/runtime/session-manager.js";
|
||||
import { apiClient, createTestApp, provisionUser, waitFor } from "./helpers.js";
|
||||
import type { TestApp } from "./helpers.js";
|
||||
|
||||
const SID = "session-2026-07-06-10-00-00-eeff0001";
|
||||
|
||||
/** Fake Session that parks on one approval per run (keeps the Task running) and records each run's input. */
|
||||
function parkingFakeSession(sessionId: string, runs: string[][]): RuntimeSession {
|
||||
return {
|
||||
sessionId,
|
||||
toolPermission: () => "rw",
|
||||
generateTitle: async () => ({ title: null, usage: null }),
|
||||
compactability: () => "ok" as const,
|
||||
steer: () => false,
|
||||
async *run(input: OmniMessage[], opts: { approve: ApproveFn; signal: AbortSignal }) {
|
||||
runs.push(input.map((m) => (m.payload as { text?: string }).text ?? ""));
|
||||
const tc = toolCall({ name: "exec_command", arguments: "{}", toolCallId: "tc-fu" });
|
||||
yield tc;
|
||||
const decision = await opts.approve(tc);
|
||||
yield approvalDecision(decision, "tc-fu");
|
||||
yield assistantText("done");
|
||||
},
|
||||
async *compact() {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("follow-up queue route", () => {
|
||||
let t: TestApp;
|
||||
let api: ReturnType<typeof apiClient>;
|
||||
let runs: string[][];
|
||||
|
||||
beforeEach(async () => {
|
||||
t = await createTestApp();
|
||||
const { cookie } = await provisionUser(t.app, "queuer");
|
||||
api = apiClient(t.app, cookie);
|
||||
const row: SessionRow = {
|
||||
sessionId: SID,
|
||||
projectId: "queuer-default_project",
|
||||
agentId: "default_agent",
|
||||
provider: "custom",
|
||||
modelId: "m1",
|
||||
workspace: "/tmp/w",
|
||||
approvalMode: "always-ask",
|
||||
title: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
t.deps.sessionsRepo.insert(row);
|
||||
runs = [];
|
||||
t.deps.manager.adopt(row, parkingFakeSession(SID, runs));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await t.cleanup();
|
||||
});
|
||||
|
||||
it("busy → 202 queued:true and auto-starts after the current run; idle → queued:false", async () => {
|
||||
// Idle: the flag is a no-op, the task starts directly.
|
||||
const direct = await api.post(`/api/sessions/${SID}/tasks`, {
|
||||
input: [{ type: "text", text: "task 1" }],
|
||||
queueIfBusy: true,
|
||||
});
|
||||
expect(direct.status).toBe(202);
|
||||
expect(((await direct.json()) as TaskCreateResponse).queued).toBe(false);
|
||||
await waitFor(() => t.deps.manager.pendingApprovalCount(SID) === 1);
|
||||
|
||||
// Busy: held server-side (a plain POST would 409).
|
||||
const queuedRes = await api.post(`/api/sessions/${SID}/tasks`, {
|
||||
input: [{ type: "text", text: "follow-up" }],
|
||||
queueIfBusy: true,
|
||||
});
|
||||
expect(queuedRes.status).toBe(202);
|
||||
expect(((await queuedRes.json()) as TaskCreateResponse).queued).toBe(true);
|
||||
expect(t.deps.manager.pendingFollowUpCount(SID)).toBe(1);
|
||||
expect(
|
||||
(await api.post(`/api/sessions/${SID}/tasks`, { input: [{ type: "text", text: "x" }] }))
|
||||
.status,
|
||||
).toBe(409);
|
||||
|
||||
// Finish run 1: the follow-up auto-starts as an ordinary task, in order.
|
||||
t.deps.manager.decideApproval(SID, "tc-fu", "allow");
|
||||
await waitFor(() => runs.length === 2);
|
||||
expect(runs[1]).toEqual(["follow-up"]);
|
||||
expect(t.deps.manager.pendingFollowUpCount(SID)).toBe(0);
|
||||
await waitFor(() => t.deps.manager.pendingApprovalCount(SID) === 1);
|
||||
t.deps.manager.decideApproval(SID, "tc-fu", "allow");
|
||||
await waitFor(() => t.deps.manager.statusOf(SID) === "idle");
|
||||
});
|
||||
});
|
||||
@@ -148,8 +148,8 @@ describe("scheduler", () => {
|
||||
await scheduler.tickOnce();
|
||||
expect(started).toHaveLength(1);
|
||||
expect(started[0]?.sessionId).toBe("session-1");
|
||||
// Trigger input = <scheduled_task> source block + the prompt body (tells the model this is a scheduled task).
|
||||
expect(started[0]?.text).toContain("<scheduled_task>");
|
||||
// Trigger input = [scheduled_task] source block + the prompt body (tells the model this is a scheduled task).
|
||||
expect(started[0]?.text).toContain("[scheduled_task]");
|
||||
expect(started[0]?.text).toContain("schedule: report");
|
||||
expect(started[0]?.text).toContain("Write the daily report");
|
||||
expect(events.map((e) => e.event.type)).toContain("schedule_fired");
|
||||
|
||||
@@ -57,6 +57,7 @@ function approvalFakeSession(sessionId: string, toolName = "exec_command"): Runt
|
||||
toolPermission: (name) => (name === "read_tool" ? "r" : "rw"),
|
||||
generateTitle: async () => ({ title: null, usage: null }),
|
||||
compactability: () => "ok" as const,
|
||||
steer: () => false,
|
||||
async *run(_input: OmniMessage[], opts: { approve: ApproveFn; signal: AbortSignal }) {
|
||||
const tc = toolCall({ name: toolName, arguments: "{}", toolCallId: "tc-1" });
|
||||
yield tc;
|
||||
@@ -163,6 +164,7 @@ describe("session-manager", () => {
|
||||
toolPermission: () => "rw",
|
||||
generateTitle: async () => ({ title: null, usage: null }),
|
||||
compactability: () => "ok" as const,
|
||||
steer: () => false,
|
||||
async *run(_input: OmniMessage[], opts: { thinkingLevel?: string }) {
|
||||
seen.push(opts.thinkingLevel);
|
||||
yield assistantText("ok");
|
||||
@@ -189,6 +191,7 @@ describe("session-manager", () => {
|
||||
toolPermission: () => "rw",
|
||||
generateTitle: async () => ({ title: null, usage: null }),
|
||||
compactability: () => "ok" as const,
|
||||
steer: () => false,
|
||||
async *run(): AsyncGenerator<OmniMessage> {
|
||||
yield requestBegin();
|
||||
yield toolCall({ name: "exec_command", arguments: "{}", toolCallId: "tc-1" });
|
||||
@@ -230,6 +233,136 @@ describe("session-manager", () => {
|
||||
await waitFor(() => manager.statusOf("session-1") === "idle");
|
||||
});
|
||||
|
||||
it("steer: forwards to the running session; idle / lost race → 409 not_running", async () => {
|
||||
const steered: string[] = [];
|
||||
const fake = approvalFakeSession("session-1");
|
||||
fake.steer = (text: string) => {
|
||||
steered.push(text);
|
||||
return true;
|
||||
};
|
||||
const manager = makeManager(loaderOf(fake));
|
||||
const steerErr = (text: string): unknown => {
|
||||
try {
|
||||
manager.steer("session-1", text);
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e;
|
||||
}
|
||||
};
|
||||
|
||||
// Not running (entry not even loaded): 409 not_running (typed like task_in_progress).
|
||||
const before = steerErr("early") as HttpError;
|
||||
expect(before.status).toBe(409);
|
||||
expect(before.code).toBe("not_running");
|
||||
|
||||
await manager.startTask("session-1", [userText("go")]);
|
||||
await waitFor(() => manager.pendingApprovalCount("session-1") === 1);
|
||||
// Running: forwarded to the core session (no SSE event of its own).
|
||||
expect(steerErr("focus on tests")).toBeNull();
|
||||
expect(steered).toEqual(["focus on tests"]);
|
||||
|
||||
// The core session lost the race (its run just finished): also 409, so the
|
||||
// caller falls back to a normal task.
|
||||
fake.steer = () => false;
|
||||
expect((steerErr("late") as HttpError).code).toBe("not_running");
|
||||
|
||||
manager.decideApproval("session-1", "tc-1", "allow");
|
||||
await waitFor(() => manager.statusOf("session-1") === "idle");
|
||||
// Idle again after the run: 409.
|
||||
expect((steerErr("post") as HttpError).code).toBe("not_running");
|
||||
});
|
||||
|
||||
it("queueIfBusy: enqueues while running, auto-starts in order after each finish; abort keeps the queue", async () => {
|
||||
sessions.updateApprovalMode("session-1", "always-ask");
|
||||
const runInputs: string[][] = [];
|
||||
const fake = approvalFakeSession("session-1");
|
||||
const origRun = fake.run.bind(fake);
|
||||
fake.run = function (input: OmniMessage[], opts: { approve: ApproveFn; signal: AbortSignal }) {
|
||||
runInputs.push(input.map((m) => (m.payload as { text?: string }).text ?? ""));
|
||||
return origRun(input, opts);
|
||||
};
|
||||
const manager = makeManager(loaderOf(fake));
|
||||
const events = capture("session-1");
|
||||
|
||||
const first = await manager.startTask("session-1", [userText("task 1")]);
|
||||
expect(first.queued).toBe(false);
|
||||
await waitFor(() => manager.pendingApprovalCount("session-1") === 1);
|
||||
|
||||
// Busy + queueIfBusy: held server-side instead of 409 (order preserved); without the
|
||||
// flag the 409 mutual exclusion is unchanged.
|
||||
const q1 = await manager.startTask("session-1", [userText("follow-up 1")], {
|
||||
queueIfBusy: true,
|
||||
});
|
||||
const q2 = await manager.startTask("session-1", [userText("follow-up 2")], {
|
||||
queueIfBusy: true,
|
||||
});
|
||||
expect([q1.queued, q2.queued]).toEqual([true, true]);
|
||||
expect(manager.pendingFollowUpCount("session-1")).toBe(2);
|
||||
const conflict = await manager.startTask("session-1", [userText("x")]).catch((e: unknown) => e);
|
||||
expect((conflict as HttpError).code).toBe("task_in_progress");
|
||||
|
||||
// Abort the running task: queued follow-ups are future tasks — NOT discarded; the next
|
||||
// one auto-starts as an ordinary task once the aborted run settles.
|
||||
manager.abortTask("session-1");
|
||||
await waitFor(() => runInputs.length === 2);
|
||||
expect(runInputs[1]).toEqual(["follow-up 1"]);
|
||||
expect(manager.pendingFollowUpCount("session-1")).toBe(1);
|
||||
|
||||
// Finishing each run starts the next queued input, in order, one at a time.
|
||||
await waitFor(() => manager.pendingApprovalCount("session-1") === 1);
|
||||
manager.decideApproval("session-1", "tc-1", "allow");
|
||||
await waitFor(() => runInputs.length === 3);
|
||||
expect(runInputs[2]).toEqual(["follow-up 2"]);
|
||||
await waitFor(() => manager.pendingApprovalCount("session-1") === 1);
|
||||
manager.decideApproval("session-1", "tc-1", "allow");
|
||||
await waitFor(
|
||||
() =>
|
||||
manager.statusOf("session-1") === "idle" && manager.pendingFollowUpCount("session-1") === 0,
|
||||
);
|
||||
|
||||
// task_state events report the queued count (for the input area's "N queued" hint).
|
||||
const states = serverEvents(events).filter((e) => e.type === "task_state");
|
||||
expect(states.some((s) => s.queued === 2)).toBe(true);
|
||||
expect(states[states.length - 1]).toMatchObject({ state: "idle", queued: 0 });
|
||||
});
|
||||
|
||||
it("queueIfBusy during compaction: the queue drains once the compaction ends", async () => {
|
||||
const runInputs: string[][] = [];
|
||||
let releaseCompact: (() => void) | null = null;
|
||||
const fake: RuntimeSession = {
|
||||
sessionId: "session-1",
|
||||
toolPermission: () => "rw",
|
||||
generateTitle: async () => ({ title: null, usage: null }),
|
||||
compactability: () => "ok" as const,
|
||||
steer: () => false,
|
||||
// eslint-disable-next-line require-yield
|
||||
async *run(input: OmniMessage[]): AsyncGenerator<OmniMessage> {
|
||||
runInputs.push(input.map((m) => (m.payload as { text?: string }).text ?? ""));
|
||||
},
|
||||
async *compact(): AsyncGenerator<OmniMessage> {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseCompact = resolve;
|
||||
});
|
||||
yield compactionBegin({ reason: "manual", mode: "summarize", context: 1, turns: 1 });
|
||||
yield compactionEnd({ reason: "manual", mode: "summarize", status: "completed" });
|
||||
},
|
||||
};
|
||||
const manager = makeManager(loaderOf(fake));
|
||||
await manager.startCompact("session-1");
|
||||
await waitFor(() => releaseCompact !== null);
|
||||
|
||||
const q = await manager.startTask("session-1", [userText("after compact")], {
|
||||
queueIfBusy: true,
|
||||
});
|
||||
expect(q.queued).toBe(true);
|
||||
expect(manager.pendingFollowUpCount("session-1")).toBe(1);
|
||||
|
||||
releaseCompact!();
|
||||
await waitFor(() => runInputs.length === 1 && manager.pendingFollowUpCount("session-1") === 0);
|
||||
expect(runInputs[0]).toEqual(["after compact"]);
|
||||
await waitFor(() => manager.statusOf("session-1") === "idle");
|
||||
});
|
||||
|
||||
it("always-ask: registers a pending approval and pushes approval_request; continues after the decision", async () => {
|
||||
const manager = makeManager(loaderOf(approvalFakeSession("session-1")));
|
||||
const events = capture("session-1");
|
||||
@@ -283,6 +416,7 @@ describe("session-manager", () => {
|
||||
toolPermission: () => "rw",
|
||||
generateTitle: async () => ({ title: null, usage: null }),
|
||||
compactability: () => "ok" as const,
|
||||
steer: () => false,
|
||||
async *run() {
|
||||
// The parent-level run_subagent call (no origin): its prompt becomes the sub-session title.
|
||||
yield toolCall({
|
||||
@@ -358,6 +492,7 @@ describe("session-manager", () => {
|
||||
toolPermission: () => "rw",
|
||||
generateTitle: async () => ({ title: null, usage: null }),
|
||||
compactability: () => "ok" as const,
|
||||
steer: () => false,
|
||||
async *run(_input, opts) {
|
||||
const tc1 = toolCall({ name: "t1", arguments: "{}", toolCallId: "tc-1" });
|
||||
yield tc1;
|
||||
@@ -595,6 +730,7 @@ describe("session-manager", () => {
|
||||
toolPermission: () => "rw",
|
||||
generateTitle: async () => ({ title: null, usage: null }),
|
||||
compactability: () => "ok" as const,
|
||||
steer: () => false,
|
||||
async *run() {
|
||||
yield thinkingMessage("thinking");
|
||||
yield assistantText("answer A");
|
||||
@@ -648,6 +784,7 @@ describe("session-manager", () => {
|
||||
toolPermission: () => "rw",
|
||||
generateTitle: async () => ({ title: null, usage: null }),
|
||||
compactability: () => "ok" as const,
|
||||
steer: () => false,
|
||||
async *run() {
|
||||
yield assistantText("x".repeat(600));
|
||||
// Sub-session text doesn't count toward the main-session body threshold
|
||||
@@ -698,6 +835,7 @@ describe("session-manager", () => {
|
||||
toolPermission: () => "rw",
|
||||
generateTitle: async () => ({ title: null, usage: null }),
|
||||
compactability: () => "ok" as const,
|
||||
steer: () => false,
|
||||
async *run() {
|
||||
yield toolCall({
|
||||
name: "run_subagent",
|
||||
|
||||
@@ -63,6 +63,7 @@ function approvalFakeSession(sessionId: string): RuntimeSession {
|
||||
toolPermission: () => "rw",
|
||||
generateTitle: async () => ({ title: null, usage: null }),
|
||||
compactability: () => "ok" as const,
|
||||
steer: () => false,
|
||||
async *run(_input: OmniMessage[], opts: { approve: ApproveFn; signal: AbortSignal }) {
|
||||
const tc = toolCall({ name: "exec_command", arguments: "{}", toolCallId: "tc-sse" });
|
||||
yield tc;
|
||||
@@ -107,7 +108,7 @@ describe("sse-stream", () => {
|
||||
it("FD-1: a new subscription's first frame is always the task_state snapshot (idle)", async () => {
|
||||
const frames = await readSseFrames(await getStream(), 1);
|
||||
expect(frames[0]!.event).toBe("server_event");
|
||||
expect(JSON.parse(frames[0]!.data)).toEqual({ type: "task_state", state: "idle" });
|
||||
expect(JSON.parse(frames[0]!.data)).toEqual({ type: "task_state", state: "idle", queued: 0 });
|
||||
expect(frames[0]!.id).toMatch(/^[0-9a-f]{8}-\d+$/); // FD-2: opaque string id
|
||||
});
|
||||
|
||||
@@ -117,7 +118,11 @@ describe("sse-stream", () => {
|
||||
await waitFor(() => t.deps.manager.pendingApprovalCount(SID) === 1);
|
||||
|
||||
const frames = await readSseFrames(await getStream(), 2);
|
||||
expect(JSON.parse(frames[0]!.data)).toEqual({ type: "task_state", state: "running" });
|
||||
expect(JSON.parse(frames[0]!.data)).toEqual({
|
||||
type: "task_state",
|
||||
state: "running",
|
||||
queued: 0,
|
||||
});
|
||||
const approval = JSON.parse(frames[1]!.data) as {
|
||||
type: string;
|
||||
toolCall: { payload: { tool_call_id: string } };
|
||||
@@ -136,7 +141,7 @@ describe("sse-stream", () => {
|
||||
2,
|
||||
);
|
||||
expect(JSON.parse(frames[0]!.data)).toEqual({ type: "resync_required" });
|
||||
expect(JSON.parse(frames[1]!.data)).toEqual({ type: "task_state", state: "idle" });
|
||||
expect(JSON.parse(frames[1]!.data)).toEqual({ type: "task_state", state: "idle", queued: 0 });
|
||||
});
|
||||
|
||||
it("FD-2: same-epoch Last-Event-ID hitting the buffer → replays later events, then the task_state snapshot", async () => {
|
||||
@@ -146,6 +151,6 @@ describe("sse-stream", () => {
|
||||
const frames = await readSseFrames(await getStream({ "Last-Event-ID": first.id }), 2);
|
||||
expect(frames[0]!.event).toBeUndefined(); // replayed OmniMessage
|
||||
expect((JSON.parse(frames[0]!.data) as { payload: { text: string } }).payload.text).toBe("m2");
|
||||
expect(JSON.parse(frames[1]!.data)).toEqual({ type: "task_state", state: "idle" });
|
||||
expect(JSON.parse(frames[1]!.data)).toEqual({ type: "task_state", state: "idle", queued: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Integration tests for POST /api/sessions/:id/steer (mid-run steering):
|
||||
* - 202 while a Task is running, forwarding the trimmed text to the core session;
|
||||
* - 400 for empty / non-string text;
|
||||
* - 409 not_running when the Session is idle (the frontend then falls back to a
|
||||
* normal task POST);
|
||||
* - 404 for foreign/unknown sessions (via the shared resolveSession lookup).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { approvalDecision, assistantText, toolCall, userText } from "@prismshadow/penguin-core";
|
||||
import type { ApproveFn, OmniMessage } from "@prismshadow/penguin-core";
|
||||
import type { SessionRow } from "../src/db/repos/sessions.js";
|
||||
import type { RuntimeSession } from "../src/runtime/session-manager.js";
|
||||
import { apiClient, createTestApp, provisionUser, waitFor } from "./helpers.js";
|
||||
import type { TestApp } from "./helpers.js";
|
||||
|
||||
const SID = "session-2026-07-06-10-00-00-ccdd0001";
|
||||
|
||||
/** Fake Session that parks on one approval (keeps the Task running) and records steer calls. */
|
||||
function steeringFakeSession(sessionId: string, steered: string[]): RuntimeSession {
|
||||
return {
|
||||
sessionId,
|
||||
toolPermission: () => "rw",
|
||||
generateTitle: async () => ({ title: null, usage: null }),
|
||||
compactability: () => "ok" as const,
|
||||
steer: (text: string) => {
|
||||
steered.push(text);
|
||||
return true;
|
||||
},
|
||||
async *run(_input: OmniMessage[], opts: { approve: ApproveFn; signal: AbortSignal }) {
|
||||
const tc = toolCall({ name: "exec_command", arguments: "{}", toolCallId: "tc-steer" });
|
||||
yield tc;
|
||||
const decision = await opts.approve(tc);
|
||||
yield approvalDecision(decision, "tc-steer");
|
||||
yield assistantText("done");
|
||||
},
|
||||
async *compact() {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("steer route", () => {
|
||||
let t: TestApp;
|
||||
let api: ReturnType<typeof apiClient>;
|
||||
let steered: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
t = await createTestApp();
|
||||
const { cookie } = await provisionUser(t.app, "steerer");
|
||||
api = apiClient(t.app, cookie);
|
||||
const row: SessionRow = {
|
||||
sessionId: SID,
|
||||
projectId: "steerer-default_project",
|
||||
agentId: "default_agent",
|
||||
modelId: "m1",
|
||||
provider: "custom",
|
||||
workspace: "/tmp/w",
|
||||
approvalMode: "always-ask",
|
||||
title: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
t.deps.sessionsRepo.insert(row);
|
||||
steered = [];
|
||||
t.deps.manager.adopt(row, steeringFakeSession(SID, steered));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await t.cleanup();
|
||||
});
|
||||
|
||||
it("idle → 409 not_running (the frontend falls back to a normal task POST)", async () => {
|
||||
const res = await api.post(`/api/sessions/${SID}/steer`, { text: "hello" });
|
||||
expect(res.status).toBe(409);
|
||||
const body = (await res.json()) as { error: { code: string } };
|
||||
expect(body.error.code).toBe("not_running");
|
||||
expect(steered).toEqual([]);
|
||||
});
|
||||
|
||||
it("running → 202, the trimmed text reaches the core session; empty text → 400", async () => {
|
||||
await t.deps.manager.startTask(SID, [userText("go")]);
|
||||
await waitFor(() => t.deps.manager.pendingApprovalCount(SID) === 1);
|
||||
|
||||
expect((await api.post(`/api/sessions/${SID}/steer`, { text: " " })).status).toBe(400);
|
||||
expect((await api.post(`/api/sessions/${SID}/steer`, { text: 42 })).status).toBe(400);
|
||||
expect(steered).toEqual([]);
|
||||
|
||||
const ok = await api.post(`/api/sessions/${SID}/steer`, { text: " focus on tests " });
|
||||
expect(ok.status).toBe(202);
|
||||
expect(steered).toEqual(["focus on tests"]);
|
||||
|
||||
t.deps.manager.decideApproval(SID, "tc-steer", "allow");
|
||||
await waitFor(() => t.deps.manager.statusOf(SID) === "idle");
|
||||
});
|
||||
|
||||
it("unknown session → 404", async () => {
|
||||
const res = await api.post(`/api/sessions/session-ghost/steer`, { text: "x" });
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -164,14 +164,24 @@ describe("title-generator", () => {
|
||||
await waitFor(() => recorded.length > 0);
|
||||
});
|
||||
|
||||
it("fallback strips a leading <use_skills> block so the marker never becomes the title", async () => {
|
||||
it("fallback strips a leading [use_skills] block so the marker never becomes the title", async () => {
|
||||
const calls = { count: 0, args: [] as unknown[] };
|
||||
const gen = makeGenerator();
|
||||
gen.maybeGenerate(CTX, fakeSession({ title: null, usage: null }, calls), {
|
||||
fallbackText: "[use_skills]\nskills: web-design\n[/use_skills]\n做一个落地页",
|
||||
});
|
||||
await waitFor(() => sessions.findById(ROW.sessionId)?.title !== null);
|
||||
// Not "[use_skills]" — the marker block is removed before the first line is taken.
|
||||
expect(sessions.findById(ROW.sessionId)?.title).toBe("做一个落地页");
|
||||
});
|
||||
|
||||
it("fallback also strips the legacy angle-bracket <use_skills> block (material from old Traces)", async () => {
|
||||
const calls = { count: 0, args: [] as unknown[] };
|
||||
const gen = makeGenerator();
|
||||
gen.maybeGenerate(CTX, fakeSession({ title: null, usage: null }, calls), {
|
||||
fallbackText: "<use_skills>\nskills: web-design\n</use_skills>\n做一个落地页",
|
||||
});
|
||||
await waitFor(() => sessions.findById(ROW.sessionId)?.title !== null);
|
||||
// Not "<use_skills>" — the marker block is removed before the first line is taken.
|
||||
expect(sessions.findById(ROW.sessionId)?.title).toBe("做一个落地页");
|
||||
});
|
||||
|
||||
|
||||
@@ -340,7 +340,7 @@ describe("trace-service", () => {
|
||||
// (the frontend uses this to list it on this turn's card), but the duration
|
||||
// only looks at the LLM request — the start point is the first request_begin,
|
||||
// and the user text's timestamp doesn't participate (the compaction summary
|
||||
// `<context_summary>` is created during compaction but only persisted on the
|
||||
// `[context_summary]` is created during compaction but only persisted on the
|
||||
// next run; using it as the start point would stretch the first turn out for
|
||||
// no reason). Also, if the turn list were built only from turns with "a model
|
||||
// segment or a tool span", a turn that fails outright with no output at all
|
||||
@@ -584,6 +584,37 @@ describe("trace-service", () => {
|
||||
expect(a.requests.map((r) => r.taskIndex)).toEqual([0, 0, 1]);
|
||||
});
|
||||
|
||||
it("Task grouping: [user_steering] user texts never start a new Task (steering continuation stays in the same Task)", async () => {
|
||||
const T = (sec: string) => `2026-07-05T10:03:${sec}Z`;
|
||||
await writeTraceFile(root, P, A, "2026-07-05", S, 12, [
|
||||
sessionMeta(metaPayload()),
|
||||
// Task 0, round 1: calls a tool; a steering message rides alongside the tool output.
|
||||
at(T("00.000"), userText("q1")),
|
||||
at(T("01.000"), requestBegin()),
|
||||
at(T("02.000"), toolCall({ name: "read_file", arguments: "{}", toolCallId: "t1" })),
|
||||
at(T("02.500"), requestEnd("completed")),
|
||||
at(T("03.000"), toolCallOutput({ output: "o", toolCallId: "t1" })),
|
||||
at(T("03.200"), userText("[user_steering]\nalso check the tests\n[/user_steering]")),
|
||||
at(T("03.500"), requestBegin()),
|
||||
at(T("04.000"), assistantText("answer 1")),
|
||||
at(T("04.500"), requestEnd("completed")),
|
||||
// Loop-end steering: the answer round produced no tool call — a plain user text here
|
||||
// would start a new Task, but the steering continuation stays in Task 0.
|
||||
at(T("05.000"), userText("[user_steering]\none more thing\n[/user_steering]")),
|
||||
at(T("05.500"), requestBegin()),
|
||||
at(T("06.000"), assistantText("answer 2")),
|
||||
at(T("06.500"), requestEnd("completed")),
|
||||
// A real new user turn afterwards starts Task 1 as usual.
|
||||
at(T("20.000"), userText("q2")),
|
||||
at(T("21.000"), requestBegin()),
|
||||
at(T("22.000"), assistantText("answer 3")),
|
||||
at(T("22.500"), requestEnd("completed")),
|
||||
]);
|
||||
const a = await service.analyze(P, A, S, 12);
|
||||
expect(a.requests.map((r) => r.taskIndex)).toEqual([0, 0, 0, 1]);
|
||||
expect(a.modelSegments.map((s) => s.taskIndex)).toEqual([0, 0, 0, 1]);
|
||||
});
|
||||
|
||||
// Compaction is its own turn: the previous turn called a tool and would
|
||||
// otherwise "continue", but compaction_begin breaks that continuation, so the
|
||||
// compaction request lands on a new taskIndex. A successful compaction splits
|
||||
@@ -604,7 +635,7 @@ describe("trace-service", () => {
|
||||
compactionBegin({ reason: "context", mode: "summarize", context: 1, turns: 1 }),
|
||||
),
|
||||
at(T("04.500"), requestBegin()),
|
||||
at(T("05.000"), assistantText("<summary>…</summary>")),
|
||||
at(T("05.000"), assistantText("[summary]…[/summary]")),
|
||||
at(T("05.500"), requestEnd("completed")),
|
||||
at(T("06.000"), compactionEnd({ reason: "context", mode: "summarize", status: "completed" })),
|
||||
]);
|
||||
|
||||
@@ -21,6 +21,7 @@ function fakeRuntimeSession(sessionId: string): RuntimeSession {
|
||||
toolPermission: () => "rw",
|
||||
generateTitle: async () => ({ title: null, usage: null }),
|
||||
compactability: () => "ok" as const,
|
||||
steer: () => false,
|
||||
async *run() {},
|
||||
async *compact() {},
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user