mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-13 04:02:28 +02:00
## Thinking Path > - Paperclip manages agents and their tasks. > - Browser and runner tests check task recovery and user actions. > - Some fixtures act before a prior request or run has settled. > - Those races can report a product failure before the intended assertion runs. > - This PR binds each check to the exact request, run, or geometry snapshot it needs. > - The tests retain their existing budgets and negative assertions. ## Linked Issues or Issue Description Refs: #15813, #15804. The draft stack is master → [fixture prerequisite #15878](https://github.com/paperclipai/paperclip/pull/15878) → [runtime prerequisite #15880](https://github.com/paperclipai/paperclip/pull/15880) → [Boat core #15813](https://github.com/paperclipai/paperclip/pull/15813) → [UI #15804](https://github.com/paperclipai/paperclip/pull/15804). GitHub confirms 5, 63, 97, and 75 changed files respectively, each below the 100-file limit. Existing commits and PR URLs remain intact. [Historical evidence](https://github.com/paperclipai/paperclip/tree/5909bfac9fa41405beb3fe8a65253dc28ba52835/doc/plans/assets/2026-10-10-boat) remains pinned to its original commit; current proof files stay with the UI PR. This is the test-only prerequisite extracted from the experimental Boat work. It contains five existing test-file changes. It does not add the Boat adapter or change task admission. ## What Changed - Wait for the exact signoff fixture run and each replacement run to succeed before applying the next stage decision. - Wait for the inbox Retry response for the exact failed run before navigating away. - Compare task planning time with the first run that actually started, instead of assuming the first list item started. - Read the touch target, container, and scroll position in one browser evaluation. Keep strict containment and native gesture checks. - Bind the native recovery fixture to its disposable fake Codex executable so installed provider binaries cannot override it. ## Verification Draft branch: `codex/boat-fixture-prerequisite`, commit `e318850909c0777565b43644a4d8114c6006da60`. Base: master `e733bddc45e00efb1819fffa8677f7b0d07802c5`. Five changed files. The isolated fixture-plus-runtime stack passed 24 browser cases. Six legacy recovery cases could not reach their assertions because the fixture requires embedded PostgreSQL metadata. A normal embedded PostgreSQL attempt then failed during cluster setup on this host. These are not six passing cases. No test seam, timeout increase, or product-database fixture was used. The fixture-only commit has not yet had a separate complete local browser run. All six legacy cases subsequently passed on this exact fixture commit in PR CI browser shard 8 (20/20 total, job114470999277). The original local setup failures remain recorded. The full-stack typecheck/build results belong to the assembled fixture-plus-runtime tree and are not claimed as independent qualification of this smaller commit. Review the exact run/request filters and the unchanged rejection assertions. Run the four changed Playwright specs and the native-session-resume suite through the repository's normal fixture setup. At 13:03:24 UTC, this exact head had 53 successful checks and two skips, with no pending checks or failures. All eight browser shards passed. Greptile is 5/5 on the exact head with no unresolved threads. [Exact-head legacy browser proof](https://github.com/paperclipai/paperclip/blob/9eacca2c5321bb63f061eef4bb775b6951d5846d/doc/plans/assets/2026-10-10-boat/evidence/prerequisite-legacy-browser-ci-proof.json). Feature acceptance stays in the downstream report. Deployment29 verified `05f3725af838b5a074707df1d6ed1e193bdfe49e` at 12:23:23.944 UTC. Native Claude cold execution passed at 12:39:57.782 after 14m56s of first-use preparation. Warm continuation passed at 12:42:09.943 with the same owner, boot and session identities. Normal retirement and archive followed. The ordinary saved post-idle turn then failed: its primary attempt reported the closed `LINK_COUNT` module fence at `turn_ownership_verified` (3,711ms), and same-run recovery settled failed at 12:52:09 with identity/authority errors. Natural archive completed at 12:52:21. Numeric link counts and their direction are not yet proven. A narrower failure-only diagnostic and a bounded read-only probe are under review; neither establishes a fix for this live failure. Prior staging27/28 failures remain preserved. Local complete lifecycle passes and staging Codex/computer/HMR passes remain separate source-bound evidence. No session, quarantine, provider stop or migration history was reset. These live observations do not qualify this prerequisite by themselves. ## Risks - Waiting for the wrong run could hide a task race. The fixtures match task, agent, and replacement-run identity before proceeding. - A geometry helper could weaken touch coverage. This change preserves containment and gesture assertions and only makes their inputs one snapshot. - The six legacy browser cases passed in exact-head CI; their local PostgreSQL setup limitation remains recorded. No live Boat acceptance result follows from this test-only change. Documentation impact: no product command or behavior changes. This description records fixture scope and validation limits. ## Model Used OpenAI Codex (GPT-6 family) assisted with code, test execution, and review. The host does not expose the exact primary model ID or context window. The work used tools for source inspection and local execution. Prior architecture work used `gpt-6-astra`; an independent design review used `gpt-6.1-sol`. ## 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 issue id or instance-derived details - [ ] 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 - [x] 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>
621 lines
19 KiB
TypeScript
621 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 taskRuns = runs.filter(
|
|
(run: any) => run.contextSnapshot?.issueId === task.id,
|
|
);
|
|
expect(taskRuns.length).toBeGreaterThan(0);
|
|
const startedAt = taskRuns
|
|
.filter((run: any) => run.startedAt != null)
|
|
.map((run: any) => Date.parse(run.startedAt));
|
|
// A newer queued/cancelled run need not have started. Preserve the
|
|
// invariant against the first actual execution, not list ordering.
|
|
expect(startedAt.length).toBeGreaterThan(0);
|
|
expect(startedAt.every(Number.isFinite)).toBe(true);
|
|
expect(Date.parse(plan.updatedAt)).toBeLessThanOrEqual(
|
|
Math.min(...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);
|
|
// The delegated task finishes on its own and wakes the conversation with
|
|
// a completion reporting turn. Wait for that turn to post its reply and
|
|
// settle before reading status, or this read can catch the conversation
|
|
// mid-turn.
|
|
await idle(request, f.chatPath, 4);
|
|
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();
|
|
}
|
|
});
|