Files
locally-uncensored/e2e/coding-agent.spec.ts
purpleandClaude Opus 5.5 9a080bb7f2 3.0.7
- A trained character works in every part of Create and in chat, and a Mac can train one
- ComfyUI workflows import in the format ComfyUI saves, with subgraphs and the node packs they need
- Edit takes more reference images, as many as the graphics memory holds
- A chat can be duplicated or branched, edits keep the old answer as a version
- Character cards become personas, personas carry sampling and a reminder
- Several OpenAI-compatible servers side by side, group members that sit out
- Auto compact is on by default, digits and encoded text are counted at their real cost
- How it works: short films for the main parts of the app
- On a Mac, Create gains Talking Character, Motion Control, Music, Enhance Image, Remove Background and masks
- Reasoning effort and a reply timeout for local servers, the REA plugin for Agent and Code
- ComfyUI shared model folders are used, AMD and Intel cards get a clearer GPU path
- GitHub issues 156, 162, 165 to 177, 180, 181 and 183

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:29:18 -07:00

404 lines
19 KiB
TypeScript

import { test, expect, type Page } from '@playwright/test'
import { tauriMockInit, DEFAULT_MODEL_NAME } from './support/tauri-mock'
import { seedOnboardingDone } from './support/cloud-mock'
import { openNewChat } from './support/ui'
import { toolCalls } from './support/recorded'
/**
* End-to-end cover for the 2.6.3 agent audit, driven through the real React
* app against the built-in engine — the DEFAULT backend, and the one every
* finding below was measured on.
*
* These are the checks no unit test can make, because each one is about the
* loop, the view lifecycle and the transport agreeing with each other:
*
* A1 the Code tab streams on a NON-Ollama transport (the built-in engine
* is providerId 'openai'); it used to sit silent until the whole call
* returned, which is what the owner reported repeatedly.
* A2 a run started before a tab switch is still stoppable afterwards — the
* view unmounts, the hook is rebuilt, and the store aborter is what
* carries the handle across.
* B1 edit → test → edit → test: the second identical shell command really
* re-runs instead of being swallowed as a repeat.
* B8 the agent injects the long shell timeout, so a build is not killed at
* the Rust side's 2-minute default.
* C1 file_read pages: offset/limit return a window, not the whole file.
*/
const FILE_LINES = Array.from({ length: 200 }, (_, i) => `line ${i + 1}`).join('\n')
type Turn = { text?: string; toolCalls?: Array<{ name: string; args: Record<string, unknown> }> }
async function boot(page: Page, agentTurns: Turn[], holdTools = false) {
await page.addInitScript(tauriMockInit, {
assistantReply: 'unused in agent specs',
modelName: DEFAULT_MODEL_NAME,
replyChunkDelayMs: 8,
agentTurns,
holdTools,
files: { 'big.ts': FILE_LINES },
})
await seedOnboardingDone(page)
await page.goto('/')
// Mode first, THEN the chat: the sidebar's mode buttons clear the active
// conversation by design, so a chat opened before the switch is dropped.
await page.getByRole('button', { name: 'Code', exact: true }).click()
await openNewChat(page)
}
async function instruct(page: Page, text: string) {
const composer = page.locator('textarea').first()
// The transcript echo of the instruction. Scoped to paragraphs on purpose:
// Playwright's text engine reads a <textarea>'s VALUE as its text, so a bare
// getByText would match the COMPOSER and report an unsent message as sent.
const echoed = page.getByRole('main').locator('p').filter({ hasText: text })
await expect(composer).toBeVisible({ timeout: 20_000 })
// What the 800 ms sleep here was actually waiting for: the composer becomes
// usable only once the view has a model (ChatInput renders `disabled` until
// then and drops the keystroke), and it refuses a send while a previous turn
// is still running. Both are visible states — a disabled textarea, and the
// Send/Stop slot showing Send — so wait for those instead of for a clock.
await expect(composer).toBeEnabled({ timeout: 20_000 })
await expect(page.getByRole('button', { name: 'Send message' })).toBeVisible({ timeout: 20_000 })
// And then verify the instruction actually went in. ChatInput drops a send
// in silence in more than one situation (the 700 ms double-fire lock, which
// since 3.0.7 holds only for the same message, a view that has not
// finished wiring its model), leaving the text sitting in the
// composer with no error anywhere. Without this check the drop surfaces
// twenty seconds later as "the answer never appeared", which points at the
// wrong half of the app.
await expect(async () => {
if ((await echoed.count()) === 0) {
await composer.fill(text)
await composer.press('Enter')
}
await expect(echoed).toBeVisible({ timeout: 2_000 })
}).toPass({ timeout: 30_000 })
}
test('the Code tab streams on the built-in engine instead of painting in one tick', async ({ page }) => {
// One long prose turn, no tools: the whole point is WHEN the text appears.
const answer = 'Streaming through the built-in engine, token by token, in the Code tab.'
await boot(page, [{ text: answer }])
await instruct(page, 'explain the repo layout')
// Partial text on screen is the proof: with the old blocking chatWithTools
// path nothing rendered until the call returned, so no prefix could ever be
// visible on its own.
const main = page.getByRole('main')
await expect(main.getByText(/Streaming through/)).toBeVisible({ timeout: 20_000 })
await expect(main.getByText(answer)).toBeVisible({ timeout: 20_000 })
})
test('a run survives a tab switch and is still stoppable afterwards', async ({ page }) => {
// The run has to be going for the whole trip to another view and back, and a
// script of tool rounds cannot promise that: the mock answers on timers, so
// forty rounds are over after about 3.8 s whatever the machine is doing,
// while the trip (0.9 s on an idle machine) waits for a lazy chunk and grows
// with every worker that shares the dev server. With enough workers the run
// ended first, and the Stop this spec looks for was honestly not there.
// So the first tool call is held open instead: the run sits inside it until
// the spec lets go, however long the trip takes.
const turns: Turn[] = Array.from({ length: 40 }, (_, i) => ({
text: `step ${i + 1}`,
toolCalls: [{ name: 'file_read', args: { path: `src/file-${i}.ts` } }],
}))
await boot(page, turns, true)
await instruct(page, 'walk the whole source tree')
// The first tool call has reached the bridge and waits there.
await expect.poll(async () => (await toolCalls(page)).length, { timeout: 20_000 }).toBe(1)
// Look at another view and come back. AppShell mounts ChatView on
// `currentView === 'chat'`, so this really unmounts CodexView and rebuilds
// the hook with empty refs — the exact move that used to orphan the run.
// (The sidebar's mode buttons clear the active conversation by design, so
// they would prove something else.)
const header = page.getByRole('banner')
await header.getByRole('button', { name: 'Models' }).click()
// The point of this move is that CodexView really UNMOUNTS, so wait for
// proof that the other view took the slot — the Model Manager is a LazyView,
// and its chunk load is exactly the part a fixed 800 ms could lose.
await expect(page.getByRole('button', { name: /Back to chat/i })).toBeVisible({ timeout: 20_000 })
await header.getByRole('button', { name: 'Chat' }).click()
// The remounted view must still show the run as active — before the fix the
// fresh hook reported idle, offered a second Send and no Stop at all.
const stop = page.getByRole('button', { name: /Stop/i })
await expect(stop).toBeVisible({ timeout: 20_000 })
// Still the same run, and still inside its first tool call.
expect((await toolCalls(page)).length).toBe(1)
await stop.click()
// The held call answers now, the way a tool that was in flight when Stop
// landed still reports back through the bridge. From here every tool answers
// at once, so a loop that Stop did not reach would run on through the script.
await page.evaluate(() => (window as unknown as { __E2E_RELEASE_TOOLS__: () => void }).__E2E_RELEASE_TOOLS__())
// Stop actually reached the controller the OLD hook instance created. The
// observable proof is the composer: Stop gives its slot back to Send only
// when the run is really over, so this waits for the stop instead of
// sleeping through it.
await expect(page.getByRole('button', { name: 'Send message' })).toBeVisible({ timeout: 20_000 })
// And after that nothing more may arrive at all. This is the falsifiable
// half and it is the one the audit cares about: an orphaned loop would ask
// the model for step 2 and push its tool call into this window. There is no
// event for "no event", so this half needs a window, but the count it is
// compared with is exact now and no longer a reading taken after a settle.
await page.waitForTimeout(2_500)
expect((await toolCalls(page)).length).toBe(1)
})
/**
* Agent mode (the Chat tab with the Agent toggle) is a DIFFERENT loop from the
* Code tab — useAgentChat, not useCodex — and audit B1/B8 are findings about
* that loop specifically. Running them in the Code tab proves nothing: useCodex
* never had the exact-repeat set, and it always injected the shell timeout.
*/
async function bootAgentMode(page: Page, agentTurns: Turn[]) {
await page.addInitScript(tauriMockInit, {
assistantReply: 'unused in agent specs',
modelName: DEFAULT_MODEL_NAME,
replyChunkDelayMs: 8,
agentTurns,
files: { 'big.ts': FILE_LINES },
})
await seedOnboardingDone(page)
// Auto-approve the tool categories this spec drives. Agent mode defaults to
// asking for terminal/filesystem, and an approval dialog is a different
// behaviour than the one under test here.
await page.addInitScript(() => {
window.localStorage.setItem(
'locally-uncensored-permissions',
JSON.stringify({
state: {
globalPermissions: {
filesystem: 'auto', terminal: 'auto', desktop: 'auto', web: 'auto',
system: 'auto', image: 'auto', video: 'auto', workflow: 'auto',
},
conversationOverrides: {}, perToolOverrides: {}, modeScope: 'agent',
},
version: 2,
}),
)
})
await page.goto('/')
await openNewChat(page)
const agentToggle = page.getByRole('main').getByRole('button', { name: 'Agent', exact: true })
await agentToggle.click()
// Activation opens the "Where should the agent work?" dialog FIRST, and
// `ui/Modal` marks the rest of the page `inert` + `aria-hidden` while a
// dialog is up. So the toggle behind it is out of the accessibility tree,
// and asserting on it before answering the dialog is a race the spec loses
// whenever the dialog wins the frame. Answer the dialog, then look at the
// toggle. The sandbox is the right answer for a spec — it touches nothing
// outside ~/agent-workspace.
const sandbox = page.getByRole('button', { name: /^Sandbox/ })
await expect(sandbox).toBeVisible({ timeout: 15_000 })
await sandbox.click()
await expect(page.getByRole('dialog', { name: /Agent workspace/i })).toHaveCount(0, { timeout: 10_000 })
await expect(agentToggle).toHaveAttribute('title', /Agent Mode is on/i, { timeout: 10_000 })
}
test('agent mode: edit, test, edit, test — the second identical command really re-runs', async ({ page }) => {
const RUN = 'npm test'
await bootAgentMode(page, [
{ text: 'running the suite', toolCalls: [{ name: 'shell_execute', args: { command: RUN } }] },
{ text: 'fixing it', toolCalls: [{ name: 'file_write', args: { path: 'src/fix.ts', content: 'export const fixed = true' } }] },
{ text: 'verifying', toolCalls: [{ name: 'shell_execute', args: { command: RUN } }] },
{ text: 'Done: the suite is green.' },
])
await instruct(page, 'make the failing test green')
await expect(page.getByRole('main').getByText('Done: the suite is green.')).toBeVisible({ timeout: 30_000 })
const calls = await toolCalls(page)
const shellRuns = calls.filter((c) => c.cmd === 'shell_execute' && c.command === RUN)
// Two real executions. The over-loop guard used to drop the second one and
// tell the model nothing had changed — with a file_write in between, which
// is exactly when it HAS changed (audit B1).
expect(shellRuns.length).toBe(2)
// And agent mode injects the long shell timeout now, instead of leaving the
// Rust default of 120 s to kill a real build (audit B8).
expect(shellRuns[0].timeout).toBe(600000)
})
test('file_read pages a large file instead of dumping it whole', async ({ page }) => {
await boot(page, [
{ text: 'reading the middle', toolCalls: [{ name: 'file_read', args: { path: 'src/big.ts', offset: 100, limit: 5 } }] },
{ text: 'Read lines 100 to 104.' },
])
await instruct(page, 'show me the middle of big.ts')
await expect(page.getByRole('main').getByText('Read lines 100 to 104.')).toBeVisible({ timeout: 30_000 })
// Open the tool block and read what the window actually returned.
await page.getByRole('main').getByText('file_read').first().click()
const block = page.getByRole('main')
await expect(block.getByText(/\[lines 100-104 of 200\]/)).toBeVisible({ timeout: 10_000 })
await expect(block.getByText(/call file_read again with offset: 105/)).toBeVisible()
})
test('the plan the model writes shows up above the composer and tracks progress', async ({ page }) => {
// Audit C4. On a long run the transcript scrolls away and the user cannot
// tell step 2 from step 9. The model owns the list through `todo_write`; this
// proves the tool is actually reachable in a real run (routing, permissions,
// executor, store, render) and that the strip follows the model's updates.
await boot(page, [
{
text: 'planning first',
toolCalls: [{
name: 'todo_write',
args: {
todos: [
{ content: 'read the config', status: 'in_progress' },
{ content: 'patch the parser', status: 'pending' },
{ content: 'run the suite', status: 'pending' },
],
},
}],
},
{
text: 'config read, patching',
toolCalls: [{
name: 'todo_write',
args: {
todos: [
{ content: 'read the config', status: 'completed' },
{ content: 'patch the parser', status: 'in_progress' },
{ content: 'run the suite', status: 'pending' },
],
},
}],
},
{ text: 'PLAN_RUN_DONE' },
])
await instruct(page, 'fix the parser and prove it')
await expect(page.getByRole('main').getByText('PLAN_RUN_DONE')).toBeVisible({ timeout: 30_000 })
// PlanBar has TWO homes and they disagree about the default state ON PURPOSE
// (chat/PlanBar.tsx): 'header' starts collapsed so it costs one line above
// the transcript, 'panel' — the Code tab's Explorer column — starts expanded.
// The old version assumed "collapsed" and clicked a bare text node, so on the
// surface that renders the panel the click COLLAPSED the plan and the step
// list it then looked for was the one it had just closed. That is what made
// this spec pass or fail with whichever home the run happened to render.
//
// Which home is showing is not what this spec is about, so it drives the
// strip through BOTH states and asserts each one. The toggle names the state
// it is in, so nothing here is assumed.
const strip = page.getByTestId('plan-header').or(page.getByTestId('plan-panel')).first()
const planToggle = strip.getByRole('button', { name: /^plan 1\/3/ })
await expect(planToggle).toBeVisible({ timeout: 15_000 })
if ((await planToggle.getAttribute('title')) === 'Collapse the plan') await planToggle.click()
// Collapsed: the counter and the step that is running right now — and NOT
// the whole list, which is the part that would push the composer away.
await expect(planToggle).toHaveAttribute('title', 'Show every step')
await expect(planToggle).toContainText('patch the parser')
await expect(strip.getByText('read the config')).toHaveCount(0)
// Expanded: every step, and the strip follows the model's second update.
await planToggle.click()
await expect(planToggle).toHaveAttribute('title', 'Collapse the plan')
await expect(strip.getByText('read the config')).toBeVisible()
await expect(strip.getByText('run the suite')).toBeVisible()
// `todo_write` must never have gone near the Rust bridge: it is pure
// conversation state, and a backend round trip would mean a permission gate
// on writing a to-do list.
const calls = await toolCalls(page)
expect(calls.filter((c) => c.cmd === 'todo_write')).toEqual([])
})
test('file_edit: several edits in one call, one of them with the indentation off, land in one write', async ({ page }) => {
// 01.10.2026: every failed edit on a paid model was "read again, retry",
// two more full-context round trips. The model here sends two changes in one
// call, and copies the first with an indentation the file does not have.
await bootAgentMode(page, [
{
text: 'patching',
toolCalls: [{
name: 'file_edit',
args: {
path: 'big.ts',
edits: [
{ old_string: ' line 10\n line 11', new_string: ' LINE TEN\n LINE ELEVEN' },
{ old_string: 'line 150', new_string: 'LINE 150' },
],
},
}],
},
{ text: 'EDITS_DONE' },
])
await instruct(page, 'rename line 10, 11 and 150 in big.ts')
await expect(page.getByRole('main').getByText('EDITS_DONE')).toBeVisible({ timeout: 30_000 })
const writes = (await toolCalls(page)).filter((c) => c.cmd === 'fs_write')
expect(writes).toHaveLength(1)
const content = String(writes[0].raw.content)
expect(content).toContain('line 9\nLINE TEN\nLINE ELEVEN\nline 12')
expect(content).toContain('line 149\nLINE 150\nline 151')
})
/**
* Customer case 30.09.2026 (Windows desktop), second mail: "if
* i go to any other tabs like setting or somewhere task is stopped and start
* from begining again". The spec above only walked Code → Models → back. This
* walks the customer's way, into Settings, in both loops, and checks the two
* halves of the complaint separately: the run keeps working while the user is
* away, and every later request still carries the steps before the switch.
*/
const bodies = (page: Page) =>
page.evaluate(() => ((window as unknown as { __E2E_CHAT_BODIES__?: string[] }).__E2E_CHAT_BODIES__ ?? []).slice())
async function awayToSettingsAndBack(page: Page) {
const header = page.getByRole('banner')
const before = (await toolCalls(page)).length
await header.getByRole('button', { name: 'Settings' }).click()
await page.waitForTimeout(2_500)
const whileAway = (await toolCalls(page)).length
await header.getByRole('button', { name: 'Chat' }).click()
return { before, whileAway }
}
for (const mode of ['code', 'agent'] as const) {
test(`${mode}: a run keeps going through a trip to Settings and does not start over`, async ({ page }) => {
const turns: Turn[] = Array.from({ length: 60 }, (_, i) => ({
text: `step ${i + 1}`,
toolCalls: [{ name: 'file_read', args: { path: `src/file-${i}.ts` } }],
}))
if (mode === 'code') await boot(page, turns)
else await bootAgentMode(page, turns)
await instruct(page, 'walk the whole source tree')
await expect.poll(async () => (await toolCalls(page)).length, { timeout: 20_000 }).toBeGreaterThan(2)
const { before, whileAway } = await awayToSettingsAndBack(page)
// Not stopped while the user looked at Settings.
expect(whileAway).toBeGreaterThan(before)
// Back in the chat the run is still the same run: Stop is offered and it
// keeps working.
await expect(page.getByRole('button', { name: /Stop/i })).toBeVisible({ timeout: 20_000 })
const back = (await toolCalls(page)).length
await expect.poll(async () => (await toolCalls(page)).length, { timeout: 20_000 }).toBeGreaterThan(back)
// Not started over: the newest request still carries the first step.
const all = await bodies(page)
expect(all[all.length - 1]).toContain('src/file-0.ts')
// And the instruction went out once, not a second time as a fresh run.
const lastMsgs = JSON.parse(all[all.length - 1]).messages as Array<{ role: string; content: unknown }>
const asks = lastMsgs.filter((m) => m.role === 'user' && String(m.content).includes('walk the whole source tree'))
expect(asks.length).toBe(1)
await page.getByRole('button', { name: /Stop/i }).click()
})
}