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
@@ -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 }) => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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 技能");
|
||||
});
|
||||
Reference in new issue
Block a user