diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6a89b54..8f1a41c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: env: TOKEN: ${{ secrets.GITHUB_TOKEN }} UI_DEPLOY_KEY: ${{ secrets.UI_DEPLOY_KEY }} - UI_SHA: 5c2b1036dd1aff89ec4eeae0c96493694e33ad4c + UI_SHA: 316891b13acf4c35434a8d7e5e1a5f1d03b081cd run: | set -euo pipefail printf 'context server=%s repository=%s sha=%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_SHA}" diff --git a/e2e/work-checkpoints.playwright.ts b/e2e/work-checkpoints.playwright.ts new file mode 100644 index 0000000..85dca3e --- /dev/null +++ b/e2e/work-checkpoints.playwright.ts @@ -0,0 +1,203 @@ +import { test, expect } from '@playwright/test' +import { readFileSync, readlinkSync } from 'node:fs' +import { createHash } from 'node:crypto' + +const manifestPath=process.env.WORK_CHECKPOINT_HTTP_MANIFEST +test.skip(!manifestPath,'Run explicitly with a task-owned mechanism instance manifest') +const manifest=manifestPath?JSON.parse(readFileSync(manifestPath,'utf8')):null +if(manifest&&(manifest.mode!=='mechanism'||!manifest.dataDir.includes('fast-slow-u2-u3-')||readlinkSync(`/proc/${manifest.pid}/exe`)!==manifest.binary||createHash('sha256').update(readFileSync(manifest.binary)).digest('hex')!==manifest.binarySHA256))throw new Error('Owned mechanism instance verification failed') + +type Scenario='normal'|'fast-ack'|'queue-hold'|'slow-stages'|'zero-failure'|'partial-failure'|'choice' +async function setup(request:any,scenario:Scenario='normal',awaitTask=true,options:{text?:string,small?:boolean,startFailure?:boolean}={}){ + const project=await(await request.post(manifest.base+'/api/projects',{data:{name:'browser-'+Date.now()}})).json() + const conv=await(await request.post(manifest.base+`/api/projects/${project.id}/conversations`,{data:{title:'材料整理',profileId:'checkpoint-eval'}})).json() + const ids:string[]=[] + for(let i=0;i<(options.small?1:3);i++){ + const marker=i===0&&scenario!=='normal'?`[fixture:${scenario}]\n`:'' + const res=await request.post(manifest.base+`/api/projects/${project.id}/uploads`,{multipart:{file:{name:`来源${i+1}.txt`,mimeType:'text/plain',buffer:Buffer.from(marker+'上线六月。验收条件需要补齐,预算50万元尚未审批。交付包含12项需求,其中3项依赖外部接口。第一期先完成8项核心需求。\n\n'+(options.small?'':('整理记录说明:保留日期、金额、限制和待确认事项。\n\n').repeat(options.text?250:100)))}}}) + expect(res.ok()).toBeTruthy();ids.push((await res.json()).artifactId) + } + if(options.startFailure)expect((await request.post(manifest.base+'/__fixture/material-start-failure',{data:{enabled:true}})).status()).toBe(204) + let sendBody:any + try{ + const sent=await request.post(manifest.base+`/api/conversations/${conv.id}/messages`,{data:{text:options.text??'',attachmentIds:ids,requestId:'browser-'+Date.now()}}) + expect(sent.ok()).toBeTruthy() + sendBody=await sent.json() + await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${sendBody.run.id}`)).json()).run.status).toBe('completed') + }finally{ + if(options.startFailure)expect((await request.post(manifest.base+'/__fixture/material-start-failure',{data:{enabled:false}})).status()).toBe(204) + } + if(awaitTask)await expect.poll(async()=>{ + const out=await(await request.get(manifest.base+`/api/conversations/${conv.id}/cards`)).json() + return out.cards.find((c:any)=>c.task)?.task.executionState + }).toBe(scenario.endsWith('failure')?'failed':'completed') + return {project,conv,ids,sendRunId:sendBody.run.id} +} +async function waitForFailureNotice(request:any,seed:any){ + await expect.poll(async()=>{ + const response=await request.get(manifest.base+`/api/conversations/${seed.conv.id}`) + if(!response.ok())return false + const current=await response.json() + const children=current.runs.filter((r:any)=>r.kind==='subagent') + const noticeText=current.messages.filter((m:any)=>m.role==='system').flatMap((m:any)=>m.parts??[]) + .filter((p:any)=>p.type==='text').map((p:any)=>p.text??'').join('\n') + const noticeCount=(noticeText.match(/后台子代理「/g)??[]).length + const noticeRuns=current.runs.filter((r:any)=>r.kind==='conversation'&&r.id!==seed.sendRunId) + return children.length>0&¬iceCount>=children.length&¬iceRuns.length>0&& + noticeRuns.every((r:any)=>['completed','failed','interrupted'].includes(r.status)) + }).toBe(true) +} +async function open(page:any,seed:any){ + await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id}) + await page.goto('/') + await expect(page.locator('[data-conversation-work]')).toContainText('上线六月') +} +test('attachment-only intake gives a safe first reply before sourced findings',async({page,request})=>{ + const seed=await setup(request,'fast-ack',false) + const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards + expect(cards.filter((c:any)=>c.task)).toHaveLength(1) + expect(cards.find((c:any)=>c.task).task.checkpointSeq??0).toBe(0) + await open(page,seed) + const reply=page.getByText(/已收到 3 份材料.*待确认/) + await expect(reply).toBeVisible() + await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0) + await page.setViewportSize({width:390,height:844}) + await expect(reply).toBeVisible() + if(process.env.WORK_CHECKPOINT_SCREENSHOT)await page.screenshot({path:process.env.WORK_CHECKPOINT_SCREENSHOT.replace(/\.png$/, '-mobile.png'),fullPage:true}) + await page.reload() + await expect(reply).toBeVisible() +}) +test('text with long material keeps the user focus in the host reply',async({page,request})=>{ + const seed=await setup(request,'fast-ack',false,{text:'重点核对预算审批'}) + await open(page,seed) + await expect(page.getByText(/你提出的工作重点已带入整理任务.*待确认/)).toBeVisible() + const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards + expect(cards.filter((c:any)=>c.task)).toHaveLength(1) +}) +test('running and completed resends reuse one material card',async({page,request})=>{ + const seed=await setup(request,'slow-stages',false) + const resend=async(key:string)=>{ + const response=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'',attachmentIds:seed.ids,requestId:key}}) + expect(response.ok()).toBeTruthy() + const body=await response.json() + await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${body.run.id}`)).json()).run.status).toBe('completed') + } + await resend('active-resend-'+Date.now()) + await open(page,seed) + await expect(page.getByText(/这批材料仍在整理.*待确认/)).toBeVisible() + await expect.poll(async()=>{ + const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards + return cards.find((c:any)=>c.task)?.task.executionState + }).toBe('completed') + await resend('completed-resend-'+Date.now()) + await expect(page.getByText(/这批材料已整理完成.*待确认/)).toBeVisible() + const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards + expect(cards.filter((c:any)=>c.task)).toHaveLength(1) +}) +test('text with a small attachment retains the ordinary reply path',async({page,request})=>{ + const seed=await setup(request,'normal',true,{text:'请先解释材料要点',small:true}) + await open(page,seed) + await expect(page.getByText('已收到材料,正在后台整理;请结合阶段发现继续工作。').first()).toBeVisible() + const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards + expect(cards.filter((c:any)=>c.task)).toHaveLength(1) +}) +test('real HTTP task recovery, source navigation and changed-version limitation',async({page,request})=>{ + const seed=await setup(request);const errors:string[]=[];page.on('pageerror',e=>errors.push(e.message));await open(page,seed) + await expect(page.locator('[data-conversation-work]')).toContainText('验收条件需要补齐') + await expect(page.locator('[data-conversation-work]')).toContainText('材料未说明具体归属') + await page.reload();await expect(page.locator('[data-conversation-work]')).toContainText('上线六月') + await page.getByRole('button',{name:'查看依据',exact:true}).first().click() + await page.getByRole('button',{name:/查看来源/}).first().click() + await expect(page.locator('.preview-pane')).toBeVisible();await expect(page.getByText('任务处理时引用的来源版本;下方是本项发现引用的片段')).toBeVisible() + const card=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards.find((c:any)=>c.task) + const source=card.task.findings[0].sources[0] + const update=await request.put(manifest.base+`/api/artifacts/${source.artifactId}/content`,{data:'上线七月。文件内容已经更新。',headers:{'Content-Type':'text/plain'}});expect(update.ok()).toBeTruthy() + const old=await request.get(manifest.base+`/api/artifacts/${source.artifactId}/content?expectedHash=${source.contentHash}`);expect(old.status()).toBe(409) + await page.reload();await expect(page.locator('[data-conversation-work]')).toContainText('依据文件已更新') + await expect(page.locator('[data-conversation-work] .acard-findings').first()).not.toContainText('上线六月') + expect(errors).toEqual([]) + if(process.env.WORK_CHECKPOINT_SCREENSHOT)await page.screenshot({path:process.env.WORK_CHECKPOINT_SCREENSHOT,fullPage:true}) +}) +test('background replay does not steal the current file, focus or viewport',async({page,request})=>{ + const seed=await setup(request);await open(page,seed) + await page.getByRole('button',{name:'查看依据',exact:true}).first().click();await page.getByRole('button',{name:/查看来源/}).first().click();await expect(page.locator('.preview-pane')).toBeVisible() + const tabs=await page.locator('.tab-strip').textContent() + const composer=page.locator('textarea').last();await composer.fill('正在记录自己的想法');await composer.focus() + const focus=await composer.evaluate((el:any)=>document.activeElement===el);expect(focus).toBe(true) + const sent=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'重点核对预算',attachmentIds:seed.ids,requestId:'another-'+Date.now()}});expect(sent.ok()).toBeTruthy() + await expect.poll(async()=>{ + const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards + return cards.filter((c:any)=>c.task?.executionState==='completed').length + }).toBe(2) + await expect(composer).toBeFocused();await expect(composer).toHaveValue('正在记录自己的想法') + expect(await page.locator('.tab-strip').textContent()).toBe(tabs) +}) +test('two sourced stages appear while the material task is still running',async({page,request})=>{ + const seed=await setup(request,'slow-stages',false) + await open(page,seed) + const cards=async()=>(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards + await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState}).toBe('running') + await expect(page.locator('[data-conversation-work]')).toContainText('上线六月') + await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState==='running'&&card.task.checkpointSeq>=2&&card.task.findings?.length>1}).toBe(true) + await expect(page.locator('[data-conversation-work]')).toContainText('验收条件需要补齐') + await expect.poll(async()=>{const card=(await cards()).find((c:any)=>c.task);return card?.task.executionState}).toBe('completed') +}) +for(const scenario of ['zero-failure','partial-failure'] as const){ + test(`${scenario} shows the saved scope without a deliverable choice`,async({page,request})=>{ + const seed=await setup(request,scenario) + await waitForFailureNotice(request,seed) + await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id}) + await page.goto('/') + const work=page.locator('[data-conversation-work]') + await expect(work).toContainText('执行失败') + if(scenario==='zero-failure')await expect(work).toContainText('暂无阶段结论记录') + else await expect(work).toContainText('上线六月') + await expect(page.getByText('后台整理状态已回投工作卡;请按卡片中的结果继续。').last()).toBeVisible() + await expect(page.locator('.hitl-choice')).toHaveCount(0) + }) +} +test('completed sourced work offers only its grounded suggestions',async({page,request})=>{ + const seed=await setup(request,'choice') + await open(page,seed) + await expect(page.locator('.hitl-choice')).toHaveCount(3) + for(const label of ['会议摘要','验收清单','联调计划'])await expect(page.locator('.hitl-choice').filter({hasText:label})).toHaveCount(1) + await expect(page.locator('.hitl-card')).toContainText('基于整理结果,接下来生成什么?') + await page.reload() + await expect(page.locator('.hitl-choice')).toHaveCount(3) +}) +test('failed material registration reports no started task or unsupported finding',async({page,request})=>{ + const seed=await setup(request,'normal',false,{startFailure:true}) + await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id}) + await page.goto('/') + await expect(page.getByText(/后台整理未能启动/).first()).toBeVisible() + const conversation=await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}`)).json() + expect(conversation.messages.some((m:any)=>m.role==='assistant'&&(m.parts??[]).some((p:any)=>p.type==='text'&&p.text?.includes('未能启动')))).toBe(true) + const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards + expect(cards.filter((c:any)=>c.task)).toHaveLength(0) + await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0) +}) +test('saturated material slots show queued status without claiming execution',async({page,request})=>{ + const seed=await setup(request,'queue-hold',false) + const sendAnother=async(label:string,hold:boolean)=>{ + const uploaded=await request.post(manifest.base+`/api/projects/${seed.project.id}/uploads`,{ + multipart:{file:{name:`${label}.txt`,mimeType:'text/plain',buffer:Buffer.from((hold?'[fixture:queue-hold]\n':'')+'上线六月。验收条件需要补齐。'+label)}} + }) + expect(uploaded.ok()).toBeTruthy() + const artifactId=(await uploaded.json()).artifactId + const sent=await request.post(manifest.base+`/api/conversations/${seed.conv.id}/messages`,{data:{text:'',attachmentIds:[artifactId],requestId:'queue-'+label}}) + expect(sent.ok()).toBeTruthy() + const runId=(await sent.json()).run.id + await expect.poll(async()=> (await(await request.get(manifest.base+`/api/runs/${runId}`)).json()).run.status).toBe('completed') + } + await sendAnother('占用二',true) + await sendAnother('占用三',true) + await sendAnother('等待四',false) + await page.addInitScript(({id}:{id:string})=>{localStorage.setItem('as.conv',id);localStorage.setItem('as.ui',JSON.stringify({theme:'light',rightCollapsed:true,fontSize:14}))},{id:seed.conv.id}) + await page.goto('/') + await expect(page.locator('[data-conversation-work]')).toContainText('排队中') + await expect(page.getByText(/整理任务正在排队.*待确认/).first()).toBeVisible() + const cards=(await(await request.get(manifest.base+`/api/conversations/${seed.conv.id}/cards`)).json()).cards.filter((c:any)=>c.task) + expect(cards).toHaveLength(4) + expect(cards.filter((c:any)=>c.task.executionState==='queued')).toHaveLength(1) + await expect(page.getByText(/剩余\s*4\s*项.*含\s*3\s*项依赖接口/)).toHaveCount(0) +}) diff --git a/knip-baseline.json b/knip-baseline.json index 36f7f4b..1aae921 100644 --- a/knip-baseline.json +++ b/knip-baseline.json @@ -1,3 +1,3 @@ { - "exports": 51 + "exports": 58 } diff --git a/src/api.ts b/src/api.ts index 0c5d646..c531b0d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -218,6 +218,8 @@ export const api = { auth.fetch(`${BASE}/api/hitl/${cardId}/resolve`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(resp) }) .then(r => json<{ resolved: boolean; pivoted: boolean; pivotText: string }>(r)), + taskSource: (cardId:string,sourceId:string) => auth.fetch(`${BASE}/api/cards/${encodeURIComponent(cardId)}/sources/${encodeURIComponent(sourceId)}`).then(r=>json<{source:import('./contracts').TaskSourceRef;versionState:string;artifact?:Artifact}>(r)), + listCardsSnapshot: (conversationId: string): Promise<{cards: ActivityCard[]; replaySeq?: number}> => auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{cards: ActivityCard[]; replaySeq?: number}>(r)), listCards: (conversationId: string) => auth.fetch(`${BASE}/api/conversations/${conversationId}/cards`).then(r => json<{ cards: ActivityCard[] }>(r)).then(d => d.cards ?? []), cardAction: (cardId: string, actionId: string, payload?: string | WorkCardAction) => diff --git a/src/components/ConversationWork.tsx b/src/components/ConversationWork.tsx index 41ce28f..bfb9f5c 100644 --- a/src/components/ConversationWork.tsx +++ b/src/components/ConversationWork.tsx @@ -29,7 +29,9 @@ export function ConversationWork() { if (card.kind === 'confirm' && card.anchorMessageId && messages.some(message => message.role === 'user' && message.id === card.anchorMessageId)) continue const summary = activitySummary(card) - if (card.status === 'action_required' || card.work?.decision?.state === 'open' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card) + const taskNotice = card.task?.limitations?.length || card.task?.phase.id === 'correction' + || card.task?.findings?.some(f => f.status === 'active' && (f.kind === 'needs_user' || f.corrects)) + if (taskNotice || card.status === 'action_required' || card.work?.decision?.state === 'open' || summary.failed || (card.status === 'done' && summary.remaining)) required.push(card) else remaining.push(card) } // Preserve the host's newest-first order for completed results and history. diff --git a/src/components/PreviewPane.tsx b/src/components/PreviewPane.tsx index da8e27f..be021cc 100644 --- a/src/components/PreviewPane.tsx +++ b/src/components/PreviewPane.tsx @@ -24,6 +24,7 @@ export function PreviewPane() { }) : undefined const art = active ? artifacts.find(a => a.id === active.id) : undefined + const contentUrl=art?api.artifactContentUrl(art.id)+(active?.sourceContext?.expectedHash?`?expectedHash=${encodeURIComponent(active.sourceContext.expectedHash)}`:''):'' const textKind = !!art && isTextKind(art) const [content, setContent] = useState(null) const [editing, setEditing] = useState(false) @@ -35,12 +36,12 @@ export function PreviewPane() { setContent(null) if (!art || !isTextKind(art)) return let cancelled = false - fetch(api.artifactContentUrl(art.id)) - .then(r => r.text()) + fetch(contentUrl) + .then(r => {if(!r.ok)throw new Error('source unavailable');return r.text()}) .then(text => { if (!cancelled) setContent(text) }) .catch(() => { if (!cancelled) setContent('(读取失败)') }) return () => { cancelled = true } - }, [art?.id]) + }, [art?.id,contentUrl]) if (!active) return null if (active.id.startsWith('surface:')) { @@ -96,6 +97,7 @@ export function PreviewPane() { await api.saveArtifactContent(art.id, content ?? '') setDirty(false) store.toast('已保存') + const conv=store.getState().currentConvId;if(conv)void store.refreshCards(conv) }}>保存} )} @@ -108,6 +110,7 @@ export function PreviewPane() { )} + {active.sourceContext&&

{active.sourceContext.notice}

{active.sourceContext.quote&&
{active.sourceContext.quote}
}
}
{editing && textKind ? (