mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-09 16:35:27 +02:00
## Thinking Path > - Paperclip lets operators assign tasks to AI agents and review their work. > - The task composer controls the next message and its assigned agent. > - Operators needed a way to choose that agent's model and effort without leaving the composer. > - The old mode selector, upload button, and input cards made the mobile composer crowded and hid normal messaging during a pending decision. > - Harnesses publish different model and effort capabilities, so the picker must follow the selected agent. > - This pull request adds one responsive composer flow, keeps pending cards visible above it, and protects Codex ACP authentication in the local test path. > - Operators can choose run settings, send a message, and answer a pending card as separate actions. ## Linked Issues or Issue Description **Subsystem affected** Task composer UI, issue thread interactions, Codex ACP credential handling, and Storybook. **Problem or motivation** The composer did not expose model or effort for the selected agent. Mobile actions wrapped poorly. Pending questions and confirmations replaced the composer. A local Codex ACP test could also reuse host authentication after the managed key was removed. **Proposed solution** Put assignee search, model search, exact model IDs, effort, and fast mode in one picker. Use a mobile dialog. Replace the direct-upload plus action and separate mode selector with an Add menu and removable Plan or Ask chips. Place pending interaction cards above the usable composer. Keep these cards pending after an ordinary message unless their creator asks for comment superseding. Replace managed ACP auth files atomically and isolate the test key from host credentials. **Roadmap alignment** ROADMAP.md does not list an overlapping composer milestone. This change improves the existing task and review flows. ## What Changed - Added the combined assignee, model, and effort picker to both task composers. Search matches agent name, role, and harness. The server uses a curated Codex list by default and honors instance-declared models. Manual IDs remain available. - Added an effort slider for known model capabilities, a conditional Codex fast control, and reset. The picker opens in a modal on mobile. - Added the Add menu for files, supported goals, Plan mode, and Ask mode. Plan and Ask are exclusive removable chips. Keyboard mode cycling remains available. - Adjusted mobile spacing, avatars, wrapping, and Send placement. Removed the composer divider. - Moved pending question, confirmation, review, and related cards above the composer. Ordinary comments now leave question and confirmation cards pending by default. The onboarding prompt retains explicit comment superseding. - Updated the Storybook composer group with responsive states and the production picker. Added UI, service, route, and browser regression coverage. - Isolated Codex ACP API-key authentication, skipped subscription auth merge and shared-home copy-back for remote API-key runs, and replaced the managed auth file atomically. ## Verification - `pnpm -r typecheck` — passed on the final local head. - `pnpm check:token-gates` — passed on the final local head. - `pnpm exec vitest run server/src/__tests__/adapter-models.test.ts ui/src/components/task-chat/ComposerRunSettingsPicker.test.tsx` — 31 tests passed, including role and harness search, declared Codex models, and filtering general OpenAI models. - `pnpm exec vitest run server/src/__tests__/issue-thread-interactions-service.test.ts` — 74 tests passed. - `pnpm exec vitest run packages/adapters/codex-local/src/server/acp.test.ts` — 42 tests passed, including remote API-key copy-back isolation. - `pnpm test:run` — attempted locally; the embedded PostgreSQL test database could not initialize on macOS. The isolated `heartbeat-run-event-sequencing` suite reproduced that environment failure. GitHub CI runs the full test matrix for this head. - `pnpm build` — passed on the final head. `pnpm build-storybook` passed after the last UI change; only server code, tests, and docs changed afterward. - Live local test drive — Codex ACP ran a task with a managed API key. The test agent was restored to its default ACP configuration afterward. - Review the interactive stories under the top-level Composer group with `pnpm storybook`. Check a narrow desktop width and mobile Plan, Ask, picker, and pending-question states. ## Risks - A pending card stays open when an ordinary comment changes the discussion. Its creator can set `supersedeOnUserComment: true` when a new comment should replace it. - Model and effort overrides persist on the task until reset or changed. An unlisted manual model ID may fail when the provider runs it. - Some harness catalogs do not report effort support. The picker hides effort for those models. - No database migration is required. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI GPT-6 via Codex. This runtime does not expose the exact model ID or context window to the task. The model used code execution and browser tools. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: OpenAI Codex <codex@openai.com>
609 lines
19 KiB
TypeScript
609 lines
19 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
|
|
import { idle, json, send, setup } from "./agent-chat.shared";
|
|
|
|
test.use({ trace: "retain-on-failure" });
|
|
test.setTimeout(120_000);
|
|
|
|
/**
|
|
* Agent chat project and content coverage: project cards, split handoff,
|
|
* uploads, Ask/Plan modes, repository selection, plan approval, and shared
|
|
* history. Shared fixtures live in ./agent-chat.shared.ts; session lifecycle
|
|
* flows run in agent-chat-sessions.spec.ts.
|
|
*/
|
|
for (const direct of [false, true])
|
|
test(`project card through ${direct ? "direct API" : "dedicated tool"} persists and deduplicates retries`, async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const f = await setup(request);
|
|
try {
|
|
await page.goto(f.route);
|
|
await send(page, {
|
|
action: "project",
|
|
name: "Browser repositories",
|
|
direct,
|
|
urls: [
|
|
"https://github.com/octocat/Hello-World.git",
|
|
"https://github.com/octocat/Spoon-Knife",
|
|
"https://github.com/octocat/Hello-World",
|
|
],
|
|
});
|
|
await idle(request, f.chatPath);
|
|
const card = page.getByRole("article", {
|
|
name: "Project created: Browser repositories",
|
|
});
|
|
await expect(card).toHaveCount(1);
|
|
await expect(
|
|
card.getByRole("link", { name: "octocat/Hello-World" }),
|
|
).toHaveAttribute("href", "https://github.com/octocat/Hello-World");
|
|
await expect(
|
|
card.getByRole("link", { name: "octocat/Spoon-Knife" }),
|
|
).toBeVisible();
|
|
const projects = await json(
|
|
await request.get(`/api/companies/${f.company.id}/projects`),
|
|
);
|
|
expect(projects).toHaveLength(1);
|
|
expect(projects[0].workspaces).toHaveLength(2);
|
|
if (direct) {
|
|
await json(await request.post(`/api/projects/${projects[0].id}/workspaces`, {
|
|
data: { name: "Additional repository", repoUrl: "https://github.com/octocat/git-consortium" },
|
|
}));
|
|
await expect(card.getByRole("link", { name: "Additional repository" }))
|
|
.toHaveAttribute("href", "https://github.com/octocat/git-consortium");
|
|
await expect(card).toHaveCount(1);
|
|
}
|
|
await send(page, "/new");
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(await json(await request.get(f.chatPath)))
|
|
.conversationSessionGeneration,
|
|
)
|
|
.toBe(1);
|
|
await page.reload();
|
|
await expect(card).toHaveCount(1);
|
|
if (direct) await expect(card.getByRole("link", { name: "Additional repository" })).toBeVisible();
|
|
await card
|
|
.getByRole("link", { name: "Browser repositories", exact: true })
|
|
.click();
|
|
await expect(page).toHaveURL(/\/projects\/.*\/issues/);
|
|
await page
|
|
.getByRole("tab", { name: "Configuration", exact: true })
|
|
.click();
|
|
await expect(
|
|
page.getByRole("region", { name: "Repositories" }),
|
|
).toContainText("octocat/Hello-World");
|
|
} finally {
|
|
await f.restore();
|
|
}
|
|
});
|
|
|
|
test("split handoff commits relevant plans before execution and never creates chat children", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const f = await setup(request);
|
|
try {
|
|
await page.goto(f.route);
|
|
await send(page, {
|
|
action: "handoff",
|
|
name: "Welcome project",
|
|
split: true,
|
|
plan: "# Welcome plan\nWrite a friendly welcome.",
|
|
});
|
|
const chat = await idle(request, f.chatPath);
|
|
await expect
|
|
.poll(
|
|
async () => {
|
|
const tasks = await json(
|
|
await request.get(`/api/companies/${f.company.id}/issues`),
|
|
);
|
|
return (
|
|
tasks.length === 2 &&
|
|
tasks.every((task: any) => task.status === "done")
|
|
);
|
|
},
|
|
{ timeout: 60_000 },
|
|
)
|
|
.toBe(true);
|
|
const tasks = await json(
|
|
await request.get(`/api/companies/${f.company.id}/issues`),
|
|
);
|
|
for (const task of tasks) {
|
|
expect(task.parentId).toBeNull();
|
|
expect(task.projectId).toBeTruthy();
|
|
expect(task.assigneeAgentId).toBe(f.agent.id);
|
|
const plan = await json(
|
|
await request.get(`/api/issues/${task.id}/documents/plan`),
|
|
);
|
|
const output = await json(
|
|
await request.get(`/api/issues/${task.id}/documents/output`),
|
|
);
|
|
expect(output.body).toContain(plan.body);
|
|
const runs = await json(
|
|
await request.get(`/api/companies/${f.company.id}/heartbeat-runs`),
|
|
);
|
|
const run = runs.find(
|
|
(run: any) => run.contextSnapshot?.issueId === task.id,
|
|
);
|
|
expect(run).toBeTruthy();
|
|
expect(Date.parse(plan.updatedAt)).toBeLessThanOrEqual(
|
|
Date.parse(run.startedAt),
|
|
);
|
|
}
|
|
expect(
|
|
(await json(await request.get(`/api/issues/${chat.id}/documents/plan`)))
|
|
.body,
|
|
).toContain("Welcome plan");
|
|
expect(
|
|
(
|
|
await request.post(`/api/companies/${f.company.id}/issues`, {
|
|
data: { title: "Invalid child", parentId: chat.id },
|
|
})
|
|
).status(),
|
|
).toBe(422);
|
|
expect(
|
|
(
|
|
await request.patch(`/api/issues/${tasks[0].id}`, {
|
|
data: { parentId: chat.id },
|
|
})
|
|
).status(),
|
|
).toBe(422);
|
|
} finally {
|
|
await f.restore();
|
|
}
|
|
});
|
|
|
|
for (const bad of [
|
|
{ ids: ["987654321"] },
|
|
{ urls: ["https://user:password@github.com/org/repo"] },
|
|
{
|
|
urls: ["https://github.com/org/repo"],
|
|
workspace: { name: "Conflicting", repoUrl: "https://github.com/org/repo" },
|
|
},
|
|
])
|
|
test(`failed project creation has no success card or partial state: ${JSON.stringify(bad)}`, async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const f = await setup(request);
|
|
try {
|
|
await page.goto(f.route);
|
|
await send(page, { action: "project", ...bad });
|
|
await idle(request, f.chatPath);
|
|
await expect(page.getByText(/Expected tool result:/)).toBeVisible();
|
|
await expect(
|
|
page.getByRole("article", { name: /Project created:/ }),
|
|
).toHaveCount(0);
|
|
expect(
|
|
await json(
|
|
await request.get(`/api/companies/${f.company.id}/projects`),
|
|
),
|
|
).toHaveLength(0);
|
|
expect(
|
|
await json(await request.get(`/api/companies/${f.company.id}/issues`)),
|
|
).toHaveLength(0);
|
|
} finally {
|
|
await f.restore();
|
|
}
|
|
});
|
|
|
|
test("first upload creates the chat without invoking its agent; shared attachments persist", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const f = await setup(request);
|
|
try {
|
|
await page.goto(f.route);
|
|
await page
|
|
.locator('input[type="file"]')
|
|
.last()
|
|
.setInputFiles({
|
|
name: "chat-notes.txt",
|
|
mimeType: "text/plain",
|
|
buffer: Buffer.from("Attachment acceptance content"),
|
|
});
|
|
await expect(
|
|
page.getByTestId("task-chat-composer-attachments"),
|
|
).toContainText("chat-notes.txt");
|
|
await expect
|
|
.poll(async () => Boolean(await json(await request.get(f.chatPath))))
|
|
.toBe(true);
|
|
const chat = await json(await request.get(f.chatPath));
|
|
expect(chat.id).toBeTruthy();
|
|
expect(
|
|
await json(
|
|
await request.get(`/api/companies/${f.company.id}/heartbeat-runs`),
|
|
),
|
|
).toHaveLength(0);
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(await json(await request.get(`/api/issues/${chat.id}/attachments`)))
|
|
.length,
|
|
)
|
|
.toBe(1);
|
|
await send(page, "Read these notes later; just acknowledge.");
|
|
await idle(request, f.chatPath);
|
|
await page.reload();
|
|
await expect(
|
|
page.getByRole("tab", { name: "Properties", exact: true }),
|
|
).toHaveCount(0);
|
|
expect(
|
|
await json(await request.get(`/api/issues/${chat.id}/attachments`)),
|
|
).toHaveLength(1);
|
|
} finally {
|
|
await f.restore();
|
|
}
|
|
});
|
|
|
|
for (const mode of ["Ask", "Plan"])
|
|
test(`${mode} denies project mutations; Plan can draft and revise without execution`, async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const f = await setup(request);
|
|
try {
|
|
await page.goto(f.route);
|
|
await page.getByTestId("task-chat-composer-add").click();
|
|
await page.getByTestId(mode === "Plan" ? "composer-add-plan" : "composer-add-ask").click();
|
|
await send(page, { action: "project", name: "Forbidden mutation" });
|
|
await idle(request, f.chatPath);
|
|
expect(
|
|
await json(
|
|
await request.get(`/api/companies/${f.company.id}/projects`),
|
|
),
|
|
).toHaveLength(0);
|
|
await expect(
|
|
page.getByRole("article", { name: /Project created:/ }),
|
|
).toHaveCount(0);
|
|
if (mode === "Plan") {
|
|
await send(page, {
|
|
action: "plan",
|
|
text: "# Draft plan\nDiscuss the goal.",
|
|
});
|
|
const chat = await idle(request, f.chatPath, 2);
|
|
const first = await json(
|
|
await request.get(`/api/issues/${chat.id}/documents/plan`),
|
|
);
|
|
await send(page, {
|
|
action: "plan",
|
|
text: "# Revised plan\nDiscuss the revised goal.",
|
|
});
|
|
await idle(request, f.chatPath, 3);
|
|
const revised = await json(
|
|
await request.get(`/api/issues/${chat.id}/documents/plan`),
|
|
);
|
|
expect(revised.latestRevisionId).not.toBe(first.latestRevisionId);
|
|
expect(revised.body).toContain("Revised plan");
|
|
await expect(
|
|
page.getByRole("tab", { name: "Plan", exact: true }),
|
|
).toBeVisible();
|
|
}
|
|
expect(
|
|
await json(await request.get(`/api/companies/${f.company.id}/issues`)),
|
|
).toHaveLength(0);
|
|
} finally {
|
|
await f.restore();
|
|
}
|
|
});
|
|
|
|
for (const selection of [
|
|
{ ids: ["101"] },
|
|
{ ids: ["101", "102"] },
|
|
{
|
|
ids: ["101"],
|
|
urls: [
|
|
"https://github.com/chat-fixture/frontend",
|
|
"https://github.com/octocat/Hello-World",
|
|
],
|
|
},
|
|
])
|
|
test(`authorized repository discovery and selection: ${JSON.stringify(selection)}`, async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const f = await setup(request);
|
|
try {
|
|
const secret = await json(
|
|
await request.post(`/api/companies/${f.company.id}/secrets`, {
|
|
data: {
|
|
name: "Deterministic GitHub credential",
|
|
value: "paperclip-e2e-repository-fixture",
|
|
},
|
|
}),
|
|
);
|
|
await json(
|
|
await request.post(`/api/companies/${f.company.id}/tools/connections`, {
|
|
data: {
|
|
name: "Fixture GitHub",
|
|
applicationName: "Fixture GitHub",
|
|
transport: "rest_api",
|
|
authKind: "api_key",
|
|
credentialPolicy: "shared",
|
|
status: "active",
|
|
enabled: true,
|
|
config: {
|
|
sourceTemplateKey: "github",
|
|
baseUrl: "https://api.github.com",
|
|
},
|
|
credentialSecretRefs: [
|
|
{
|
|
configPath: "headers.Authorization",
|
|
secretId: secret.id,
|
|
versionSelector: "latest",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
const repos = await json(
|
|
await request.get(
|
|
`/api/companies/${f.company.id}/project-repositories`,
|
|
),
|
|
);
|
|
expect(repos.repositories.map((repo: any) => repo.id).sort()).toEqual([
|
|
"101",
|
|
"102",
|
|
]);
|
|
await page.goto(f.route);
|
|
await send(page, {
|
|
action: "project",
|
|
name: "Selected repositories",
|
|
...selection,
|
|
});
|
|
await idle(request, f.chatPath);
|
|
const project = (
|
|
await json(await request.get(`/api/companies/${f.company.id}/projects`))
|
|
)[0];
|
|
expect(project).toBeTruthy();
|
|
expect(
|
|
project.workspaces
|
|
.map((w: any) => w.metadata?.githubRepositoryId)
|
|
.filter(Boolean)
|
|
.sort(),
|
|
).toEqual(selection.ids);
|
|
expect(new Set(project.workspaces.map((w: any) => w.repoUrl)).size).toBe(
|
|
project.workspaces.length,
|
|
);
|
|
await expect(
|
|
page.getByRole("article", {
|
|
name: "Project created: Selected repositories",
|
|
}),
|
|
).toHaveCount(1);
|
|
} finally {
|
|
await f.restore();
|
|
}
|
|
});
|
|
|
|
test("plan approval hands the preserved revision to an assigned project task", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const f = await setup(request);
|
|
try {
|
|
await page.goto(f.route);
|
|
await page.getByTestId("task-chat-composer-add").click();
|
|
await page.getByTestId("composer-add-plan").click();
|
|
await send(page, {
|
|
action: "plan",
|
|
text: "# Approved welcome\nWrite two friendly sentences.",
|
|
approval: true,
|
|
});
|
|
const chat = await idle(request, f.chatPath);
|
|
const original = await json(
|
|
await request.get(`/api/issues/${chat.id}/documents/plan`),
|
|
);
|
|
expect(
|
|
await json(await request.get(`/api/companies/${f.company.id}/issues`)),
|
|
).toHaveLength(0);
|
|
await page
|
|
.getByRole("button", { name: "Approve handoff", exact: true })
|
|
.last()
|
|
.click();
|
|
await idle(request, f.chatPath, 2);
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(
|
|
await json(
|
|
await request.get(`/api/companies/${f.company.id}/issues`),
|
|
)
|
|
).filter((task: any) => task.status === "done").length,
|
|
{ timeout: 60_000 },
|
|
)
|
|
.toBe(1);
|
|
const task = (
|
|
await json(await request.get(`/api/companies/${f.company.id}/issues`))
|
|
)[0];
|
|
expect(task.parentId).toBeNull();
|
|
expect(task.projectId).toBeTruthy();
|
|
expect(task.assigneeAgentId).toBe(f.agent.id);
|
|
const plan = await json(
|
|
await request.get(`/api/issues/${task.id}/documents/plan`),
|
|
);
|
|
const output = await json(
|
|
await request.get(`/api/issues/${task.id}/documents/output`),
|
|
);
|
|
expect(plan.body).toContain(original.body);
|
|
expect(output.body).toContain(plan.body);
|
|
expect(
|
|
(await json(await request.get(`/api/issues/${chat.id}/documents/plan`)))
|
|
.latestRevisionId,
|
|
).toBe(original.latestRevisionId);
|
|
await expect(
|
|
page.getByRole("article", {
|
|
name: "Project created: Approved plan project",
|
|
}),
|
|
).toHaveCount(1);
|
|
} finally {
|
|
await f.restore();
|
|
}
|
|
});
|
|
|
|
test("shared questions resume and existing project reuse creates no project card", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const f = await setup(request);
|
|
try {
|
|
const project = await json(
|
|
await request.post(`/api/companies/${f.company.id}/projects`, {
|
|
data: { name: "Garden club" },
|
|
}),
|
|
);
|
|
await page.goto(f.route);
|
|
await send(page, { action: "question" });
|
|
await expect(
|
|
page.getByRole("radio", { name: "Garden club", exact: true }).last(),
|
|
).toBeVisible();
|
|
await page
|
|
.getByRole("radio", { name: "Garden club", exact: true })
|
|
.last()
|
|
.click();
|
|
await page
|
|
.getByRole("button", { name: "Submit answers", exact: true })
|
|
.last()
|
|
.click();
|
|
const chat = await idle(request, f.chatPath, 2);
|
|
await expect(
|
|
page.getByText("Reply generation 0: Clarification received.", {
|
|
exact: true,
|
|
}),
|
|
).toBeVisible();
|
|
await send(page, { action: "handoff", projectId: project.id });
|
|
await idle(request, f.chatPath, 3);
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(
|
|
await json(
|
|
await request.get(`/api/companies/${f.company.id}/issues`),
|
|
)
|
|
).length,
|
|
)
|
|
.toBe(1);
|
|
const task = (
|
|
await json(await request.get(`/api/companies/${f.company.id}/issues`))
|
|
)[0];
|
|
expect(task.projectId).toBe(project.id);
|
|
expect(task.parentId).toBeNull();
|
|
expect(
|
|
await json(await request.get(`/api/companies/${f.company.id}/projects`)),
|
|
).toHaveLength(1);
|
|
await expect(
|
|
page.getByRole("article", { name: /Project created:/ }),
|
|
).toHaveCount(0);
|
|
const ordinaryChild = await json(
|
|
await request.post(`/api/companies/${f.company.id}/issues`, {
|
|
data: { title: "Ordinary delegation still works", parentId: task.id },
|
|
}),
|
|
);
|
|
expect(ordinaryChild.parentId).toBe(task.id);
|
|
expect(
|
|
(await json(await request.get(`/api/issues/${chat.id}`))).status,
|
|
).toBe("in_review");
|
|
} finally {
|
|
await f.restore();
|
|
}
|
|
});
|
|
|
|
test("shared history loads older messages without replacing the latest turn", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const f = await setup(request);
|
|
try {
|
|
await page.goto(f.route);
|
|
await send(page, { action: "history" });
|
|
await idle(request, f.chatPath, 65);
|
|
await page.reload();
|
|
await expect(
|
|
page.getByText("History message 64", { exact: true }),
|
|
).toBeVisible();
|
|
// Scroll the shared transcript, including its older-page sentinel.
|
|
await page.getByText("History message 64", { exact: true }).hover();
|
|
for (
|
|
let attempt = 0;
|
|
attempt < 5 &&
|
|
!(await page.getByText("History message 00", { exact: true }).count());
|
|
attempt++
|
|
) {
|
|
await page.mouse.wheel(0, -12000);
|
|
await expect
|
|
.poll(async () => page.getByText(/History message/).count())
|
|
.toBeGreaterThan(40);
|
|
}
|
|
await expect(
|
|
page.getByText("History message 00", { exact: true }),
|
|
).toBeAttached();
|
|
await expect(
|
|
page.getByText("History message 64", { exact: true }),
|
|
).toBeAttached();
|
|
} finally {
|
|
await f.restore();
|
|
}
|
|
});
|
|
|
|
test("disabling the experiment lets an active turn settle and keeps idle history", async ({
|
|
page,
|
|
request,
|
|
}) => {
|
|
const original = await json(
|
|
await request.get("/api/instance/settings/experimental"),
|
|
);
|
|
expect(original.enableAgentChat).toBe(false);
|
|
const f = await setup(request);
|
|
try {
|
|
await page.goto(f.route);
|
|
await send(page, { action: "delayed" });
|
|
await expect(
|
|
page.getByText("Turn started before feature disable.", { exact: true }),
|
|
).toBeVisible();
|
|
const chat = await json(await request.get(f.chatPath));
|
|
await json(
|
|
await request.patch("/api/instance/settings/experimental", {
|
|
data: { enableAgentChat: false },
|
|
}),
|
|
);
|
|
await expect
|
|
.poll(async () =>
|
|
(await json(await request.get(`/api/issues/${chat.id}/comments`))).some(
|
|
(c: any) => c.body === "Active turn settled after feature disable.",
|
|
),
|
|
)
|
|
.toBe(true);
|
|
await expect
|
|
.poll(
|
|
async () =>
|
|
(await json(await request.get(`/api/issues/${chat.id}`)))
|
|
.conversationState,
|
|
)
|
|
.toBe("waiting");
|
|
expect(
|
|
(
|
|
await request.post(`/api/issues/${chat.id}/comments`, {
|
|
data: { body: "/new" },
|
|
})
|
|
).status(),
|
|
).toBe(404);
|
|
const before = (
|
|
await json(
|
|
await request.get(`/api/companies/${f.company.id}/heartbeat-runs`),
|
|
)
|
|
).length;
|
|
await page.reload();
|
|
await expect(page.getByText(/Agent Chat is disabled/)).toBeVisible();
|
|
expect(
|
|
(
|
|
await json(
|
|
await request.get(`/api/companies/${f.company.id}/heartbeat-runs`),
|
|
)
|
|
).length,
|
|
).toBe(before);
|
|
} finally {
|
|
await f.restore();
|
|
}
|
|
});
|