mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 01:27:09 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - People open tasks from their personal workspace and send replies to agents. > - Cold task opens should load recent conversation before older history. > - Classic settings could expand history before the Coordinator preference arrived. > - Coordinator lists also read full tasks, and response startup used the previous assignee. > - This pull request restores compact reads and keeps the selected responder visible through runtime discovery. > - People can read and reply without waiting for unrelated history or losing response feedback. ## Linked Issues or Issue Description Related: #17593, #17751, #17756, #17885, #17983, and #18092. **What happened?** A cold task could select Classic full history while its personal workspace preference was pending. The full-history choice stayed cached after Coordinator became active. Coordinator Tasks used full task reads. Needs attention fetched one enriched task per pending task. Sending to a newly selected agent could show no responder, show the previous assignee, or clear feedback when assignment changed. Legacy runtime discovery then replaced the startup identity with a generic activity line. **Expected behavior** Wait for the workspace preference before selecting Classic history. Show the task instruction and recent conversation with **More messages** for older content. Read collection indexes and fetch diagnostics for visible rows. Keep the selected responder mounted from Send through saved input and runtime startup. **Steps to reproduce** 1. Enable Classic task UI and Coordinator. Delay the personal workspace preference on a cold task open. 2. Open Coordinator Tasks or its Needs attention view with many pending tasks. 3. Open a task assigned to a person. Select another agent in the composer and send a message. 4. Let the message save before runtime reads return. Then allow a legacy run to appear. ## What Changed - Wait for task interface settings before persisting Classic history expansion. - Use lightweight indexes for Coordinator Tasks. Resolve pending task IDs in batches of at most 100. Keep search and participant filters on the server. Enrich at most 50 visible rows through the existing hook without replacing list membership or order. - Preserve an error when a pending task cannot load, instead of showing a false all-clear. - Register board cards for row enrichment. Observe newly mounted cards after column expansion or pagination, and track visibility by element so hidden list rows cannot clear visible board cards with the same task ID. - Track the recipient of each submitted message through reassignment. A later draft's recipient does not change the response already starting. - Share a stable response header across startup, native runtime, and legacy runtime rendering. Keep the existing transcript renderers. - Add regression tests, production-browser checks, design guidance, and feature-map notes. ## Verification - Red/green: delayed Coordinator preference started a full comments read before the fix. It now retains compact history and still expands on request. - Red/green: sends from human or other-agent assignment showed no identity or the wrong identity. Both now retain the selected responder through assignment, acknowledgement, later draft selection, and native/legacy startup. - In a 105-task Needs attention fixture, the previous code made 105 enriched detail requests. The fixed code makes two bounded index requests. - All 659 final affected tests pass across task detail, thread, runner turn, history window, Inbox, board, row enrichment, and Coordinator collection suites. - Review follow-up: the production board lost its next-step badge before card registration. The regression now passes, along with column pagination. The focused 82-test enrichment run and 371-test header/thread run also pass. - All 12 compact-startup browser checks pass against the production UI and an isolated PostgreSQL container. They cover delayed preferences, list navigation, saved-card geometry, document/comment anchors, history expansion, draft retention, retry, and actual legacy process startup. The final deterministic process case recorded 118 frames with zero missing or replaced responder identities. It does not call a model provider. - Full workspace build and typecheck pass. Final UI build and typecheck pass after the shared-header change. Token gates, map checks, module boundaries, and diff checks pass. - The full UI rerun before the board review follow-up passed all 10,034 tests across 844 files. The 659 affected tests pass after that follow-up. The first full UI run passed 10,030 tests and timed out in one unchanged IssuesList large-tree test. Its two variants pass in an isolated rerun and in the full rerun. Those later passes establish recovery; the earlier timeout's cause remains unproven. - The full `pnpm test:run` attempt was stopped after repeated embedded PostgreSQL initialization failures in server fixtures. No complete local server-suite pass is claimed. Browser verification recovered with a disposable container database. - A later avatar browser rerun reused the previous same-title task and its cancelled run through the existing 48-hour title deduplication. That fixture never reached fresh runtime startup. The test now uses a unique title and checks its initial assignment/status; a full 12-case rerun and a second avatar run on the same instance pass. The failed trace is retained locally. - Final commit `7a18372da3` has 55 successful checks, two intentionally skipped Storybook checks, and one neutral Paperclip Review. All required verification gates are green. Greptile is 5/5 with no actionable findings or open review threads. The advisory Paperclip review paused without a complete assessment; no passing Paperclip assessment is claimed. - These checks establish bounded reads and stable rendering. No reliable staging latency benchmark or staging deployment is claimed. ## Risks - Collection projections omit task bodies. Detail navigation must retain its normal core/full task reads. Search remains server-side. - Startup feedback must end on rejection, unavailable agents, terminal outcomes, or a visible run. Existing cases cover those transitions. - The shared header changes the legacy live-turn layout to retain identity beside its existing status line. - No migration, mutation permission, provider protocol, or execution authority change. ## Model Used OpenAI Codex, GPT-6 family. The exact serving deployment ID and context-window size are not exposed in this session. The model used reasoning, code editing, shell tools, automated Chromium checks, and manual browser inspection. ## 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 (focused suites and browser checks; full-suite limitations are recorded above) - [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>
307 lines
19 KiB
TypeScript
307 lines
19 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { expect, test, type Page } from "@playwright/test";
|
|
|
|
test.setTimeout(60_000);
|
|
test.use({ video: "on", trace: "retain-on-failure", serviceWorkers: "block", viewport: { width: 1440, height: 900 } });
|
|
|
|
let fixture: { id: string; identifier: string; prefix: string; oldestCommentId: string };
|
|
const fixturePath = path.resolve("test-results/compact-startup-fixture.json");
|
|
test.beforeAll(async ({ request }) => {
|
|
const saved = await fs.readFile(fixturePath, "utf8").then(text => JSON.parse(text)).catch(() => null);
|
|
if (saved) {
|
|
const existing = await request.get(`/api/issues/${saved.id}`);
|
|
if (existing.ok()) { fixture = saved; return; }
|
|
// A different disposable instance needs a new fixture. Server failures
|
|
// must fail the test rather than silently creating another company.
|
|
expect(existing.status(), await existing.text()).toBe(404);
|
|
}
|
|
const company = await (await request.post("/api/companies", { data: { name: "Task loading reproduction" } })).json();
|
|
const response = await request.post(`/api/companies/${company.id}/issues`, { data: {
|
|
title: "Review the latest investigation", status: "backlog", description: "Investigate the latest reported pattern and keep the saved evidence together.",
|
|
} });
|
|
expect(response.ok(), await response.text()).toBeTruthy();
|
|
const issue = await response.json();
|
|
let oldestCommentId = "";
|
|
for (let index = 0; index < 24; index++) {
|
|
const comment = await request.post(`/api/issues/${issue.id}/comments`, { data: { body: index === 23
|
|
? "The saved update is ready. [Read the investigation](#document-investigation). The recent reply must stay in the same place while cards load."
|
|
: `Historical investigation message ${index}. ${"Saved findings and verification details. ".repeat(5)}` } });
|
|
expect(comment.ok()).toBeTruthy();
|
|
if (!index) oldestCommentId = (await comment.json()).id;
|
|
}
|
|
for (const [key, title] of [["plan", "Investigation and verification plan"], ["investigation", "Saved investigation"], ["verification", "Verification evidence"], ["handoff", "Engineering handoff"], ["continuation-summary", "Continuation summary"]]) {
|
|
const document = await request.put(`/api/issues/${issue.id}/documents/${key}`, { data: {
|
|
title, format: "markdown", body: `# ${title}\n\nInspect the original report.\n\nVerify the saved evidence.\n\nKeep the reader's position stable.`,
|
|
} });
|
|
expect(document.ok(), await document.text()).toBeTruthy();
|
|
}
|
|
for (let index = 0; index < 3; index++) {
|
|
const product = await request.post(`/api/issues/${issue.id}/work-products`, { data: {
|
|
type: "pull_request", provider: "github", title: `Investigation fix ${index + 1}`, status: "merged",
|
|
url: `https://github.com/example/reproduction/pull/${index + 1}`, metadata: { repository: "example/reproduction", number: index + 1 },
|
|
} });
|
|
expect(product.ok(), await product.text()).toBeTruthy();
|
|
}
|
|
fixture = { id: issue.id, identifier: issue.identifier, prefix: company.issuePrefix, oldestCommentId };
|
|
await fs.mkdir(path.dirname(fixturePath), { recursive: true });
|
|
await fs.writeFile(fixturePath, JSON.stringify(fixture, null, 2));
|
|
});
|
|
|
|
async function ready(page: Page) {
|
|
await expect(page.getByTestId("task-chat-thread").locator(':scope > [aria-busy="false"]')).toBeVisible({ timeout: 10_000 });
|
|
}
|
|
|
|
async function coordinatorPreference(page: Page, waitForPreference = Promise.resolve()) {
|
|
await page.route("**/api/instance/settings/experimental", async route => {
|
|
const response = await route.fetch();
|
|
await route.fulfill({ json: { ...await response.json(), enableClassicTaskInterface: true } });
|
|
});
|
|
await page.route("**/api/workspace-preferences/me", async route => {
|
|
await waitForPreference;
|
|
const response = await route.fetch();
|
|
await route.fulfill({ json: { ...await response.json(), coordinatorWorkspaceEnabled: true } });
|
|
});
|
|
}
|
|
|
|
test("a delayed Coordinator preference cannot expand a cold task through Classic settings", async ({ page }) => {
|
|
let release!: () => void;
|
|
const held = new Promise<void>(resolve => { release = resolve; });
|
|
await coordinatorPreference(page, held);
|
|
const historyRequests: string[] = [];
|
|
page.on("request", request => {
|
|
const url = new URL(request.url());
|
|
if (/\/api\/issues\/[^/]+\/(comments|activity|runs)$/.test(url.pathname) &&
|
|
!["recent", "comment-inputs"].includes(url.searchParams.get("view") ?? "")) historyRequests.push(url.pathname + url.search);
|
|
});
|
|
try {
|
|
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}`);
|
|
await expect(page.getByTestId("issue-detail-header")).toContainText("Review the latest investigation");
|
|
expect(historyRequests).toEqual([]);
|
|
release();
|
|
await ready(page);
|
|
await expect(page.getByRole("button", { name: "More messages", exact: true })).toBeVisible();
|
|
expect(historyRequests).toEqual([]);
|
|
await page.getByRole("button", { name: "More messages", exact: true }).click();
|
|
await expect(page.getByText("Historical investigation message 0.", { exact: false }).first()).toBeAttached();
|
|
expect(historyRequests.length).toBeGreaterThan(0);
|
|
} finally { release(); }
|
|
});
|
|
|
|
test("Coordinator task rows use index reads and open a compact conversation", async ({ page }) => {
|
|
await coordinatorPreference(page);
|
|
const listReads: string[] = [];
|
|
page.on("request", request => {
|
|
const url = new URL(request.url());
|
|
if (/\/api\/companies\/[^/]+\/issues$/.test(url.pathname)) listReads.push(url.searchParams.get("view") ?? "full");
|
|
});
|
|
await page.goto(`/${fixture.prefix}/issues`);
|
|
const row = page.locator("[data-inbox-issue-link]", { hasText: "Review the latest investigation" }).first();
|
|
await expect(row).toBeVisible();
|
|
expect(listReads).toContain("index");
|
|
expect(listReads).not.toContain("full");
|
|
await row.click();
|
|
await ready(page);
|
|
await expect(page.getByRole("button", { name: "More messages", exact: true })).toBeVisible();
|
|
});
|
|
|
|
test("core rich cards reveal before duplicate reads and stay stable during slow enrichment", async ({ page }, info) => {
|
|
await page.addInitScript(() => { window.__PAPERCLIP_ISSUE_DETAIL_PROFILE__ = true; });
|
|
await page.addInitScript(() => {
|
|
const frames: unknown[] = [];
|
|
Object.assign(window, { compactStartupFrames: frames });
|
|
const sample = () => {
|
|
const thread = document.querySelector('[data-testid="task-chat-thread"] > [aria-busy="false"]');
|
|
const scroller = thread?.querySelector('[data-testid="task-chat-scroller"]');
|
|
if (scroller) {
|
|
const cards = [...scroller.querySelectorAll('[data-testid="task-chat-resource-document"], [data-testid="task-chat-plan-preview"], [data-testid="task-chat-rich-work-product-pull_request"], [data-testid="task-chat-resource-deliverable"]')];
|
|
frames.push({ time: performance.now(), height: scroller.scrollHeight, width: scroller.clientWidth, cards: cards.length, boxes: cards.map(card => {
|
|
const box = card.getBoundingClientRect(); return { top: box.top, height: box.height };
|
|
}) });
|
|
}
|
|
requestAnimationFrame(sample);
|
|
};
|
|
requestAnimationFrame(sample);
|
|
});
|
|
let release!: () => void;
|
|
const held = new Promise<void>(resolve => { release = resolve; });
|
|
await page.route(/\/api\/issues\/[^/?]+$/, async route => { await held; await route.continue(); });
|
|
await page.route(/\/api\/issues\/[^/?]+\/work-products$/, async route => { await held; await route.continue(); });
|
|
try {
|
|
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}`);
|
|
await ready(page);
|
|
await page.waitForTimeout(250);
|
|
release();
|
|
await expect(page.getByTestId("task-chat-resource-document")).toHaveCount(3);
|
|
await expect(page.getByTestId("task-chat-plan-preview")).toHaveCount(1);
|
|
await page.waitForTimeout(500);
|
|
const frames = await page.evaluate(() => (window as unknown as { compactStartupFrames: { height: number; width: number; cards: number; boxes: { top: number; height: number }[] }[] }).compactStartupFrames);
|
|
await fs.writeFile(info.outputPath("layout.json"), JSON.stringify(frames, null, 2));
|
|
await page.screenshot({ path: info.outputPath("settled.png") });
|
|
expect(frames.length).toBeGreaterThan(5);
|
|
expect(new Set(frames.map(frame => frame.cards)).size, "Cards must arrive before the conversation becomes readable").toBe(1);
|
|
expect(Math.max(...frames.map(frame => frame.height)) - Math.min(...frames.map(frame => frame.height)), "Initial content must not grow after reveal").toBeLessThanOrEqual(2);
|
|
expect(Math.max(...frames.map(frame => frame.width)) - Math.min(...frames.map(frame => frame.width)), "Initial pane geometry must settle before reveal").toBeLessThanOrEqual(2);
|
|
for (let card = 0; card < frames[0].cards; card++) {
|
|
for (const dimension of ["top", "height"] as const) {
|
|
const values = frames.map(frame => frame.boxes[card][dimension]);
|
|
expect(Math.max(...values) - Math.min(...values), `Card ${card} ${dimension} stays stable after reveal`).toBeLessThanOrEqual(2);
|
|
}
|
|
}
|
|
} finally {
|
|
await fs.writeFile(info.outputPath("readiness.json"), JSON.stringify(await page.evaluate(() => performance.getEntriesByType("mark").map(entry => ({ ...entry.toJSON(), detail: (entry as PerformanceMark).detail }))), null, 2));
|
|
release();
|
|
}
|
|
});
|
|
|
|
for (const anchor of ["document", "comment", "unknown", "continuation"]) {
|
|
test(`${anchor} anchors keep compact loading and resolve their target`, async ({ page }) => {
|
|
const requests: string[] = [];
|
|
page.on("request", request => { if (request.url().includes("/api/issues/")) requests.push(request.url()); });
|
|
const hash = anchor === "comment" ? `#comment-${fixture.oldestCommentId}` : anchor === "document" ? "#document-investigation" : anchor === "continuation" ? "#document-continuation-summary" : "#unknown";
|
|
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}${hash}`);
|
|
await ready(page);
|
|
await expect(page.getByRole("button", { name: "More messages", exact: true })).toBeVisible();
|
|
await expect(page.getByText("The saved update is ready.", { exact: false }).first()).toBeAttached();
|
|
if (anchor === "comment") await expect(page.locator(`[id="comment-${fixture.oldestCommentId}"]`)).toBeVisible();
|
|
if (anchor === "document") await expect(page.locator('[id="side-panel-content-document:investigation"]')).toBeVisible();
|
|
if (anchor === "continuation") await expect(page.locator('[id="side-panel-content-document:continuation-summary"]')).toBeVisible();
|
|
expect(requests.some(url => /\/comments\?/.test(url))).toBe(false);
|
|
expect(requests.some(url => /\/(activity|runs)(?:\?|$)/.test(url) && !url.includes("view=recent") && !url.includes("comment-inputs"))).toBe(false);
|
|
});
|
|
}
|
|
|
|
test("opening documents and older comments keeps the mounted composer and compact window", async ({ page }) => {
|
|
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}`);
|
|
await ready(page);
|
|
const editor = page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]');
|
|
await editor.fill("A draft stays here while I inspect saved evidence.");
|
|
await editor.evaluate(element => { element.setAttribute("data-mount-proof", "retained"); });
|
|
await page.getByRole("link", { name: "Read the investigation", exact: true }).click();
|
|
await expect(page.locator('[id="side-panel-content-document:investigation"]')).toBeVisible();
|
|
await expect(editor).toHaveAttribute("data-mount-proof", "retained");
|
|
await expect(editor).toHaveText("A draft stays here while I inspect saved evidence.");
|
|
await page.getByRole("button", { name: "More messages", exact: true }).click();
|
|
await expect(page.getByText("Historical investigation message 0.", { exact: false }).first()).toBeAttached();
|
|
await expect(editor).toHaveAttribute("data-mount-proof", "retained");
|
|
});
|
|
|
|
test("opening a document from an older linked comment retains that row and its position", async ({ page, request }) => {
|
|
// Give the real saved target a document link without inventing a browser row.
|
|
const target = await (await request.get(`/api/issues/${fixture.id}/comments/${fixture.oldestCommentId}`)).json();
|
|
await page.route(`**/api/issues/${fixture.id}/comments/${fixture.oldestCommentId}`, route => route.fulfill({ json: { ...target, body: `${target.body}\n\n[Inspect saved evidence](#document-investigation)` } }));
|
|
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}#comment-${fixture.oldestCommentId}`, { waitUntil: "domcontentloaded" });
|
|
await ready(page);
|
|
const row = page.locator(`[id="comment-${fixture.oldestCommentId}"]`);
|
|
const before = (await row.boundingBox())!.y;
|
|
await row.getByRole("link", { name: "Inspect saved evidence", exact: true }).click();
|
|
await expect(page.locator('[id="side-panel-content-document:investigation"]')).toBeVisible();
|
|
await expect(row).toBeVisible();
|
|
await expect.poll(async () => Math.abs((await row.boundingBox())!.y - before)).toBeLessThanOrEqual(2);
|
|
await expect(page.getByRole("button", { name: "More messages", exact: true })).toBeVisible();
|
|
});
|
|
|
|
test("a failed linked comment keeps recent messages readable and retries without expanding history", async ({ page }) => {
|
|
const targetPath = `**/api/issues/${fixture.id}/comments/${fixture.oldestCommentId}`;
|
|
await page.route(targetPath, route => route.fulfill({ status: 404, contentType: "application/json", body: '{"error":"Comment not found"}' }));
|
|
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}#comment-${fixture.oldestCommentId}`);
|
|
await ready(page);
|
|
await expect(page.getByText("The saved update is ready.", { exact: false }).first()).toBeAttached();
|
|
await expect(page.getByRole("button", { name: "More messages", exact: true })).toBeVisible();
|
|
await page.unroute(targetPath);
|
|
await page.getByRole("button", { name: "Retry", exact: true }).click();
|
|
await expect(page.locator(`[id="comment-${fixture.oldestCommentId}"]`)).toBeVisible();
|
|
await expect(page.getByRole("button", { name: "More messages", exact: true })).toBeVisible();
|
|
});
|
|
|
|
test("late provider metadata keeps saved PR card geometry stable", async ({ page }) => {
|
|
let release!: () => void;
|
|
const held = new Promise<void>(resolve => { release = resolve; });
|
|
await page.route("**/api/issues/*/work-products?refreshPullRequests=true", async route => {
|
|
const response = await route.fetch();
|
|
const products = await response.json();
|
|
await held;
|
|
await route.fulfill({ response, json: products.map((product: { metadata: object }) => ({ ...product, metadata: { ...product.metadata, additions: 78, deletions: 2, filesChanged: 5 } })) });
|
|
});
|
|
try {
|
|
await page.goto(`/${fixture.prefix}/issues/${fixture.identifier}`);
|
|
await ready(page);
|
|
const cards = page.getByTestId("task-chat-rich-work-product-pull_request");
|
|
await expect(cards).toHaveCount(3);
|
|
const before = await cards.evaluateAll(elements => elements.map(element => element.getBoundingClientRect().height));
|
|
release();
|
|
await expect(cards.first()).toContainText("+78");
|
|
const after = await cards.evaluateAll(elements => elements.map(element => element.getBoundingClientRect().height));
|
|
expect(after).toEqual(before);
|
|
} finally { release(); }
|
|
});
|
|
|
|
test("the selected responder stays mounted through comment acknowledgement and legacy startup", async ({ page, request }, info) => {
|
|
const source = await (await request.get(`/api/issues/${fixture.id}`)).json();
|
|
const agentResponse = await request.post(`/api/companies/${source.companyId}/agents`, { data: {
|
|
name: "Startup responder", role: "engineer", adapterType: "process",
|
|
adapterConfig: { command: process.execPath, args: ["-e", "setTimeout(() => console.log('Fixture finished.'), 12000)"] },
|
|
runtimeConfig: { heartbeat: { enabled: false, wakeOnDemand: true } },
|
|
} });
|
|
expect(agentResponse.ok(), await agentResponse.text()).toBeTruthy();
|
|
const agent = await agentResponse.json();
|
|
const issueResponse = await request.post(`/api/companies/${source.companyId}/issues`, { data: {
|
|
title: `Keep the selected responder visible ${Date.now()}`, status: "in_review", assigneeUserId: "local-board",
|
|
description: "This disposable process fixture verifies the send transition without a model provider.",
|
|
} });
|
|
expect(issueResponse.ok(), await issueResponse.text()).toBeTruthy();
|
|
const issue = await issueResponse.json();
|
|
// Repeated runs must not adopt the previous fixture through same-title dedup.
|
|
expect(issue).toMatchObject({ status: "in_review", assigneeUserId: "local-board", assigneeAgentId: null });
|
|
await coordinatorPreference(page);
|
|
await page.addInitScript(() => {
|
|
const frames: { visible: boolean; same: boolean }[] = [];
|
|
let first: Element | null = null;
|
|
Object.assign(window, { responderFrames: frames });
|
|
const sample = () => {
|
|
const identity = document.querySelector('[data-testid="task-chat-live-transcript"] [data-testid="task-chat-agent-identity"]');
|
|
first ??= identity;
|
|
if (first) frames.push({ visible: !!identity && identity.getBoundingClientRect().height > 0, same: first === identity });
|
|
requestAnimationFrame(sample);
|
|
};
|
|
requestAnimationFrame(sample);
|
|
});
|
|
let release!: () => void;
|
|
const held = new Promise<void>(resolve => { release = resolve; });
|
|
let holdRuntime = false;
|
|
await page.route(/\/api\/issues\/[^/]+\/(active-run|live-runs|runs)(\?|$)/, async route => {
|
|
if (holdRuntime && route.request().method() === "GET") await held;
|
|
await route.continue();
|
|
});
|
|
try {
|
|
await page.goto(`/${fixture.prefix}/issues/${issue.identifier}`);
|
|
await ready(page);
|
|
await page.getByTestId("task-chat-composer-assignee").click();
|
|
await page.locator(`[data-assignee-identity="agent:${agent.id}"]`).click();
|
|
await page.getByTestId("task-chat-composer-stack").locator('[contenteditable="true"]').fill("Keep your identity visible through startup.");
|
|
holdRuntime = true;
|
|
const saved = page.waitForResponse(response => response.request().method() === "PATCH" &&
|
|
[issue.id, issue.identifier].some(ref => new URL(response.url()).pathname === `/api/issues/${ref}`));
|
|
await page.getByTestId("task-chat-composer-send").click();
|
|
expect((await saved).ok()).toBeTruthy();
|
|
const identity = page.getByTestId("task-chat-live-transcript").getByTestId("task-chat-agent-identity");
|
|
await expect(identity).toHaveText(agent.name);
|
|
await page.screenshot({ path: info.outputPath("startup.png") });
|
|
release();
|
|
await expect(page.getByTestId("task-chat-live-run-pill")).toBeVisible();
|
|
await expect(identity).toHaveText(agent.name);
|
|
await page.screenshot({ path: info.outputPath("running.png") });
|
|
const frames = await page.evaluate(() => (window as unknown as { responderFrames: { visible: boolean; same: boolean }[] }).responderFrames);
|
|
await fs.writeFile(info.outputPath("avatar-frames.json"), JSON.stringify(frames, null, 2));
|
|
expect(frames.length).toBeGreaterThan(2);
|
|
expect(frames.every(frame => frame.visible && frame.same), "The responder must remain visible in the same mounted identity").toBe(true);
|
|
} finally {
|
|
release();
|
|
const active = await request.get(`/api/issues/${issue.id}/active-run`);
|
|
if (active.ok()) {
|
|
const run = await active.json();
|
|
if (run?.id) await request.post(`/api/heartbeat-runs/${run.id}/cancel`);
|
|
}
|
|
}
|
|
});
|