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

+63 -10
View File
@@ -28,6 +28,7 @@ import { test, expect } from "@playwright/test";
import { provisionAndLogin } from "./auth.mjs";
const BASE = process.env.BASE_URL;
const MOCK = process.env.MOCK_URL;
const U = "layoutuser";
const P = "password123";
@@ -256,7 +257,13 @@ test("layout: mobile chat dropdowns stay inside the viewport", async ({ page })
data: {
defaultModel: { provider: "custom", modelId: "claude-4-8" },
models: [
{ provider: "custom", modelId: "claude-4-8", apiKey: "sk-mock", contextWindow: 200000 },
{
provider: "custom",
modelId: "claude-4-8",
apiKey: "sk-mock",
baseUrl: MOCK,
contextWindow: 200000,
},
{ provider: "openai", modelId: "gpt-5.5", apiKey: "sk-mock2" },
{
provider: "custom",
@@ -289,6 +296,24 @@ test("layout: mobile chat dropdowns stay inside the viewport", async ({ page })
expect(m.scrolled, `${name}: page not scrolled sideways`).toBe(0);
const d = await docWidths(page);
expect(d.scrollWidth, `${name}: no horizontal overflow`).toBeLessThanOrEqual(d.clientWidth);
// On-screen coordinates alone don't prove the panel is *painted*: an ancestor with a
// non-visible overflow (the composer toolbar scrolls horizontally on phones) clips it
// while leaving its rect intact. Hit-test a point inside the panel's **visible** area —
// the rect's intersection with the viewport, since a long menu may legitimately extend
// past the fold on a page that scrolls.
await expect(panel, `${name}: panel visible`).toBeInViewport();
const hit = await panel.evaluate((el) => {
const r = el.getBoundingClientRect();
const top = Math.max(r.top, 0);
const bottom = Math.min(r.bottom, window.innerHeight);
if (bottom - top < 2) return "no visible area";
const x = Math.round(Math.max(r.left, 0) + Math.min(r.width / 2, 40));
const y = Math.round(top + Math.min((bottom - top) / 2, 20));
const hitEl = document.elementFromPoint(x, y);
if (!hitEl) return "nothing at the sampled point";
return el.contains(hitEl) || el === hitEl ? true : `covered by ${hitEl.tagName}`;
});
expect(hit, `${name}: panel not clipped by an ancestor`).toBe(true);
};
const open = async (label, name) => {
await page.locator(`button[aria-label="${label}"]`).click();
@@ -329,19 +354,47 @@ test("layout: mobile chat dropdowns stay inside the viewport", async ({ page })
await close();
}
// Session state (bottom-docked composer, menus open upward): approval + skills render there
// too and share the left-anchored geometry; still at 390x844 from the loop above.
// Session state (bottom-docked composer, menus open upward). The composer toolbar scrolls
// horizontally at phone widths, so every picker in it must escape that clipping ancestor —
// checked idle **and** while a Task runs (an always-ask session parks on a pending
// approval), at the narrowest widths we support.
const sess = await (
await page.request.post(`${BASE}/api/projects/${projectId}/agents/default_agent/sessions`, {
data: { provider: "custom", modelId: "claude-4-8" },
data: { provider: "custom", modelId: "claude-4-8", approvalMode: "always-ask" },
})
).json();
await page.goto(`${BASE}/chat/${sess.session.sessionId}`);
await page.getByPlaceholder(/Type a message/).waitFor();
await open("Approval mode", "approval @session");
await close();
await open("Skills", "skills @session");
await close();
const sessionPickers = ["Approval mode", "Skills", "More settings", "Thinking level"];
for (const vp of [
{ width: 320, height: 640 },
{ width: 375, height: 667 },
]) {
await page.setViewportSize(vp);
await page.goto(`${BASE}/chat/${sess.session.sessionId}`);
await page.getByPlaceholder(/Type a message/).waitFor();
for (const label of sessionPickers) {
await open(label, `${label} @session ${vp.width}`);
await close();
}
}
// Running state: send a message, wait for the pending approval (the run stays running), then
// re-open every picker. The composer stays enabled for steering, so the row is at its
// busiest here — this is the state the toolbar overflowed in.
await page.getByPlaceholder(/Type a message/).fill("Help me set up @theme");
await page.locator('button[aria-label="Send"]').click();
await page.getByRole("button", { name: /^Allow$/ }).waitFor();
// Skills are deliberately locked mid-run; the rest must still open.
await expect(page.locator('button[aria-label="Skills"]')).toBeDisabled();
for (const label of ["Approval mode", "More settings", "Thinking level"]) {
await open(label, `${label} @running 375`);
await close();
}
// …and the toolbar itself still fits, with the merged Stop/Send button reachable.
const running = await docWidths(page);
expect(running.scrollWidth, "running toolbar: no horizontal overflow").toBeLessThanOrEqual(
running.clientWidth,
);
await expect(page.locator('button[aria-label="Stop"]')).toBeVisible();
});
test("layout: login — blank start, non-crossing traces, lang/theme controls", async ({ page }) => {
+1 -1
View File
@@ -93,7 +93,7 @@ const server = http.createServer((req, res) => {
// the connection (no message_stop), so AgentHub reports "stream incomplete" -> GenerativeModel
// resolves it as malformed. On reconnect the engine **resends the input verbatim** — in this
// scenario the failed attempt only has a half tool_call (never committed to the ledger), so
// the retry request carries no <turn_retried> block and is byte-for-byte identical to the
// the retry request carries no [turn_retried] block and is byte-for-byte identical to the
// first request; the mock can only tell them apart by request count (see the malformedTurns counter).
const wantsMalformed = flat.includes("bad stream test");
+3 -3
View File
@@ -17,7 +17,7 @@
* - selections are written into the draft (#74 comment): in draft state, checking the dropdown
* then reloading keeps both the body and the selection; in session state, selecting via slash
* then reloading likewise persists (keyed by user x Session);
* - sending the prefilled body directly -> the message stream collapses the <use_skills> block
* - sending the prefilled body directly -> the message stream collapses the [use_skills] block
* into a "使用技能" ("Use skills") banner, the invoke text still renders normally, the stored
* message really does start with the block, and the selection clears once sending succeeds;
* - slash invocation: typing /<prefix> shows a skill command item, and pressing Enter selects
@@ -243,14 +243,14 @@ test("skills: library groups and cards -> manage-install Modal -> quick-invoke p
await expect(row("agent-optimization")).toHaveAttribute("aria-pressed", "true");
await page.keyboard.press("Escape");
// The stored message really does start with the <use_skills> block (the banner is only a
// The stored message really does start with the [use_skills] block (the banner is only a
// rendering-layer collapse; Trace/storage keeps the raw text), with the body being the invoke
// text prefilled by quick invoke.
const messages = await (
await page.request.get(`${BASE}/api/sessions/${sessionId}/messages`)
).json();
const flat = JSON.stringify(messages);
expect(flat, "stored message keeps the <use_skills> block").toContain("<use_skills>");
expect(flat, "stored message keeps the [use_skills] block").toContain("[use_skills]");
expect(flat, "block lists the selected skill").toContain("skills: agent-creation");
expect(flat, "prefilled body follows the block").toContain("使用 agent-creation 技能");
});