mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-10 22:20:27 +00:00
Merge branch 'main' into issue/ope-51-ask_user-upgrades
This commit is contained in:
@@ -45,7 +45,7 @@ test("run_shell → full card: description title, command preview, stays-on-this
|
||||
// The mocked proposal has no description → plain "Run a command" title; the command is
|
||||
// the preview; the reason still renders; the scope note replaces the old badge.
|
||||
await expect(page.getByText("Run a command").last()).toBeVisible();
|
||||
await expect(page.getByText("stays on this Mac").last()).toBeVisible();
|
||||
await expect(page.getByText("stays on this computer").last()).toBeVisible();
|
||||
await expect(page.getByText("The coworker wants to run a command.").first()).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Always allow this command" }).last()).toBeVisible();
|
||||
await expect(page.getByText(/local action/)).toHaveCount(0);
|
||||
|
||||
@@ -57,3 +57,37 @@ test("approval: Deny skips the tool and the agent says so", async ({ page }) =>
|
||||
await page.getByRole("button", { name: "Deny" }).last().click();
|
||||
await expect(page.getByText("Understood — skipped the command.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("long user pastes clamp with a more…/less… toggle", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const box = page.getByPlaceholder(/Ask the coworker/);
|
||||
await expect(box).toBeVisible();
|
||||
|
||||
const tail = "END-OF-PASTE-MARKER";
|
||||
const paste =
|
||||
"reply OK. " + "lorem ipsum dolor sit amet consectetur ".repeat(60) + tail; // ~2.4k chars
|
||||
await box.fill(paste);
|
||||
await page.getByRole("button", { name: "Send" }).click();
|
||||
|
||||
// Clamped: the bubble shows the head but not the tail, plus the toggle.
|
||||
const more = page.getByRole("button", { name: "more…" });
|
||||
await expect(more).toBeVisible();
|
||||
const bubble = page.locator(".bubble-user").last();
|
||||
await expect(bubble).toContainText("reply OK.");
|
||||
await expect(bubble).not.toContainText(tail);
|
||||
|
||||
// Expand → full text + "less…"; collapse → clamped again.
|
||||
await more.click();
|
||||
await expect(bubble).toContainText(tail);
|
||||
const less = page.getByRole("button", { name: "less…" });
|
||||
await expect(less).toBeVisible();
|
||||
await less.click();
|
||||
await expect(bubble).not.toContainText(tail);
|
||||
|
||||
// Short messages never show the control.
|
||||
await expect(page.getByText("Echo:").first()).toBeVisible();
|
||||
await box.fill("short follow-up");
|
||||
await page.getByRole("button", { name: "Send" }).click();
|
||||
await expect(page.getByText("short follow-up", { exact: true }).first()).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "more…" })).toHaveCount(1); // still only the paste's
|
||||
});
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
// OPE-27 — auto-compaction GUI: the Settings card's two overrides + summarizer-model
|
||||
// pin POST through, and the "context compacted" divider renders inline mid-session
|
||||
// (driven by the fixtures' scripted `compacted` event) without touching the transcript.
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
|
||||
test("Settings: Context compaction card edits threshold, cap, and summarizer model", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("account-row").click();
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Models", exact: true }).click();
|
||||
|
||||
const card = page.getByTestId("compaction-card");
|
||||
await expect(card).toBeVisible();
|
||||
await expect(card.getByText("Context compaction")).toBeVisible();
|
||||
|
||||
// Defaults render when the backend doesn't send the fields (older-backend robustness).
|
||||
await expect(card.getByTestId("compaction-threshold")).toHaveValue("80");
|
||||
await expect(card.getByTestId("compaction-cap")).toHaveValue("250000");
|
||||
await expect(card.getByTestId("compaction-model")).toHaveValue("");
|
||||
|
||||
// Threshold edits POST as a fraction, clamped to 10–95%.
|
||||
const [req] = await Promise.all([
|
||||
page.waitForRequest(
|
||||
(r) => r.url().endsWith("/v1/settings/compaction") && r.method() === "POST",
|
||||
),
|
||||
card.getByTestId("compaction-threshold").fill("70"),
|
||||
]);
|
||||
expect(req.postDataJSON()).toEqual({ compaction_threshold_pct: 0.7 });
|
||||
|
||||
const [req2] = await Promise.all([
|
||||
page.waitForRequest(
|
||||
(r) => r.url().endsWith("/v1/settings/compaction") && r.method() === "POST",
|
||||
),
|
||||
card.getByTestId("compaction-cap").fill("100000"),
|
||||
]);
|
||||
expect(req2.postDataJSON()).toEqual({ compaction_cap_tokens: 100000 });
|
||||
|
||||
// Summarizer pin: the picker offers the session-default plus the configured models.
|
||||
const [req3] = await Promise.all([
|
||||
page.waitForRequest(
|
||||
(r) => r.url().endsWith("/v1/settings/compaction") && r.method() === "POST",
|
||||
),
|
||||
card.getByTestId("compaction-model").selectOption("gpt-4o-mini"),
|
||||
]);
|
||||
expect(req3.postDataJSON()).toEqual({ compaction_model: "gpt-4o-mini" });
|
||||
});
|
||||
|
||||
test("the compacted divider renders mid-session and the transcript stays intact", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByText("Draft the launch note").first().click();
|
||||
const box = page.getByPlaceholder(/Ask the coworker/);
|
||||
|
||||
// An earlier exchange that must survive the compaction marker (transcript intact).
|
||||
await box.fill("remember the launch date");
|
||||
await box.press("Enter");
|
||||
await expect(page.getByText("Echo: remember the launch date").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await box.fill("compact the context");
|
||||
await box.press("Enter");
|
||||
// The transient signal shows while the summarizer runs, then yields to the divider.
|
||||
await expect(page.getByText("Compacting context…").first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(
|
||||
page.getByText("Context compacted — earlier turns were summarized").first(),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("Compacting context…")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByText("Still on it — continuing where I left off.").first(),
|
||||
).toBeVisible();
|
||||
// Outbound-only: everything before the divider is still on screen.
|
||||
await expect(page.getByText("Echo: remember the launch date").first()).toBeVisible();
|
||||
});
|
||||
@@ -158,7 +158,7 @@ const CONNECTORS = {
|
||||
// MCP-BACKED connectors (§42): vendor-hosted MCP + local OAuth, pinned tool subset.
|
||||
// monday is one-click ONLY (no manual fields); jira also has a manual token path
|
||||
// (two-mode modal). Neither needs cloud sign-in.
|
||||
{ name: "monday", title: "monday.com", icon: "▦", blurb: "Read boards and items, track work, create items and post updates.", aliases: ["project management", "tasks", "boards"], auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#6161ff", logo: "monday", mcp: true, fields: [], instructions: ["One click connects via monday.com sign-in in your browser.", "Sign-in is fully local — tokens stay on this Mac."], connected: false, account: null, enabled: false, allowed_users: [], tools: [{ name: "mcp__monday__get_board_info", label: "Read board", kind: "read", description: "Read a board's columns and groups.", enabled: true, requires_approval: false }, { name: "mcp__monday__create_item", label: "Create item", kind: "write", description: "Create an item on a board.", enabled: true, requires_approval: true }], managed: false, managed_profile: false },
|
||||
{ name: "monday", title: "monday.com", icon: "▦", blurb: "Read boards and items, track work, create items and post updates.", aliases: ["project management", "tasks", "boards"], auth: "oauth", two_way: false, channels: false, available: true, brand_color: "#6161ff", logo: "monday", mcp: true, fields: [], instructions: ["One click connects via monday.com sign-in in your browser.", "Sign-in is fully local — tokens stay on this computer."], connected: false, account: null, enabled: false, allowed_users: [], tools: [{ name: "mcp__monday__get_board_info", label: "Read board", kind: "read", description: "Read a board's columns and groups.", enabled: true, requires_approval: false }, { name: "mcp__monday__create_item", label: "Create item", kind: "write", description: "Create an item on a board.", enabled: true, requires_approval: true }], managed: false, managed_profile: false },
|
||||
{ name: "jira", title: "Jira", icon: "◆", blurb: "Search, summarize, create, and update issues.", aliases: ["issues", "tickets", "atlassian"], auth: "api_token", two_way: false, channels: false, available: true, brand_color: "#0052cc", logo: "jira", mcp: true, fields: [{ key: "base_url", label: "Atlassian site URL", secret: false, required: true, help: "", placeholder: "" }, { key: "email", label: "Account email", secret: false, required: true, help: "", placeholder: "" }, { key: "api_token", label: "API token", secret: true, required: true, help: "", placeholder: "" }], instructions: [], connected: false, account: null, enabled: false, allowed_users: [], tools: [], managed: false, managed_profile: false },
|
||||
],
|
||||
};
|
||||
@@ -552,6 +552,14 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
||||
// Per-session unattended flag — mutable so the composer's "Send to Inbox" toggle persists and
|
||||
// the app reads it back (which is what gates parking approvals to the Inbox vs an inline card).
|
||||
const unattended: Record<string, boolean> = {};
|
||||
// Skills (SKILLS-SPEC) — mutable folder-is-truth mirror: Settings CRUD, the enabled flag,
|
||||
// staged uploads (stage → preview → confirm), and the composer's per-session menu all
|
||||
// round-trip through this one list.
|
||||
const skills: any[] = [
|
||||
{ name: "weekly-report", description: "Monday status report", instructions: "1. Collect updates\n2. Write it up", scope: "global", source: "local", enabled: true, path: "/state/skills/weekly-report", files: 0 },
|
||||
{ name: "html-to-markdown", description: "Convert an HTML document or fragment to clean markdown.", instructions: "Convert the given HTML to markdown, preserving structure.", scope: "global", source: "uploaded", enabled: true, path: "/state/skills/html-to-markdown", files: 2 },
|
||||
];
|
||||
let stagedSkill: any = null;
|
||||
|
||||
// Fresh cloud sign-in state per test (module state outlives a page).
|
||||
Object.assign(CLOUD_STATE, {
|
||||
@@ -585,7 +593,12 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
||||
const msg = JSON.parse(String(raw));
|
||||
if (msg.type === "user_message") {
|
||||
hadTurn = true;
|
||||
send("turn_start", { input: msg.text });
|
||||
// Force-run (SKILLS-SPEC §6): like the real server, TURN_START ships the user's
|
||||
// literal "/name …" line as `display` so the client dedupes on what the user sees.
|
||||
send("turn_start", {
|
||||
input: msg.text,
|
||||
...(msg.skill ? { display: `/${msg.skill}${msg.text ? ` ${msg.text}` : ""}` } : {}),
|
||||
});
|
||||
if (/run a tool/i.test(msg.text)) {
|
||||
pendingTool = "run_shell";
|
||||
send("tool_proposed", { name: "run_shell", arguments: { command: "ls" } });
|
||||
@@ -681,6 +694,18 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
||||
}, 120);
|
||||
return;
|
||||
}
|
||||
// Auto-compaction (OPE-27): the server signals `compacting` (the transient
|
||||
// spinner label), summarizes for a beat, then emits the marker and the turn
|
||||
// continues normally — the divider must render inline.
|
||||
if (/compact the context/i.test(msg.text)) {
|
||||
send("compacting", {});
|
||||
setTimeout(() => {
|
||||
send("compacted", { text: "Context compacted — earlier turns were summarized" });
|
||||
send("assistant_message", { text: "Still on it — continuing where I left off." });
|
||||
send("turn_done");
|
||||
}, 400);
|
||||
return;
|
||||
}
|
||||
// A turn that dies on a provider error; the follow-up {type:"retry"} recovers.
|
||||
if (/fail the turn/i.test(msg.text)) {
|
||||
send("error", { error: "model unreachable" });
|
||||
@@ -709,10 +734,11 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
||||
send("assistant_delta", { text: msg.text });
|
||||
// Echo the model the message carried — pins the model-per-message contract (the
|
||||
// composer's visible model must ride on every user_message; 2026-07-04 fix).
|
||||
// Same for `skill`: the force-run pick must ride as its OWN FIELD, never as text.
|
||||
// `usage` mirrors the real engine's assistant_message sidecar (OPE-42): fixed
|
||||
// counts per turn so the usage-chip specs can assert exact accumulation.
|
||||
send("assistant_message", {
|
||||
text: `Echo: ${msg.text} [model=${msg.model || "none"}]`,
|
||||
text: `Echo: ${msg.text} [model=${msg.model || "none"}]${msg.skill ? ` [skill=${msg.skill}]` : ""}`,
|
||||
usage: {
|
||||
model: msg.model || "anthropic:claude-opus-4-8",
|
||||
input: 1_000,
|
||||
@@ -824,8 +850,79 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
||||
return json(i >= 0 ? sessions[i] : PINNED_SESSION);
|
||||
}
|
||||
|
||||
// Skills (SKILLS-SPEC §5/§6). Order matters: upload/confirm before the {name} regexes.
|
||||
if (/\/v1\/sessions\/[^/]+\/skills$/.test(p)) {
|
||||
// The composer's live menu: every Settings-enabled skill (§4 — disabled = invisible).
|
||||
return json({
|
||||
skills: skills
|
||||
.filter((s) => s.enabled)
|
||||
.map((s) => ({ name: s.name, description: s.description, scope: s.scope, enabled: true })),
|
||||
});
|
||||
}
|
||||
if (p.endsWith("/v1/skills/upload/confirm") && m === "POST") {
|
||||
const b = req.postDataJSON() || {};
|
||||
if (!stagedSkill || b.token !== stagedSkill.token)
|
||||
return json({ ok: false, error: "Upload expired — pick the file again." });
|
||||
skills.push({
|
||||
name: stagedSkill.name, description: stagedSkill.description,
|
||||
instructions: stagedSkill.instructions, scope: "global", source: "uploaded",
|
||||
enabled: true, path: `/state/skills/${stagedSkill.name}`, files: stagedSkill.files.length,
|
||||
});
|
||||
stagedSkill = null;
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (p.endsWith("/v1/skills/upload") && m === "POST") {
|
||||
// Stage → preview; nothing lands until confirm. Fixed parse (the mock reads no zips).
|
||||
stagedSkill = {
|
||||
token: "stage-1", name: "greet", description: "says hello",
|
||||
instructions: "Say hello warmly.", files: ["notes.txt"],
|
||||
};
|
||||
return json({ ok: true, ...stagedSkill });
|
||||
}
|
||||
{
|
||||
const mr = p.match(/\/v1\/skills\/([^/]+)\/reveal$/);
|
||||
if (mr && m === "POST") {
|
||||
return json(
|
||||
skills.some((s) => s.name === decodeURIComponent(mr[1]))
|
||||
? { ok: true }
|
||||
: { ok: false, error: `Unknown skill: ${decodeURIComponent(mr[1])}` },
|
||||
);
|
||||
}
|
||||
}
|
||||
if (/\/v1\/skills\/[^/]+$/.test(p) && (m === "PATCH" || m === "DELETE")) {
|
||||
const name = decodeURIComponent(p.split("/").pop()!);
|
||||
const i = skills.findIndex((s) => s.name === name);
|
||||
if (i < 0) return json({ ok: false, error: `Unknown skill: ${name}` });
|
||||
if (m === "DELETE") {
|
||||
skills.splice(i, 1);
|
||||
return json({ ok: true });
|
||||
}
|
||||
const b = req.postDataJSON() || {};
|
||||
if (typeof b.enabled === "boolean") skills[i].enabled = b.enabled;
|
||||
if (typeof b.description === "string") skills[i].description = b.description;
|
||||
if (typeof b.instructions === "string") skills[i].instructions = b.instructions;
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (p.endsWith("/v1/skills") && m === "POST") {
|
||||
const b = req.postDataJSON() || {};
|
||||
if (!b.name || !(b.instructions || "").trim())
|
||||
return json({ ok: false, error: "Skill name and instructions are required." });
|
||||
if (skills.some((s) => s.name === b.name))
|
||||
return json({ ok: false, error: `A skill named '${b.name}' already exists in that scope.` });
|
||||
skills.push({
|
||||
name: b.name, description: b.description || "", instructions: b.instructions,
|
||||
scope: "global", source: "local", enabled: true, path: `/state/skills/${b.name}`, files: 0,
|
||||
});
|
||||
return json({ ok: true });
|
||||
}
|
||||
if (p.endsWith("/v1/skills")) return json({ skills });
|
||||
|
||||
if (p.endsWith("/v1/health")) return json(HEALTH);
|
||||
if (p.endsWith("/v1/settings")) return json(SETTINGS);
|
||||
if (p.endsWith("/v1/settings/context-bar") && m === "POST") {
|
||||
Object.assign(SETTINGS, req.postDataJSON());
|
||||
return json({ ok: true, context_bar: SETTINGS.context_bar });
|
||||
}
|
||||
if (p.endsWith("/v1/settings/pdf") && m === "POST") {
|
||||
Object.assign(SETTINGS, req.postDataJSON());
|
||||
return json({
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
|
||||
// SKILLS-SPEC §9 journey 4 — the "/" force-run: popup pick inserts the inline `/name `
|
||||
// prefix, the send carries the skill as its OWN WebSocket field (never as message text),
|
||||
// and the transcript shows ONE truthful bubble with exactly what the user typed.
|
||||
|
||||
test("skills-forcerun: popup pick → inline /name → skill rides the frame → one bubble", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByText("Draft the launch note").first().click();
|
||||
|
||||
// "/" opens the popup; picking inserts the inline prefix (no chip) and keeps focus.
|
||||
const box = page.getByPlaceholder(/Ask the coworker/);
|
||||
await box.fill("/");
|
||||
await expect(page.getByTestId("skill-popup")).toBeVisible();
|
||||
await page.getByText("/weekly-report").click();
|
||||
await expect(box).toHaveValue("/weekly-report ");
|
||||
|
||||
await box.type("cover last week");
|
||||
await box.press("Enter");
|
||||
|
||||
// ONE user bubble, showing the literal line the user typed — never the model-facing
|
||||
// "load this skill…" framing (§6: the _display contract).
|
||||
await expect(page.getByText("/weekly-report cover last week")).toHaveCount(1);
|
||||
await expect(page.getByText(/Use the skill/)).toHaveCount(0);
|
||||
|
||||
// The fake agent echoes what actually rode the wire: text WITHOUT the prefix, and the
|
||||
// skill as its own field.
|
||||
await expect(page.getByText(/\[skill=weekly-report\]/)).toBeVisible();
|
||||
await expect(page.getByText(/Echo: cover last week/)).toBeVisible();
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
|
||||
// SKILLS-SPEC §9 journey 2 — liveness from the session's seat: the composer's "/" popup is
|
||||
// the live "what can my worker use right now" view. A skill created in Settings is offered;
|
||||
// a disabled one vanishes. Hermetic: the popup reads /v1/sessions/{id}/skills from fixtures.
|
||||
|
||||
test("skills-session: new skill offered in '/', disabled one absent", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByText("Draft the launch note").first().click();
|
||||
|
||||
// The seeded menu: both enabled skills offered on "/".
|
||||
const box = page.getByPlaceholder(/Ask the coworker/);
|
||||
await box.fill("/");
|
||||
await expect(page.getByTestId("skill-popup")).toBeVisible();
|
||||
await expect(page.getByText("/weekly-report")).toBeVisible();
|
||||
await expect(page.getByText("/html-to-markdown")).toBeVisible();
|
||||
await box.fill(""); // close the popup
|
||||
|
||||
// Settings round-trip: create one skill, disable another.
|
||||
await page.getByTestId("account-row").click();
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Skills", exact: true }).click();
|
||||
await page.getByRole("button", { name: /Add skill/ }).click();
|
||||
await page.getByText("Write it myself").click();
|
||||
await page.getByLabel("Name").fill("fresh-skill");
|
||||
await page.getByLabel("Instructions").fill("Do the fresh thing.");
|
||||
await page.getByRole("button", { name: "Save skill" }).click();
|
||||
await expect(page.getByRole("status")).toContainText("fresh-skill");
|
||||
await page.getByLabel("weekly-report enabled").click();
|
||||
await expect(page.getByRole("status")).toContainText("turned off everywhere");
|
||||
|
||||
// Back in the session: the popup reflects the new state — created offered, disabled gone.
|
||||
await page.getByText("Draft the launch note").first().click();
|
||||
await box.fill("/");
|
||||
await expect(page.getByTestId("skill-popup")).toBeVisible();
|
||||
await expect(page.getByText("/fresh-skill")).toBeVisible();
|
||||
await expect(page.getByText("/weekly-report")).toHaveCount(0);
|
||||
await expect(page.getByText("/html-to-markdown")).toBeVisible(); // untouched one persists
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
|
||||
// SKILLS-SPEC §9 journey 1 — Settings ▸ Skills as the management home: create through the
|
||||
// Add-skill menu, edit in place, disable with the amber clean-slate banner, and the
|
||||
// rich-skill folder chip. Hermetic: every /v1 call lands in fixtures.ts.
|
||||
|
||||
const openSkills = async (page: import("@playwright/test").Page) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("account-row").click();
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Skills", exact: true }).click();
|
||||
};
|
||||
|
||||
test("skills-settings: create via the menu → name-first banner; edit persists", async ({ page }) => {
|
||||
await openSkills(page);
|
||||
|
||||
// The seeded rows render; the rich one wears its folder chip; the list is the page
|
||||
// (no standing add-surfaces).
|
||||
await expect(page.getByText("weekly-report")).toBeVisible();
|
||||
await expect(page.getByText("uploaded")).toBeVisible();
|
||||
await expect(page.getByTitle("Show folder")).toContainText("2 files");
|
||||
await expect(page.getByText("Start a conversation")).toHaveCount(0);
|
||||
|
||||
// Add skill ▾ → the three doors, then Write it myself.
|
||||
await page.getByRole("button", { name: /Add skill/ }).click();
|
||||
await expect(page.getByText("Import a file")).toBeVisible();
|
||||
await expect(page.getByText("Create with OpenWorker")).toBeVisible();
|
||||
await page.getByText("Write it myself").click();
|
||||
|
||||
await page.getByLabel("Name").fill("greet-warmly");
|
||||
await page.getByLabel("Description").fill("Greets people warmly");
|
||||
await page.getByLabel("Instructions").fill("Always greet warmly.");
|
||||
await page.getByRole("button", { name: "Save skill" }).click();
|
||||
|
||||
// Name-first teal confirmation (§7) + the new row.
|
||||
const status = page.getByRole("status");
|
||||
await expect(status).toContainText("greet-warmly");
|
||||
await expect(status).toContainText("can now use it in every conversation");
|
||||
await expect(page.getByText("Greets people warmly")).toBeVisible();
|
||||
|
||||
// Edit: pencil prefills, name locked, save PATCHes through to the re-fetched list.
|
||||
await page.getByTitle("Edit").first().click();
|
||||
const name = page.getByLabel("Name");
|
||||
await expect(name).toBeDisabled();
|
||||
await page.getByLabel("Description").fill("Monday status report, sharper");
|
||||
await page.getByRole("button", { name: "Save skill" }).click();
|
||||
await expect(page.getByText("Monday status report, sharper")).toBeVisible();
|
||||
});
|
||||
|
||||
test("skills-settings: disable → amber everywhere/clean-slate banner; delete is two-step", async ({ page }) => {
|
||||
await openSkills(page);
|
||||
|
||||
await page.getByLabel("weekly-report enabled").click();
|
||||
const status = page.getByRole("status");
|
||||
await expect(status).toContainText("weekly-report");
|
||||
await expect(status).toContainText("turned off everywhere");
|
||||
await expect(status).toContainText("start a new one for a completely clean slate");
|
||||
|
||||
// Two-step delete: arm, confirm, row gone, banner names the skill.
|
||||
await page.getByLabel("Delete html-to-markdown").click();
|
||||
await expect(page.getByText("html-to-markdown")).toBeVisible(); // armed ≠ deleted
|
||||
await page.getByText("Confirm delete").click();
|
||||
await expect(page.getByText("html-to-markdown")).toHaveCount(1); // only the banner remains
|
||||
await expect(page.getByRole("status")).toContainText("removed");
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
|
||||
// SKILLS-SPEC §9 journey 3 — import with the mandatory review gate: the preview installs
|
||||
// NOTHING; confirm installs and the row wears the `uploaded` provenance badge. Hermetic:
|
||||
// stage/confirm round-trip through fixtures.ts state.
|
||||
|
||||
test("skills-upload: preview installs nothing → confirm → uploaded badge", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("account-row").click();
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Skills", exact: true }).click();
|
||||
|
||||
// Add skill ▾ → Import a file → straight to the (hidden) picker.
|
||||
await page.getByRole("button", { name: /Add skill/ }).click();
|
||||
await page.getByText("Import a file").click();
|
||||
await page.getByLabel("Upload a skill archive").setInputFiles({
|
||||
name: "greet.zip",
|
||||
mimeType: "application/zip",
|
||||
buffer: Buffer.from("PKfake"),
|
||||
});
|
||||
|
||||
// The mandatory review screen: everything parsed, nothing installed yet.
|
||||
await expect(page.getByText("Review before installing")).toBeVisible();
|
||||
await expect(page.getByText("says hello")).toBeVisible();
|
||||
await expect(page.getByText("Say hello warmly.")).toBeVisible();
|
||||
await expect(page.getByText(/notes\.txt/)).toBeVisible();
|
||||
await expect(page.getByText("greet", { exact: true })).toHaveCount(1); // preview only, no row
|
||||
|
||||
await page.getByRole("button", { name: "Install skill" }).click();
|
||||
|
||||
// Installed: teal name-first banner, a real row with the provenance badge + folder chip.
|
||||
const status = page.getByRole("status");
|
||||
await expect(status).toContainText("greet");
|
||||
await expect(status).toContainText("can now use it in every conversation");
|
||||
await expect(page.getByText("greet", { exact: true })).toHaveCount(2); // banner + the new row
|
||||
await expect(page.getByText("uploaded")).toHaveCount(2); // html-to-markdown + greet
|
||||
});
|
||||
@@ -20,7 +20,8 @@ test("usage chip appears after a turn and opens the breakdown popover", async ({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Chip shows the session total (1k + 200 + 8k + 800 = 10k).
|
||||
// Default: no bar (owner ask 2026-07-30) — the chip states the session total
|
||||
// (1k + 200 + 8k + 800 = 10k). The bar is opt-in via Settings.
|
||||
const chip = page.getByTestId("usage-chip");
|
||||
await expect(chip).toContainText("10k");
|
||||
|
||||
@@ -58,9 +59,41 @@ test("usage resets on a new session", async ({ page }) => {
|
||||
const box = page.getByPlaceholder(/Ask the coworker/);
|
||||
await box.fill("hello");
|
||||
await box.press("Enter");
|
||||
await expect(page.getByTestId("usage-chip")).toContainText("10k", { timeout: 10_000 });
|
||||
await expect(page.getByTestId("usage-chip")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// "+ New session" wipes the transcript — and the usage accumulation with it.
|
||||
await page.getByRole("button", { name: /New session/ }).first().click();
|
||||
await expect(page.getByTestId("usage-chip")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Settings toggle turns the context bar on; default is the session total", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByText("Draft the launch note").first().click();
|
||||
const box = page.getByPlaceholder(/Ask the coworker/);
|
||||
await box.fill("hello");
|
||||
await box.press("Enter");
|
||||
const chip = page.getByTestId("usage-chip");
|
||||
await expect(chip).toContainText("10k", { timeout: 10_000 }); // default: total, no bar
|
||||
|
||||
// Turn the bar ON in Settings -> General.
|
||||
await page.getByTestId("account-row").click();
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await expect(page.getByTestId("context-bar-toggle")).not.toBeChecked();
|
||||
const [req] = await Promise.all([
|
||||
page.waitForRequest(
|
||||
(r) => r.url().endsWith("/v1/settings/context-bar") && r.method() === "POST",
|
||||
),
|
||||
page.getByTestId("context-bar-toggle").check(),
|
||||
]);
|
||||
expect(req.postDataJSON()).toEqual({ context_bar: true });
|
||||
|
||||
// Reload so the app re-reads settings: the chip is now the fill bar, not a number.
|
||||
await page.goto("/");
|
||||
await page.getByText("Draft the launch note").first().click();
|
||||
await page.getByPlaceholder(/Ask the coworker/).fill("hello");
|
||||
await page.getByPlaceholder(/Ask the coworker/).press("Enter");
|
||||
const bar = page.getByTestId("usage-chip");
|
||||
await expect(bar).toBeVisible({ timeout: 10_000 });
|
||||
await expect(bar).not.toContainText("10k");
|
||||
await expect(bar).toHaveAttribute("title", /Context window 5% full/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user