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:
Yaowei ZhengandClaude Fable 5 authored and GitHub committed 2026-07-26 23:01:18 +08:00
1 parent abd5b13d52
commit 7584171482
81 files changed
+3139 -930

No files matched your search

+100
View File
@@ -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");
});
});
+2 -2
View File
@@ -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",
+9 -4
View File
@@ -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 });
});
});
+97
View File
@@ -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);
});
});
+12 -2
View File
@@ -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("做一个落地页");
});
+33 -2
View File
@@ -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" })),
]);
+1
View File
@@ -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() {},
};