From 5c29e0c26dd975e23e87c8c08393b594de75b804 Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Fri, 4 Sep 2026 12:39:36 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(regenerate):=20web=E3=80=8C=E9=87=8D?= =?UTF-8?q?=E6=96=B0=E7=94=9F=E6=88=90=E3=80=8D=E6=94=B9=E7=94=A8=E7=9C=9F?= =?UTF-8?q?=20task=20identity=E2=80=94=E2=80=94hub=20=E8=A1=A5=E9=BD=90=20?= =?UTF-8?q?agent=5Ftask=20=E7=94=9F=E4=BA=A7=E7=AB=AF=E3=80=81transcript?= =?UTF-8?q?=20=E5=86=99=E7=A9=BF=20agentTaskId=E3=80=81=E8=8F=9C=E5=8D=95?= =?UTF-8?q?=E4=B8=8E=E7=AB=AF=E5=8F=A3=E5=8F=8C=E9=87=8D=E8=AF=9A=E5=AE=9E?= =?UTF-8?q?=E9=97=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真流取证(live 栈 + 真 OIDC,lane-artifacts/round-74/b1-*-PRE.json + 截图)证明修复前: - 右键真 agent 回复 → 菜单无条件提供「重新生成」→ 点击发 POST /web/agent-tasks//regenerate,X 是 block id 剥前缀后的值,而 block id 实为消息的 client_msg_id(hubMessageBlockId = client_msg_id ?? id ?? message_id) ——第三个 identity 域;端点查 pending_agent_tasks 主键 ⇒ live 恒 404 agent_task_not_found,用户看到「Regenerate failed, please retry」; - 未登录 demo 模式同样提供入口并对真后端发无凭据请求 ⇒ 401 auth_invalid_token (对空气发真实请求)。 合同裁决(ADR-033):identity = task id,服务端合同不变;前端不得猜。三段修法: 1. 生产端补齐:hub 两条 edge 回调路径(stream 投影 + done-final)在 agent 消息 content jsonb 里 stamp agent_task:{"task_id":…} —— 正是 shared normalizer 早已 解析(agentTaskFromRecord)却 0 生产者的形状(此前唯一生产者是 demo fixture)。 agentevent.StampAgentTaskRef:非 object content 不 stamp、既有 ref 不覆盖、 空 task id / 非法 json 原样返回(用户可见 payload 永不变形)。 2. transcript 写穿:normalizeHubMessages 把它写成 block.agentTaskId (TextTranscriptBlock 新增可选字段,exactOptional 风格:缺席而非 null)。 3. 双重诚实门:菜单条目与 regenerate effect 同时要求端口已接线且 block.agentTaskId 存在;web 的 onRegenerate 随 chatActions fail-closed (demo/未登录端口 undefined ⇒ 入口不渲染、零请求)。端口签名改为 (blockId, taskId),执行器把两者都交给 shell。 历史消息无 stamp ⇒ 入口不出现(fail-closed,不做回填迁移)。 验收三层: - contract:agentevent 4 发单测(object/array/scalar/既有 ref/空值)+ stream 投影 断言 content 逐字含 agent_task + done-final sqlite 断言;live API 对照 regenerate(message id)=404 / regenerate(task id)=200+新任务。 - frontend:shared normalizer 2 发、workbench chrome 4 发(含两个诚实门)、 web App 3 发(taskId 断言 + 无 stamp 无入口 + 无 chatActions 无入口); 三包 typecheck 绿、web 270/270、desktop 全量 374 文件 4979 例绿。 - real flow:真浏览器点击 PRE 404+失败 toast / POST 200+新任务+「Regenerating」; demo PRE 401 请求 / POST 入口消失零请求(b1-*-POST.json + 截图)。 Refs #2274 (B-1) Co-authored-by: Cursor --- .../transcript/normalizeHubMessages.test.ts | 32 ++++++++++ .../src/transcript/normalizeHubMessages.ts | 5 ++ app/shared/src/transcript/types.ts | 10 +++ app/web/src/App.test.tsx | 61 ++++++++++++++++++ app/web/src/App.tsx | 20 ++++-- app/workbench/src/AgentHubWorkbenchTypes.ts | 2 +- .../src/useWorkbenchTranscriptChrome.test.ts | 7 +- .../src/useWorkbenchTranscriptChrome.ts | 2 +- ...benchTranscriptChromeActionMappers.test.ts | 58 +++++++++++++++-- .../workbenchTranscriptChromeActionMappers.ts | 27 ++++++-- .../workbenchTranscriptChromeHelpers.test.ts | 18 ++++-- .../src/workbenchTranscriptChromeHelpers.ts | 2 +- docs/decisions.md | 1 + .../service/agent/agent_edge_callback.go | 7 ++ .../service/agent/agent_final_message_test.go | 29 +++++++++ .../service/agent/agent_run_event_test.go | 9 ++- .../internal/service/agentevent/stamp.go | 42 ++++++++++++ .../internal/service/agentevent/stamp_test.go | 64 +++++++++++++++++++ 18 files changed, 367 insertions(+), 29 deletions(-) create mode 100644 hub-server/internal/service/agentevent/stamp.go create mode 100644 hub-server/internal/service/agentevent/stamp_test.go diff --git a/app/shared/src/transcript/normalizeHubMessages.test.ts b/app/shared/src/transcript/normalizeHubMessages.test.ts index b36800f71..0a2ed8414 100644 --- a/app/shared/src/transcript/normalizeHubMessages.test.ts +++ b/app/shared/src/transcript/normalizeHubMessages.test.ts @@ -495,3 +495,35 @@ describe('normalizeHubMessagesToTranscript attachment pass-through (#1972)', () ]); }); }); + +describe('normalizeHubMessagesToTranscript producing-task projection (#2274 B-1)', () => { + const agentMessage = (content: unknown) => ({ + id: 'msg-agent-1', + session_id: 'hub-session-1', + seq_id: 7, + sender_type: 'agent', + sender_id: 'agent-1', + sender: { nickname: 'Builder' }, + content_type: 'text', + content, + created_at: '2026-09-04T02:03:09Z', + }); + + it('writes the hub-stamped agent_task.task_id onto the text block', () => { + const blocks = normalizeHubMessagesToTranscript([ + agentMessage({ content: 'B-1 final answer', agent_task: { task_id: 'task-77' } }), + ]); + + expect(blocks).toHaveLength(1); + expect(blocks[0]).toMatchObject({ kind: 'text', agentTaskId: 'task-77' }); + }); + + it('leaves agentTaskId unset when the message carries no task ref', () => { + const blocks = normalizeHubMessagesToTranscript([agentMessage({ content: 'plain answer' })]); + + expect(blocks).toHaveLength(1); + // exactOptional style: absent, not null — the chrome gate reads truthiness + // and an explicit null would still be a lie about "we know the task". + expect('agentTaskId' in blocks[0]!).toBe(false); + }); +}); diff --git a/app/shared/src/transcript/normalizeHubMessages.ts b/app/shared/src/transcript/normalizeHubMessages.ts index 16303480b..fbdc69ac7 100644 --- a/app/shared/src/transcript/normalizeHubMessages.ts +++ b/app/shared/src/transcript/normalizeHubMessages.ts @@ -208,6 +208,11 @@ function normalizeHubMessage( author: normalizeAuthor(message), ...(message.created_at ? { createdAt: message.created_at } : {}), ...(pinned ? { pinned: true } : {}), + // #2274 B-1: the producing task id is the only identity the regenerate + // endpoint accepts. Hub agent messages carry it as content metadata + // (`agent_task.task_id`, stamped by hub's edge callback paths); without + // writing it onto the block the shell cannot offer an honest regenerate. + ...(metadata?.agentTask?.task_id ? { agentTaskId: metadata.agentTask.task_id } : {}), kind: 'text', text, ...(visibleState.displayTitle ? { displayTitle: visibleState.displayTitle } : {}), diff --git a/app/shared/src/transcript/types.ts b/app/shared/src/transcript/types.ts index de3136c80..53386bf65 100644 --- a/app/shared/src/transcript/types.ts +++ b/app/shared/src/transcript/types.ts @@ -54,6 +54,16 @@ interface TranscriptBlockBase { export interface TextTranscriptBlock extends TranscriptBlockBase { kind: 'text'; text: string; + /** + * Producing agent task id, when the source message carries one. Hub stamps + * `agent_task.task_id` into agent message content on both edge callback + * paths (stream projection + done-final) and the hub-message normalizer + * writes it through here (#2274 B-1). It is the only identity + * POST /web/agent-tasks/:id/regenerate accepts, so the transcript chrome + * offers "regenerate" only when this is present — absence means there is no + * server-truthful task to regenerate, and offering the click would be a lie. + */ + agentTaskId?: string; displayTitle?: string; displayDetail?: string; badgeLabel?: string; diff --git a/app/web/src/App.test.tsx b/app/web/src/App.test.tsx index 5a6326fbb..2fbc556df 100644 --- a/app/web/src/App.test.tsx +++ b/app/web/src/App.test.tsx @@ -248,8 +248,12 @@ describe('Web app root', () => { kind: 'text', author: { id: 'hub-agent', name: 'Builder', role: 'agent' }, text: 'Agent 的回复内容', + // #2274 B-1: hub stamps the producing task id onto agent messages; + // the menu only offers regenerate when the block carries it. + agentTaskId: 'task-42', }, ], + chatActions: {}, }); const { container } = render(); @@ -261,12 +265,69 @@ describe('Web app root', () => { const regenerateItem = await screen.findByRole('menuitem', { name: 'context.regenerate' }); fireEvent.click(regenerateItem); + // #2274 B-1: the port must send the TASK id — the only identity + // POST /web/agent-tasks/:id/regenerate accepts (pre-fix web sent a message + // identifier and every live click 404'd). + expect(hubClientStub.regenerateAgentTask).toHaveBeenCalledWith('task-42'); + // Test env renders i18n keys raw; runtime shows 'Regenerate failed, please retry'. await screen.findByText('toast.regenerateFailed'); // The failed regenerate must not hide the original message. expect(screen.getByText('Agent 的回复内容')).toBeInTheDocument(); }); + // #2274 B-1 honesty gate, both halves: no stamped task id ⇒ nothing honest to + // send; no hub session (demo/unauthenticated) ⇒ the port must not exist at + // all, so the shared menu hides the entry instead of offering a dead click. + it('hides regenerate when the agent block carries no stamped task id', async () => { + useWebWorkbenchModelMock.mockReturnValue({ + activeConversationId: 'hub-session-1', + conversations: [ + { id: 'hub-session-1', title: '真实 Hub 会话', kind: 'group', subtitle: 'Hub group' }, + ], + transcript: [ + { + id: 'hub-message-1', + kind: 'text', + author: { id: 'hub-agent', name: 'Builder', role: 'agent' }, + text: 'Agent 的回复内容', + }, + ], + chatActions: {}, + }); + + const { container } = render(); + fireEvent.contextMenu(container.querySelector('[data-selectable-card="hub-message-1"]')!); + await screen.findByRole('menuitem', { name: 'context.copyLink' }); + expect(screen.queryByRole('menuitem', { name: 'context.regenerate' })).toBeNull(); + expect(hubClientStub.regenerateAgentTask).not.toHaveBeenCalled(); + }); + + it('hides regenerate outside hubReady even when a task id is stamped (#2274 B-1)', async () => { + useWebWorkbenchModelMock.mockReturnValue({ + activeConversationId: 'hub-session-1', + conversations: [ + { id: 'hub-session-1', title: '真实 Hub 会话', kind: 'group', subtitle: 'Hub group' }, + ], + transcript: [ + { + id: 'hub-message-1', + kind: 'text', + author: { id: 'hub-agent', name: 'Builder', role: 'agent' }, + text: 'Agent 的回复内容', + agentTaskId: 'task-42', + }, + ], + // no chatActions ⇒ demo / unauthenticated shell + }); + + const { container } = render(); + fireEvent.contextMenu(container.querySelector('[data-selectable-card="hub-message-1"]')!); + await screen.findByRole('menuitem', { name: 'context.copyLink' }); + expect(screen.queryByRole('menuitem', { name: 'context.regenerate' })).toBeNull(); + expect(hubClientStub.regenerateAgentTask).not.toHaveBeenCalled(); + }); + it('keeps Hub Agent Profiles available to the shared composer without legacy demo controls', () => { useAgentListMock.mockReturnValue({ data: { diff --git a/app/web/src/App.tsx b/app/web/src/App.tsx index fbecc9599..6eaf2611d 100644 --- a/app/web/src/App.tsx +++ b/app/web/src/App.tsx @@ -255,13 +255,21 @@ function WebWorkbenchRoot() { void agentList.refetch(); }, [agentList]); - const handleRegenerate = useCallback((blockId: string): Promise => { + const handleRegenerate = useCallback((_blockId: string, taskId: string): Promise => { + // #2274 B-1: the identity contract of POST /web/agent-tasks/:id/regenerate + // is the TASK id (hub RegenerateAgentTask looks up pending_agent_tasks by + // primary key). The pre-fix port stripped a `hub-message-` prefix and sent + // what it called a "message id" — which is in fact the message's + // client_msg_id, a third identifier domain — so every live click 404'd + // (agent_task_not_found) and unauthenticated demo mode fired real + // unauthenticated requests at the hub (401). The workbench chrome now + // offers the entry only for blocks carrying the hub-stamped task id + // (`agent_task.task_id` → block.agentTaskId) and passes it here. // #1821: return the real promise — the workbench chrome awaits it, so a // failed regenerate shows an error toast and keeps the message visible // instead of silently soft-hiding it behind a fake "regenerating" toast. - const messageId = blockId.replace(/^hub-message-/, ''); return createHubClient({ getToken: getAccessToken }) - .regenerateAgentTask(messageId) + .regenerateAgentTask(taskId) .then(() => undefined); }, []); @@ -305,7 +313,11 @@ function WebWorkbenchRoot() { onApprovalDecision={workbench.onApprovalDecision} onNavigateToConversation={handleNavigateToConversation} onStartNewConversation={handleStartNewConversation} - onRegenerate={handleRegenerate} + // #2274 B-1: regenerate is a real Hub mutation, so it rides the same + // fail-closed gate as the other five chat actions — outside hubReady + // (demo / unauthenticated) the port is undefined and the shared menu + // hides the entry instead of offering a click that can only fail. + onRegenerate={chatActions ? handleRegenerate : undefined} isAgentRunning={workbench.isAgentRunning} onCancelRun={workbench.onCancelRun} onEditMessage={ diff --git a/app/workbench/src/AgentHubWorkbenchTypes.ts b/app/workbench/src/AgentHubWorkbenchTypes.ts index 069bd9c9e..2dde85151 100644 --- a/app/workbench/src/AgentHubWorkbenchTypes.ts +++ b/app/workbench/src/AgentHubWorkbenchTypes.ts @@ -145,7 +145,7 @@ export interface AgentHubWorkbenchProps { * the block ID. May return a Promise: the chrome awaits it so a failed * regenerate surfaces an error toast instead of a fake success (#1821). */ - onRegenerate?: ((blockId: string) => Promise | void) | undefined; + onRegenerate?: ((blockId: string, taskId: string) => Promise | void) | undefined; /** * F1/F6 attention source: the shell's existing run/approval/thread model * arrays. The workbench derives sidebar live dots, the rail badge and the diff --git a/app/workbench/src/useWorkbenchTranscriptChrome.test.ts b/app/workbench/src/useWorkbenchTranscriptChrome.test.ts index e691bc123..59d6bf03c 100644 --- a/app/workbench/src/useWorkbenchTranscriptChrome.test.ts +++ b/app/workbench/src/useWorkbenchTranscriptChrome.test.ts @@ -193,7 +193,7 @@ describe('useWorkbenchTranscriptChrome', () => { // entries are additionally gated on the port handler being wired — the // shape assertions here are about author/kind, so declare both ports. const { result } = renderTranscriptChrome({ - transcript: [textBlock(), userTextBlock()], + transcript: [textBlock({ agentTaskId: 'task-b1' }), userTextBlock()], sessionId: 'sess-1', onRegenerate: vi.fn(), onRecallMessage: vi.fn(), @@ -350,14 +350,15 @@ describe('useWorkbenchTranscriptChrome', () => { it('regenerates agent text and soft-hides the block', () => { const onRegenerate = vi.fn(); const { result } = renderTranscriptChrome({ - transcript: [textBlock(), userTextBlock()], + transcript: [textBlock({ agentTaskId: 'task-b1' }), userTextBlock()], onRegenerate, }); act(() => { result.current.handleTranscriptBlockAction('regenerate', 'b1'); }); - expect(onRegenerate).toHaveBeenCalledWith('b1'); + // #2274 B-1: the port receives (blockId, taskId). + expect(onRegenerate).toHaveBeenCalledWith('b1', 'task-b1'); expect(result.current.softHiddenBlockIds).toEqual(['b1']); expect(result.current.toastMessage).toBe('action.regenerating'); diff --git a/app/workbench/src/useWorkbenchTranscriptChrome.ts b/app/workbench/src/useWorkbenchTranscriptChrome.ts index b2077a141..3033d1ebd 100644 --- a/app/workbench/src/useWorkbenchTranscriptChrome.ts +++ b/app/workbench/src/useWorkbenchTranscriptChrome.ts @@ -37,7 +37,7 @@ export interface UseWorkbenchTranscriptChromeOptions { * so the success toast fires after resolution and a rejection surfaces a * failure toast instead of a fake optimistic success. */ - onRegenerate?: ((blockId: string) => Promise | void) | undefined; + onRegenerate?: ((blockId: string, taskId: string) => Promise | void) | undefined; /** * Hub session id for REST message actions (#1383). Optional — Desktop/demo * shells omit it; the react/pin/unpin/recall menu entries are then hidden diff --git a/app/workbench/src/workbenchTranscriptChromeActionMappers.test.ts b/app/workbench/src/workbenchTranscriptChromeActionMappers.test.ts index 6807ac70d..0a8f8a099 100644 --- a/app/workbench/src/workbenchTranscriptChromeActionMappers.test.ts +++ b/app/workbench/src/workbenchTranscriptChromeActionMappers.test.ts @@ -112,7 +112,7 @@ describe('workbenchTranscriptChromeActionMappers', () => { }); it('plans permission and regenerate block actions', () => { - const transcript = [permissionBlock(), textBlock({ id: 'agent' })]; + const transcript = [permissionBlock(), textBlock({ id: 'agent', agentTaskId: 'task-9' })]; const approve = planTranscriptBlockAction({ action: 'approve', blockId: 'perm-1', @@ -127,7 +127,46 @@ describe('workbenchTranscriptChromeActionMappers', () => { transcript, t, }); - expect(regenerate.some((effect) => effect.type === 'regenerate')).toBe(true); + // #2274 B-1: the planned effect must carry the producing task id — that is + // the only identity POST /web/agent-tasks/:id/regenerate accepts. + expect(regenerate).toContainEqual( + expect.objectContaining({ type: 'regenerate', blockId: 'agent', taskId: 'task-9' }), + ); + }); + + it('plans no regenerate for agent text without a stamped task id (#2274 B-1)', () => { + // Honesty gate: no server-truthful task id ⇒ nothing to send ⇒ no effect, + // so no shell can soft-hide a message behind a request that must fail. + const transcript = [textBlock({ id: 'agent-no-task' })]; + const planned = planTranscriptBlockAction({ + action: 'regenerate', + blockId: 'agent-no-task', + transcript, + t, + }); + expect(planned.some((effect) => effect.type === 'regenerate')).toBe(false); + }); + + it('hides the regenerate menu entry unless the block carries a task id (#2274 B-1)', () => { + const withTask = buildTranscriptContextMenuGroups({ + blockId: 'b1', + transcript: [textBlock({ agentTaskId: 'task-9' })], + t, + onAction: vi.fn(), + onEnterSelection: vi.fn(), + capabilities: { regenerate: true }, + }); + expect(withTask.flat().map((item) => item.label)).toContain('context.regenerate'); + + const withoutTask = buildTranscriptContextMenuGroups({ + blockId: 'b1', + transcript: [textBlock()], + t, + onAction: vi.fn(), + onEnterSelection: vi.fn(), + capabilities: { regenerate: true }, + }); + expect(withoutTask.flat().map((item) => item.label)).not.toContain('context.regenerate'); }); it('builds menu/multi view models and applies side effects', () => { @@ -697,11 +736,13 @@ describe('workbenchTranscriptChromeActionMappers', () => { onRegenerate: vi.fn().mockRejectedValue(new Error('regen refused')), }; applyTranscriptChromeSideEffects([ - { type: 'regenerate', blockId: 'b1', successMessage: 'regen-ok', failureMessage: 'regen-fail' }, + { type: 'regenerate', blockId: 'b1', taskId: 'task-1', successMessage: 'regen-ok', failureMessage: 'regen-fail' }, ], regenerateHandlers); await vi.waitFor(() => { expect(regenerateHandlers.showWorkbenchToast).toHaveBeenCalledWith('regen refused'); }); + // #2274 B-1: the port receives the task id alongside the block id. + expect(regenerateHandlers.onRegenerate).toHaveBeenCalledWith('b1', 'task-1'); expect(regenerateHandlers.softHideBlocks).not.toHaveBeenCalled(); expect(regenerateHandlers.pulseBlock).not.toHaveBeenCalled(); @@ -715,8 +756,9 @@ describe('workbenchTranscriptChromeActionMappers', () => { onRegenerate: vi.fn().mockResolvedValue(undefined), }; applyTranscriptChromeSideEffects([ - { type: 'regenerate', blockId: 'b2', successMessage: 'regen-ok', failureMessage: 'regen-fail' }, + { type: 'regenerate', blockId: 'b2', taskId: 'task-2', successMessage: 'regen-ok', failureMessage: 'regen-fail' }, ], okRegenerateHandlers); + expect(okRegenerateHandlers.onRegenerate).toHaveBeenCalledWith('b2', 'task-2'); await vi.waitFor(() => { expect(okRegenerateHandlers.showWorkbenchToast).toHaveBeenCalledWith('regen-ok'); }); @@ -733,8 +775,9 @@ describe('workbenchTranscriptChromeActionMappers', () => { onRegenerate: vi.fn(), }; applyTranscriptChromeSideEffects([ - { type: 'regenerate', blockId: 'b3', successMessage: 'regen-ok', failureMessage: 'regen-fail' }, + { type: 'regenerate', blockId: 'b3', taskId: 'task-3', successMessage: 'regen-ok', failureMessage: 'regen-fail' }, ], syncRegenerateHandlers); + expect(syncRegenerateHandlers.onRegenerate).toHaveBeenCalledWith('b3', 'task-3'); expect(syncRegenerateHandlers.softHideBlocks).toHaveBeenCalledWith(['b3']); expect(syncRegenerateHandlers.pulseBlock).toHaveBeenCalledWith('b3'); expect(syncRegenerateHandlers.showWorkbenchToast).toHaveBeenCalledWith('regen-ok'); @@ -811,7 +854,8 @@ describe('workbenchTranscriptChromeActionMappers', () => { ]; const groups = buildTranscriptContextMenuGroups({ blockId: 'agent-1', - transcript: [textBlock({ id: 'agent-1' })], + // #2274 B-1: the regenerate entry also needs the stamped task id. + transcript: [textBlock({ id: 'agent-1', agentTaskId: 'task-1' })], t, onAction, onEnterSelection, @@ -853,7 +897,7 @@ describe('workbenchTranscriptChromeActionMappers', () => { /* ── #2154 P1-A:菜单按 handler 存在性 fail-closed + 派发器不再静默丢弃 ── */ it('renders handler-backed menu entries only when the capability is declared (#2154)', () => { - const agentBlock = textBlock({ id: 'a1' }); + const agentBlock = textBlock({ id: 'a1', agentTaskId: 'task-1' }); const userBlock = textBlock({ id: 'u1', author: { id: 'u', role: 'human', name: 'You' } }); const pinnedBlock = textBlock({ id: 'p1', pinned: true }); const conversations: Array<{ id: string; title: string; kind: 'direct' | 'group' }> = [ diff --git a/app/workbench/src/workbenchTranscriptChromeActionMappers.ts b/app/workbench/src/workbenchTranscriptChromeActionMappers.ts index 58153f738..dc389cb46 100644 --- a/app/workbench/src/workbenchTranscriptChromeActionMappers.ts +++ b/app/workbench/src/workbenchTranscriptChromeActionMappers.ts @@ -50,6 +50,12 @@ export type TranscriptChromeSideEffect = | { type: 'regenerate'; blockId: string; + /** + * #2274 B-1: the producing agent task id — the only identity + * POST /web/agent-tasks/:id/regenerate accepts. Planned only when the + * block actually carries it (hub-stamped `agent_task.task_id`). + */ + taskId: string; /** Shown only after the regenerate request resolves successfully (#1821). */ successMessage: string; /** Shown when the regenerate request rejects (#1821). */ @@ -236,12 +242,19 @@ export function planContextAction(options: { }); } if (action === 'regenerate' && block && block.kind === 'text' && block.author.role === 'agent') { + // #2274 B-1: regenerate's identity is the producing TASK id; the endpoint + // answers 404 to anything else (message id / client_msg id — the pre-fix + // web behaviour). Without block.agentTaskId there is no server-truthful + // identity to send, so plan nothing: a soft-hide + success toast here + // would claim an effect that cannot run (#1818 / #2154 honesty rule). + if (!block.agentTaskId) return effects; // #1821: the soft-hide + success toast only land after the regenerate // request resolves; on rejection the block stays visible and the failure // toast surfaces (no fake "regenerating" state). effects.push({ type: 'regenerate', blockId, + taskId: block.agentTaskId, successMessage: t('action.regenerating'), failureMessage: t('toast.regenerateFailed'), failureFallbackKey: 'toast.regenerateFailed', @@ -391,12 +404,15 @@ export function planTranscriptBlockAction(options: { } if (action === 'retry' || action === 'regenerate') { - if (block.kind === 'text' && block.author.role === 'agent') { + // #2274 B-1: same identity gate as the context-menu path — no stamped + // task id means no honest regenerate to plan. + if (block.kind === 'text' && block.author.role === 'agent' && block.agentTaskId) { // #1821: same honest contract as the context-menu regenerate path — the // soft-hide + success toast ride the resolved request. effects.push({ type: 'regenerate', blockId, + taskId: block.agentTaskId, successMessage: t('action.regenerating'), failureMessage: t('toast.regenerateFailed'), failureFallbackKey: 'toast.regenerateFailed', @@ -482,7 +498,7 @@ export interface TranscriptChromeEffectHandlers { * soft-hide + success toast wait for resolution and a rejection surfaces * the failure toast instead (the block stays visible). */ - onRegenerate?: ((blockId: string) => Promise | void) | undefined; + onRegenerate?: ((blockId: string, taskId: string) => Promise | void) | undefined; onApprovalDecision?: ((decision: ApprovalDecisionAction) => Promise | void) | undefined; pulseBlock: (blockId: string) => void; showWorkbenchToast: (message: string) => void; @@ -625,7 +641,7 @@ export function applyTranscriptChromeSideEffects( announceUnavailableAction(effect, handlers, t); break; } - const outcome = regenerateHandler(effect.blockId); + const outcome = regenerateHandler(effect.blockId, effect.taskId); if (isThenable(outcome)) { void Promise.resolve(outcome).then( () => { @@ -891,7 +907,10 @@ export function buildTranscriptContextMenuGroups({ [ // #2154: regenerate needs the shell's regenerate port — Desktop has // none, so the entry stays hidden instead of doing nothing on click. - ...(isAgentText && caps.regenerate === true + // #2274 B-1: the entry also needs the block's stamped task id — the + // shell's port is necessary but not sufficient: without a task id the + // click can only fail (pre-fix web sent a message id and got 404). + ...(isAgentText && caps.regenerate === true && Boolean(block?.agentTaskId) ? [{ label: t('context.regenerate'), icon: 'refresh' as const, onClick: () => onAction('regenerate', blockId) }] : []), // Recall (#1383) only makes sense for the user's own messages — the diff --git a/app/workbench/src/workbenchTranscriptChromeHelpers.test.ts b/app/workbench/src/workbenchTranscriptChromeHelpers.test.ts index 34f3dc54d..876438c90 100644 --- a/app/workbench/src/workbenchTranscriptChromeHelpers.test.ts +++ b/app/workbench/src/workbenchTranscriptChromeHelpers.test.ts @@ -123,7 +123,9 @@ function createChromeFixture(overrides: Partial }, writers, getTranscript: () => [ - textBlock({ id: 'a1', text: 'Alpha' }), + // #2274 B-1: the agent block carries the hub-stamped task id so the + // regenerate entry/effect has an honest identity to send. + textBlock({ id: 'a1', text: 'Alpha', agentTaskId: 'task-a1' }), textBlock({ id: 'u1', text: 'Mine', author: { id: 'u', role: 'human', name: 'You' } }), textBlock({ id: 'p1', text: 'Pinned', pinned: true }), ], @@ -198,7 +200,7 @@ describe('workbenchTranscriptChromeHelpers', () => { it('builds context menu groups with quote/regenerate conditionals', () => { const onAction = vi.fn(); const onEnterSelection = vi.fn(); - const agentText = textBlock({ id: 'agent-text' }); + const agentText = textBlock({ id: 'agent-text', agentTaskId: 'task-7' }); const userText = textBlock({ id: 'user-text', author: { id: 'user-1', role: 'human', name: 'You' }, @@ -359,7 +361,7 @@ describe('workbenchTranscriptChromeHelpers', () => { }); it('plans context actions for copy/delete/reply/quote/regenerate', () => { - const agent = textBlock({ id: 'agent-1', text: 'body\nnext' }); + const agent = textBlock({ id: 'agent-1', text: 'body\nnext', agentTaskId: 'task-9' }); const copy = planContextAction({ action: 'copy', blockId: 'agent-1', @@ -410,6 +412,7 @@ describe('workbenchTranscriptChromeHelpers', () => { { type: 'regenerate', blockId: 'agent-1', + taskId: 'task-9', successMessage: 'action.regenerating', failureMessage: 'toast.regenerateFailed', failureFallbackKey: 'toast.regenerateFailed', @@ -419,7 +422,7 @@ describe('workbenchTranscriptChromeHelpers', () => { it('plans transcript block actions for approval/retry/copy', () => { const perm = permissionBlock({ id: 'p1' }); - const agent = textBlock({ id: 'a1' }); + const agent = textBlock({ id: 'a1', agentTaskId: 'task-a1' }); const approval = planTranscriptBlockAction({ action: 'approve', blockId: 'p1', @@ -451,6 +454,7 @@ describe('workbenchTranscriptChromeHelpers', () => { { type: 'regenerate', blockId: 'a1', + taskId: 'task-a1', successMessage: 'action.regenerating', failureMessage: 'toast.regenerateFailed', failureFallbackKey: 'toast.regenerateFailed', @@ -608,7 +612,7 @@ describe('workbenchTranscriptChromeHelpers', () => { actions: [{ type: 'setText', text: 'hi' }], focusComposer: true, }, - { type: 'regenerate', blockId: 'a' }, + { type: 'regenerate', blockId: 'a', taskId: 'task-a' }, { type: 'approval', decision: { approvalId: 'req', decision: 'allow' }, @@ -626,7 +630,7 @@ describe('workbenchTranscriptChromeHelpers', () => { expect(handlers.softHideBlocks).toHaveBeenCalledWith(['a']); expect(handlers.dispatchComposer).toHaveBeenCalledWith({ type: 'setText', text: 'hi' }); expect(handlers.focusComposer).toHaveBeenCalledOnce(); - expect(handlers.onRegenerate).toHaveBeenCalledWith('a'); + expect(handlers.onRegenerate).toHaveBeenCalledWith('a', 'task-a'); expect(handlers.onApprovalDecision).toHaveBeenCalledWith({ approvalId: 'req', decision: 'allow', @@ -1142,7 +1146,7 @@ describe('workbenchTranscriptChromeHelpers', () => { click('u1', 'context.recall'); expect(onRecallMessage).toHaveBeenCalledWith('u1'); click('a1', 'context.regenerate'); - expect(onRegenerate).toHaveBeenCalledWith('a1'); + expect(onRegenerate).toHaveBeenCalledWith('a1', 'task-a1'); // Forward rides the picker's encoded action string (#1385). controller.runContextAction(forwardActionForTargets(['s2']), 'u1'); diff --git a/app/workbench/src/workbenchTranscriptChromeHelpers.ts b/app/workbench/src/workbenchTranscriptChromeHelpers.ts index 797f1b4a7..2b107f95a 100644 --- a/app/workbench/src/workbenchTranscriptChromeHelpers.ts +++ b/app/workbench/src/workbenchTranscriptChromeHelpers.ts @@ -195,7 +195,7 @@ export interface TranscriptChromeControllerDeps { t: TranscriptChromeTranslate; dispatchComposer: (action: ComposerAction) => void; composerInputRef: { current: { focus: () => void } | null }; - onRegenerate?: ((blockId: string) => Promise | void) | undefined; + onRegenerate?: ((blockId: string, taskId: string) => Promise | void) | undefined; onApprovalDecision?: ((decision: ApprovalDecisionAction) => Promise | void) | undefined; /** * Hub session id for REST message actions (#1383). Web *and* Desktop set it diff --git a/docs/decisions.md b/docs/decisions.md index 30c8b9215..cdff15fdf 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -40,6 +40,7 @@ | ADR-030 | Accepted 2026-09-04 | 对外契约四条口径(#2258):① `x-agenthub-owner ∈ {Hub, Edge}`,`Runner` 退役(`edge-server/internal/runners/` 只有 registry,workspace 实现全在 hub-server),4 个 `/v1/workspaces/**` 改 `owner: Hub` 且**必须保持 `status: planned`**(否则被拉进 router 比对而红);workspace 元数据/列举归 Hub,将来若真需要 Edge 侧文件内容端点,届时该端点自己标 Edge,不预先在 Edge 建第二套实现。② `x-agenthub-phase` **只适用于 `/v1/**` 设计面**——声明适用范围而不是批量补 166 个标记,只补真正违反声明的 3 个 `/v1`。③ Mobile 单一口径 =「装配中的 fixture/边界验证 lane,**非 release candidate**」,证据锚点是 `release.yml` 的 `build-mobile` 受 `RELEASE_MOBILE_ENABLED` 门控默认 skipped;README 双语与 `docs/architecture.md` 统一到这句。④ 根 `AGENTS.md`(285/300 行)下沉方案 accepted、实施 deferred,触发条件 = 下次新增规则撞行数预算时在同一 PR 内下沉(不做纯 doc churn PR)。 | API / Docs / Product | 是 | | ADR-031 | Accepted 2026-09-04 | 分页 clamp 可观测性(#2243 残项):**保留「夹到端点自己声明的上限」,不改 400**。实测 13 个 list handler 的信封已回传 `page.nextCursor` + `page.hasMore`(另两个 clamp 端点是 limit/offset 形态,客户端仍可推进),所以被夹短的页**可续取、不是数据丢失**;400 会把可满足的请求变成硬失败、零用户收益,并与 `repository/pagination_clamp_test.go` 钉住的 `ClampPageSize` 不变量冲突。可观测性用**文档化**补齐(`api/conventions.md` + OpenAPI `PageSize` 描述写明「超过声明上限即夹到该上限,余下部分用 nextCursor/offset 续取」),**不**新增信封字段(跨 ~16 端点的契约变更换近乎零价值)。残项:`repository/message.go GetMessagesIncrement` 的非正值分支按 `paging.go` 自己写明的「0 = no explicit limit ⇒ 把 requested 当 def 传」惯例表达,行为不变、消掉最后一处手写分支。 | Hub API | 是 | | ADR-032 | Accepted 2026-09-04 | CI 反馈回路裁决(#2251)。**① 量化目标已达成**:`@agenthub/workbench` coverage job 从实测中位 **441s**(15 次 pre-#2300 run,自测基线而非沿用 7m31s 口径)降到 **259s**(5 次 post,**−41.3%**;把 #2300 自己 4 次 pre-merge run 并进来的 n=9 口径 −40.1%),机制在 CI 日志里验证过:vitest `tests` 桶 796.49s → 110.16s worker-seconds(−86%),达成并发度恒为 **3.86**(=job 内已无并行度可挖)。**② stretch ≤3m 不做**:单 runner 下限已是 870/3.86 + 30 = **255s**,要到 180s 得再删 ~35% 测试工作量,那是拿覆盖保障换 KPI。**③ workbench coverage 分片(`vitest --shard`)DEFERRED**:在 post-#2300 逐文件成本上重拟合 LPT+4worker 模型(N=1 标定 −2.0%),N=3/N=4 把该 job 降到 1m45s–2m26s,但 **PR 墙钟中位只从 289s → 275s(−4.8%),9 次里 4 次收益恰好为 0**——FE-only PR 已成 `frontend-required`(5/9) 与 `windows-frontend`(4/9) 的双极平局(中位差 20s);同时分片把 FE-only run 叶子数 15→19,而账号级并发上限实测 **~20**,两个重叠 FE-only PR 会 30→38(30 那一档本窗口内已饿过一次)。**触发重开**:该 job 墙钟中位 ≥ **~5m10s**(= 交叉点 ~4m09s + 60s 余量),或 ④(c) 落地之后。**④ 真正的极已换人**:(a) **`go-hub-test` 分片失衡——本 ADR 同批实施**:`internal/repository` 一个包 = hub-server 实测 418.8s race 测试成本的 **227.7s(54.4%)**,它同时是任何包粒度切分的**硬下限**;`NR % 2` 把它放在 `go list` 第 15 位(奇数)⇒ shard 2,而 shard 2 在 **14/14**(post-#2300 窗口 **18/18**)次 CI run 里都是慢的那半(中位 +119s / +2m02s,最大 +177s / 3m20s)。改成 shard 1 独占该包、shard 2 拿其余 51 个:load **310.5/108.3 → 227.7/191.1**(spread 202.3s → 36.5s),**不新增 job**;**本 PR 自己的 CI run 就是这次改动的实验,实测已回收**:`go-hub-test (1)`=**3m49s**、`(2)`=**3m28s**(spread **21s**),对照基线 shard1 2m01s–3m00s / shard2 4m41s–5m14s(中位 spread +119s)⇒ 矩阵跨度 **−62s**,落在预测的 −40…−70s 区间内;含 1 个包的那片反而成了较慢的一片(229s vs 208s),说明「测试二进制 link 成本随包数增长」这一项确实存在、量级约 20s,比 load 失衡小一个数量级,所以按 load 切是对的方向。同一 run 的 `go-edge-test` 未改:(1) 2m39s / (2) 1m48s,仍不是 `backend-required` 的极 ⇒ (b) 的「不动」判断被新数据再次支持。硬编码包路径不会静默腐烂——包被改名/移动/拆分则 shard 1 收到 0 个包,既有 0-package 守卫硬报错,`verify-ci-gates.py` 另加 3 条断言 + 2 发变异自测钉住。(b) **`go-edge-test` 保持轮转**:实测 edge 无单一支配包(最重 `internal/lifecycle` 34.57s = **24.0%**),当前 spread 29.1s load(shard1 重,与 CI「edge shard2 在 14/14 次里更快、中位 −46.5s」同向),LPT-2 只省 14.1s load,而 edge-test 从来不是 `backend-required` 的极(2m39s vs hub 4m51s)⇒ 省下的时间到不了 PR 墙钟;触发重开 = edge-test 成为 backend 极。(c) **`windows-frontend` 的 ~75s/leg 环境准备税 DEFERRED**(同样四步在 Ubuntu 上 21.5–24.0s:setup-node +28~29s、pnpm action-setup +19~22s、pnpm install +20~22s、checkout +5~6s,且 PRE→POST 还在变慢 3m54s→4m07s):只读测量量得出税额、量不出任何修法的效果,需要一次真实 CI 实验;**禁止**用砍掉 Windows leg 的 `Production build` 换那 47–50s——`desktop-linux-build` 是 `workflow_dispatch` only(43 次 run 出现 0 次),Linux `frontend-desktop` 只跑 typecheck+lint+test 不 build,那条腿是每个 PR **唯一**证明 desktop `tsc -p tsconfig.app.json && vite build` 能过的地方。(d) 两个前端杠杆**严格互补、单独都不划算**:Windows 税单独修 = **+0.0%**(`frontend-required` 变成 9/9 的极)、分片单独 = −4.8%、两个一起 = 4m49s → **3m49s(−20.5%)**、再加 `frontend-desktop` 分片重排(164s vs 138s)= **3m29s(−27.3%)**。**⑤ 测量方法学(防下次重推)**:本机 ARM64 上 `go test -short -count=1 -p 1` **不带 `-race`** 得到的成本分布与 CI 完全不同(repository 只占 9.9%、`internal/middleware` 占 31%),带上 `-race` 才复现 CI 的方向;两个模块的失衡方向都被 **28/28** 次 CI 观察独立证实 ⇒ 任何 shard 重排必须用 CI 同款 flag(`-short -race -count=1`)逐包测,命令:`go test ./... -count=1 -short -race -p 1 -parallel 1`。证据底稿 `lane-artifacts/round-73/lane-ci-REPORT.md` 与 `lane-ci-post2300-REPORT.md`。 | CI / Developer feedback loop | 是 | +| ADR-033 | Accepted 2026-09-04;landed #2315 | regenerate 的 identity 合同(#2274 B-1):**identity = task id**(服务端合同 `POST /web/agent-tasks/:id/regenerate` → `RegenerateAgentTask(userID, taskID)` 不变)。真流取证(live 栈 + 真 OIDC)证明修复前 web 把 block id 剥前缀后当 task id 发——而 block id 实为消息的 **client_msg_id**(第三个 identity 域),live 恒 404 `agent_task_not_found`、未登录 demo 对真后端发无凭据请求 401。修法三段:① **生产端补齐**——hub 两条 edge 回调路径(stream 投影 + done-final)在 agent 消息 content jsonb 里 stamp `agent_task:{"task_id":…}`,即 shared normalizer 早已解析却 0 生产者的形状(demo fixture 是唯一生产者);非 object content 不 stamp、既有 ref 不覆盖。② **transcript 写穿**——`normalizeHubMessages` 把它写成 `block.agentTaskId`(`TextTranscriptBlock` 新增可选字段)。③ **双重诚实门**——菜单条目与 regenerate effect 同时要求「端口已接线」**且** `block.agentTaskId` 存在;web 的 `onRegenerate` 随 `chatActions` fail-closed(demo/未登录端口为 undefined ⇒ 入口不渲染、零请求)。**禁止**:从 message id / client_msg_id 猜 task id、失败后静默 fallback、demo 向真 API 发请求。**历史消息无 stamp ⇒ 入口不出现**(fail-closed,不做回填迁移)。验收三层:contract(Go 单测 + live API 404/200 对照)、frontend(shared/workbench/web 单测含两个诚实门用例)、real flow(真浏览器点击:PRE 404+失败 toast / POST 200+新任务+成功 toast;demo PRE 401 请求 / POST 零请求)。证据 `lane-artifacts/round-74/b1-*-PRE/POST.json` + 截图。 | Frontend / Hub dispatch | 是 | | ADR-034 | Accepted 2026-09-04(卫生切片 landed;语义部分 DEFERRED 待 operator) | 项目状态筛选的 vocabulary 裁决(#2274 B-6)。**事实**:Hub 的 workspace/project **没有 status 事实**——model/service/handler/openapi/migration/live DB 六处逐字一致(live 表仅 6 列);`GET /web/projects` 也不接受 status 查询;UI filter(all/running/completed/archived)**100% 前端内存过滤**;三个同名 `workspaceProjectToProjectInfo` 产 5 个输出值,与 Hub 侧对应物交集为 ∅;匹配规则还有三套互相矛盾的实现(精确表 / 精确对 / 子串 `includes('归档')`,后者与表头注释明确拒绝的做法相反)。**裁决**:不在没有 L3 事实源的前提下发明 vocabulary;收敛必须三段——L3 Hub 真 lifecycle 事实(语义由 operator 定义:running/completed/archived 各由什么派生)→ L2 唯一映射(回到 `hubDataMapping.ts`)→ L1 类型分离(`statusLabel` 与 `lifecycle: bucket|'unknown'` 拆开,`unknown` 时 chips 不渲染)。**本批已做(不需裁决)**:删 0 消费者孤儿 mapper(#2291 后唯一「消费者」是测试里一句错误注释,主机已复核 desktop 只 import 编排函数、mapper 用本地副本)+ 修正该注释 + openapi 枚举对齐 0074(run status 补 `pending_review` ×2、assignment type 补 `compete` ×3;漂移根因是 3 个 verifier 只比路由形状不比字段集/enum)+ 清 workbench vitest coverage 对已删文件的 stale exclude。**DEFERRED 触发条件**:operator 给出 lifecycle 语义定义,或 Hub 获得 status 字段;在此之前 filter 词汇保持现状但**不得**在对外文档中被表述为 Hub 事实。证据 `lane-artifacts/round-74/b6-vocabulary-survey.md`(526 行,主机复核 3/3 关键断言成立)。 | Product / Frontend / Hub | 是 | ## Archive diff --git a/hub-server/internal/service/agent/agent_edge_callback.go b/hub-server/internal/service/agent/agent_edge_callback.go index 2845b99bb..d5b3fa55a 100644 --- a/hub-server/internal/service/agent/agent_edge_callback.go +++ b/hub-server/internal/service/agent/agent_edge_callback.go @@ -287,6 +287,10 @@ func (s *EdgeCallbackService) HandleTaskStream(ctx context.Context, edgeUserID, if err != nil { return err } + // #2274 B-1: stamp the producing task id into the projected message so the + // transcript can offer "regenerate" with the identity the endpoint actually + // requires (task id), instead of the shell guessing from a message id. + messageContent = agentevent.StampAgentTaskRef(messageContent, taskID) // #130: idempotent stream-to-message — skip if a message with this client_msg_id already exists if stream.ClientMsgID != "" { @@ -703,6 +707,9 @@ func (s *EdgeCallbackService) buildDoneFinalMessage(ctx context.Context, ai *mod if err != nil { return nil, err } + // #2274 B-1: same task-ref stamp as the stream projection path, so both + // agent messages of one run carry the identity of the run that made them. + messageContent = agentevent.StampAgentTaskRef(messageContent, task.ID) if s.seq == nil { return nil, errcode.ErrInternal.WithMessage("edge callback sequence allocator not configured") } diff --git a/hub-server/internal/service/agent/agent_final_message_test.go b/hub-server/internal/service/agent/agent_final_message_test.go index 8641ddfc3..2cbb39671 100644 --- a/hub-server/internal/service/agent/agent_final_message_test.go +++ b/hub-server/internal/service/agent/agent_final_message_test.go @@ -6,10 +6,12 @@ package agent import ( "context" + "encoding/json" "testing" "github.com/DATA-DOG/go-sqlmock" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/agenthub/hub-server/internal/model" ) @@ -186,3 +188,30 @@ func TestCanonicalContent(t *testing.T) { // The projection wrapper {"content": X} unwraps to X. assert.Equal(t, "x", canonicalContent(`{"content":"x"}`)) } + +// #2274 B-1: the done-final message must carry the producing task ref, exactly +// like the stream projection path — the transcript needs `agent_task.task_id` +// to offer an honest regenerate (the endpoint only accepts task ids). +func TestHandleTaskDone_StampsProducingTaskRefOnFinalMessage(t *testing.T) { + db := newAgentRunEventTestDB(t) + b := newTestBus(t) + svc := &Service{db: db, bus: b, cacheClient: &mockAgentCache{}} + + err := svc.HandleTaskDone(context.Background(), "user-1", "dev-1", "task-1", "run-1", "FINAL ANSWER") + require.NoError(t, err) + + var msg model.Message + require.NoError(t, db.Where("session_id = ? AND sender_type = ?", "sess-1", model.SenderTypeAgent). + Order("seq_id DESC").First(&msg).Error) + + var content struct { + Content string `json:"content"` + AgentTask *struct { + TaskID string `json:"task_id"` + } `json:"agent_task"` + } + require.NoError(t, json.Unmarshal([]byte(msg.Content), &content)) + require.Equal(t, "FINAL ANSWER", content.Content, "visible text must survive the stamp") + require.NotNil(t, content.AgentTask, "done-final message must carry the producing task ref") + require.Equal(t, "task-1", content.AgentTask.TaskID) +} diff --git a/hub-server/internal/service/agent/agent_run_event_test.go b/hub-server/internal/service/agent/agent_run_event_test.go index bd05f96e8..7ad0577ba 100644 --- a/hub-server/internal/service/agent/agent_run_event_test.go +++ b/hub-server/internal/service/agent/agent_run_event_test.go @@ -168,7 +168,14 @@ func TestHandleTaskStreamPersistsTypedRunEventAndProjection(t *testing.T) { var projected model.Message require.NoError(t, db.Where("session_id = ? AND client_msg_id = ?", "sess-1", "11111111-1111-4111-8111-111111111111").First(&projected).Error) require.Equal(t, model.ContentTypeText, projected.ContentType) - require.JSONEq(t, string(payload), projected.Content) + // #2274 B-1: the projection keeps the event payload verbatim AND carries + // the producing task ref (`agent_task.task_id`) that the transcript needs + // to offer an honest regenerate — hub stamps it on both callback paths. + require.JSONEq( + t, + `{"type":"run.agent.tool_call","callId":"call-1","toolName":"read_file","agent_task":{"task_id":"task-1"}}`, + projected.Content, + ) select { case event := <-agentStream: diff --git a/hub-server/internal/service/agentevent/stamp.go b/hub-server/internal/service/agentevent/stamp.go new file mode 100644 index 000000000..7e37b65bd --- /dev/null +++ b/hub-server/internal/service/agentevent/stamp.go @@ -0,0 +1,42 @@ +package agentevent + +import "encoding/json" + +// StampAgentTaskRef records the producing agent task inside an agent message's +// jsonb content as `agent_task: {"task_id": ""}`. +// +// That is the exact shape the transcript normalizer already parses on the +// client side (app/shared/src/transcript/normalizeHubMessages.ts → +// agentTaskFromRecord → block.agentTaskId), but until #2274 B-1 no production +// path ever emitted it: only demo fixtures populated it. Without the producer, +// the web shell had no server-truthful way to learn which task produced an +// agent reply, and its "regenerate" port ended up sending a message identifier +// to an endpoint that requires a task id (live 404, demo 401). +// +// Contract rules (keep this honest): +// - only object-shaped content is stamped; arrays/scalars pass through +// untouched so user-visible payload is never reshaped; +// - an existing `agent_task` ref is never overwritten; +// - empty task id or invalid json leaves the content unchanged. +func StampAgentTaskRef(content string, taskID string) string { + if taskID == "" || content == "" { + return content + } + var obj map[string]json.RawMessage + if err := json.Unmarshal([]byte(content), &obj); err != nil || obj == nil { + return content + } + if _, exists := obj["agent_task"]; exists { + return content + } + ref, err := json.Marshal(map[string]string{"task_id": taskID}) + if err != nil { + return content + } + obj["agent_task"] = ref + out, err := json.Marshal(obj) + if err != nil { + return content + } + return string(out) +} diff --git a/hub-server/internal/service/agentevent/stamp_test.go b/hub-server/internal/service/agentevent/stamp_test.go new file mode 100644 index 000000000..d0d892fc3 --- /dev/null +++ b/hub-server/internal/service/agentevent/stamp_test.go @@ -0,0 +1,64 @@ +package agentevent + +import ( + "encoding/json" + "testing" +) + +func stampTaskID(t *testing.T, content string) string { + t.Helper() + var obj map[string]json.RawMessage + if err := json.Unmarshal([]byte(content), &obj); err != nil { + t.Fatalf("stamped content is not a json object: %v", err) + } + var ref struct { + TaskID string `json:"task_id"` + } + raw, ok := obj["agent_task"] + if !ok { + t.Fatalf("agent_task ref missing in %s", content) + } + if err := json.Unmarshal(raw, &ref); err != nil { + t.Fatalf("agent_task ref unreadable: %v", err) + } + return ref.TaskID +} + +func TestStampAgentTaskRefObjectContent(t *testing.T) { + out := StampAgentTaskRef(`{"content":"B-1 final answer"}`, "task-1") + if got := stampTaskID(t, out); got != "task-1" { + t.Fatalf("task_id = %q, want task-1", got) + } + // the visible text must survive untouched + var obj map[string]any + if err := json.Unmarshal([]byte(out), &obj); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if obj["content"] != "B-1 final answer" { + t.Fatalf("content reshaped: %v", obj["content"]) + } +} + +func TestStampAgentTaskRefNeverReshapesNonObjects(t *testing.T) { + for _, in := range []string{`["a","b"]`, `"plain"`, `42`, `null`, `not json`} { + if out := StampAgentTaskRef(in, "task-1"); out != in { + t.Fatalf("non-object content reshaped: in=%s out=%s", in, out) + } + } +} + +func TestStampAgentTaskRefNoOpEdges(t *testing.T) { + if out := StampAgentTaskRef(`{"content":"x"}`, ""); out != `{"content":"x"}` { + t.Fatalf("empty task id must be a no-op, got %s", out) + } + if out := StampAgentTaskRef("", "task-1"); out != "" { + t.Fatalf("empty content must be a no-op, got %s", out) + } +} + +func TestStampAgentTaskRefPreservesExistingRef(t *testing.T) { + in := `{"content":"x","agent_task":{"task_id":"original","status":"done"}}` + if out := StampAgentTaskRef(in, "task-1"); stampTaskID(t, out) != "original" { + t.Fatalf("existing agent_task ref was overwritten: %s", out) + } +} From 40991ed119238e1b7728f16ef8b18c3aa30cc585 Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Fri, 4 Sep 2026 14:21:20 +0800 Subject: [PATCH 2/3] =?UTF-8?q?test(e2e):=20round-74=20Golden=20Flows?= =?UTF-8?q?=E2=80=94=E2=80=94=E5=9B=9B=E6=9D=A1=E6=9C=80=E8=96=84=E7=9C=9F?= =?UTF-8?q?=E6=B5=81=E9=AA=8C=E6=94=B6=E8=BF=9B=20real=20lane=EF=BC=88GF2?= =?UTF-8?q?=20=E5=8D=B3=20B-1=20=E5=9B=9E=E5=BD=92=E9=97=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 不造新框架:复用既有 L3 real lane(playwright.real.config.ts testMatch 注册 + run-real-e2e-lane.sh),全程真实 OIDC Authorization Code + PKCE(无自签 JWT、无 stub hub),agent 回复经真实 Edge→Hub 回调契约产生(唯一非真实部件是模型 runner)。 - GF1 真登录后主界面拿到真实 Hub 数据(会话标题与 agent 回复文本逐字来自 provisioning,非 demo fixture); - GF2 「重新生成」发**真 task id** 且 200 + 成功 toast(#2274 B-1 的回归门:修复前 这里是 message/client_msg id + 404); - GF3 未登录 demo 不提供会失败的「重新生成」且零 /regenerate 请求(诚实门回归); - GF4 Edge 回调落地后 transcript 无需手动刷新(round-73 invalidation 修复的回归门)。 实测(huawei-dev 真栈,manifest-20260904-141958.json status=passed): 4 passed(GF1 17.3s / GF2 1.2m / GF3 11.3s / GF4 16.8s)。 两条环境事实已写进 spec 注释:ID 对 /api/auth/login 按 IP 限流(窗口内第 3 次起 429)⇒ describe 串行 + 登录按 Retry-After 退避自愈;create-session 响应键是 session_id、send-message 是 message_id(与 hub DTO 逐字对齐)。 Refs #2274 (B-1 回归门) Co-authored-by: Cursor --- app/e2e/golden-flows.spec.ts | 326 ++++++++++++++++++++++++++++++ app/web/playwright.real.config.ts | 4 +- 2 files changed, 329 insertions(+), 1 deletion(-) create mode 100644 app/e2e/golden-flows.spec.ts diff --git a/app/e2e/golden-flows.spec.ts b/app/e2e/golden-flows.spec.ts new file mode 100644 index 000000000..617c0ff5c --- /dev/null +++ b/app/e2e/golden-flows.spec.ts @@ -0,0 +1,326 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import { test, expect, request as pwRequest, type APIRequestContext, type Page } from '@playwright/test'; + +/** + * AgentHub Golden Flows(round-74 Phase 3)——最薄的「用户真实操作」验收层。 + * + * 纪律(AGENTS §5.5 L3 + #1839): + * - 只复用既有 real lane(playwright.real.config.ts + run-real-e2e-lane.sh),不引入新框架; + * - 全程真实 OIDC Authorization Code + PKCE(无自签 JWT、无 stub hub); + * - agent 回复消息经**真实 Edge→Hub 回调契约**(/edge/agent-tasks/:id/ack|stream|done) + * 产生,而不是直接写库;唯一非真实部件是模型 runner 本身(由本 spec 代答)。 + * + * 四条流: + * GF1 真登录后主界面拿到真实数据(会话与消息来自 Hub,不是 demo); + * GF2 「重新生成」用真 task identity 且成功(#2274 B-1 的回归门); + * GF3 未登录 demo 不提供会失败的「重新生成」(B-1 的诚实门回归); + * GF4 Edge 回调落地后 transcript 无需手动刷新(round-73 invalidation 修复的回归门)。 + */ + +const ID = process.env.AGENTHUB_E2E_ID_BASE_URL || 'http://127.0.0.1:3000'; +const HUB = process.env.AGENTHUB_E2E_HUB_BASE_URL || 'http://127.0.0.1:8080'; +const WEB = process.env.AGENTHUB_E2E_WEB_BASE_URL || 'http://127.0.0.1:5174'; +const WEB_CALLBACK = `${WEB}/workbench/auth/tokendance/callback`; +const ACCOUNT_ENV = `${process.env.AGENTHUB_E2E_ACCOUNT_ENV || '/root/agenthub-dev/AgentHub/tests/artifacts/real-e2e-account.env'}`; + +function readAccountEnv(): Record { + const out: Record = {}; + if (!fs.existsSync(ACCOUNT_ENV)) return out; + for (const line of fs.readFileSync(ACCOUNT_ENV, 'utf8').split('\n')) { + const i = line.indexOf('='); + if (i > 0) out[line.slice(0, i).trim()] = line.slice(i + 1).trim(); + } + return out; +} + +const creds = readAccountEnv(); + +function pkce(): { verifier: string; challenge: string } { + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~'; + let verifier = ''; + const bytes = crypto.randomBytes(64); + for (let i = 0; i < 64; i += 1) verifier += alphabet[bytes[i]! % alphabet.length]; + const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); + return { verifier, challenge }; +} + +interface HubSession { token: string; userId: string } + +/** 真实 Authorization Code + PKCE:ID 登录 → hub authorize → consent → hub callback。 */ +async function realHubSession(api: APIRequestContext, email: string, password: string, deviceType: 'web' | 'desktop'): Promise { + // ID 对 /api/auth/login 按 IP 限流(实测窗口内第 3 次起 429):按 Retry-After + // 退避重试,而不是把限流当成登录失败。 + let loginStatus = 0; + for (let attempt = 0; attempt < 6; attempt += 1) { + const login = await api.post(`${ID}/api/auth/login`, { data: { email, password }, maxRedirects: 0 }); + loginStatus = login.status(); + if (loginStatus === 200) break; + if (loginStatus !== 429) break; + const retryAfter = Number(login.headers()['retry-after'] ?? '0') || 10 * (attempt + 1); + await new Promise((resolve) => { setTimeout(resolve, retryAfter * 1000); }); + } + expect(loginStatus, `ID login for ${deviceType}`).toBe(200); + + const { verifier, challenge } = pkce(); + const deviceId = crypto.randomUUID(); + const authorize = await api.post(`${HUB}/client/auth/oidc/authorize`, { + data: { code_challenge: challenge, code_challenge_method: 'S256', device_type: deviceType, device_id: deviceId, redirect_uri: WEB_CALLBACK }, + }); + expect(authorize.status()).toBe(200); + const az = (await authorize.json()).data as { state: string; authorization_url: string }; + + let code: string | null = null; + const first = await api.get(az.authorization_url, { maxRedirects: 0 }); + if ([302, 303, 307].includes(first.status())) { + code = new URL(first.headers().location ?? '', ID).searchParams.get('code'); + } else { + expect(first.status()).toBe(200); + const html = await first.text(); + const form = html.match(/]+action="\/oidc\/authorize\/confirm"[^>]*>([\s\S]*?)<\/form>/); + const formBody = form?.[1]; + expect(formBody, 'consent form present').toBeTruthy(); + const fields: Record = {}; + for (const m of (formBody ?? '').matchAll(/]+name="([^"]+)"[^>]+value="([^"]*)"/g)) fields[m[1] ?? ''] = m[2] ?? ''; + const confirm = await api.post(`${ID}/oidc/authorize/confirm`, { + data: new URLSearchParams(fields).toString(), + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + maxRedirects: 0, + }); + expect([302, 303, 307].includes(confirm.status()), `consent confirm status ${confirm.status()}`).toBe(true); + code = new URL(confirm.headers().location ?? '', ID).searchParams.get('code'); + } + expect(code, 'authorization code obtained').not.toBeNull(); + + const callback = await api.post(`${HUB}/client/auth/oidc/callback`, { + data: { code, state: az.state, code_verifier: verifier, device_type: deviceType, device_id: deviceId, redirect_uri: WEB_CALLBACK }, + }); + expect(callback.status(), 'hub oidc callback').toBe(200); + const body = (await callback.json()).data as { access_token: string; user: { id: string } }; + return { token: body.access_token, userId: body.user.id }; +} + +async function hubJson(api: APIRequestContext, method: 'get' | 'post', path: string, token: string, data?: unknown) { + const res = await api[method === 'get' ? 'get' : 'post'](`${HUB}${path}`, { + data, + headers: { Authorization: `Bearer ${token}` }, + }); + return { status: res.status(), body: (await res.json().catch(() => null)) as Record | null }; +} + +interface Provisioned { + sessionId: string; + taskId: string; + agentReplyText: string; + sessionName: string; +} + +/** 真数据:group 会话 → agent 实例 → 用户消息 → 任务 → 真实 Edge 回调产出 agent 回复。 */ +async function provisionConversation(api: APIRequestContext, web: HubSession, desktop: HubSession, label: string): Promise { + const sessionName = `GF ${label} ${crypto.randomBytes(3).toString('hex')}`; + const session = await hubJson(api, 'post', '/client/sessions', web.token, { type: 'group', name: sessionName, member_ids: [] }); + expect(session.status, 'create group session').toBe(200); + const sessionId = (session.body?.data?.session_id ?? session.body?.data?.id) as string; + expect(sessionId, 'session id in create response').toBeTruthy(); + + const agent = await hubJson(api, 'post', `/client/sessions/${sessionId}/agents`, web.token, { agent_type: 'claude-code', display_name: 'GF Agent' }); + expect(agent.status, 'add agent').toBe(200); + const agentInstanceId = agent.body?.data?.id as string; + + const msg = await hubJson(api, 'post', `/client/sessions/${sessionId}/messages`, web.token, { + content_type: 'text', + content: JSON.stringify({ content: `${label}: please answer once.` }), + }); + expect(msg.status, 'send trigger message').toBe(200); + const triggerMessageId = (msg.body?.data?.message_id ?? msg.body?.data?.id) as string; + expect(triggerMessageId, 'message id in send response').toBeTruthy(); + + const task = await hubJson(api, 'post', '/web/agent-tasks', web.token, { trigger_message_id: triggerMessageId, agent_instance_id: agentInstanceId }); + expect(task.status, 'trigger task').toBe(200); + const taskId = task.body?.data?.id as string; + + const runId = `gf-run-${crypto.randomBytes(6).toString('hex')}`; + const agentReplyText = `${label}: final answer from the real Edge callback contract.`; + for (const [path, payload] of [ + [`/edge/agent-tasks/${taskId}/ack`, { edge_run_id: runId }], + [`/edge/agent-tasks/${taskId}/stream`, { edge_run_id: runId, event_type: 'output', content: `${label}: thinking...` }], + [`/edge/agent-tasks/${taskId}/done`, { edge_run_id: runId, final_content: agentReplyText }], + ] as Array<[string, unknown]>) { + const res = await api.post(`${HUB}${path}`, { data: payload, headers: { Authorization: `Bearer ${desktop.token}` } }); + expect(res.status(), `edge callback ${path.split('/').pop()}`).toBe(200); + } + return { sessionId, taskId, agentReplyText, sessionName }; +} + +/** 真浏览器 OIDC 登录(与 real-oidc-login.spec.ts 同一路径)。 */ +async function loginInBrowser(page: Page): Promise { + await page.goto(`${WEB}/workbench/`, { waitUntil: 'domcontentloaded' }); + const composer = page.locator('textarea[data-composer-input]').first(); + await composer.click(); + await composer.fill('golden-flow: open sign-in'); + await composer.press('Enter'); + await page.getByRole('button', { name: /Continue with TokenDance|使用 TokenDance ID 登录/i }).first().click(); + const idHost = new URL(ID).host.replace(/\./g, '\\.'); + await expect(page).toHaveURL(new RegExp(`${idHost}/login`), { timeout: 25_000 }); + await page.locator('#login-email').fill(creds.AGENTHUB_E2E_USER_EMAIL ?? ''); + await page.locator('#login-password').fill(creds.AGENTHUB_E2E_USER_PASSWORD ?? ''); + // ID 按 IP 限流 /api/auth/login:并行 spec 会消耗配额,提交后可能停在登录页。 + // 表单值保留,所以限流窗口过后重提交即可自愈(不把限流当登录失败)。 + const consent = page.locator('form[action="/oidc/authorize/confirm"]'); + for (let attempt = 0; attempt < 4; attempt += 1) { + await page.locator('button.login-submit').click(); + const appeared = await consent.waitFor({ timeout: 12_000 }).then(() => true).catch(() => false); + if (appeared) break; + if (attempt === 3) await consent.waitFor({ timeout: 25_000 }); + await page.waitForTimeout(15_000); + } + await consent.locator('button[type="submit"]').click(); + await page.waitForURL('**/workbench/**', { timeout: 30_000 }); + await page.waitForFunction(() => sessionStorage.getItem('agenthub_hub_token') !== null, null, { timeout: 30_000 }); +} + +async function openSessionByName(page: Page, name: string): Promise { + const row = page.locator('[role="listbox"] [role="option"] button', { hasText: name }).first(); + await row.click({ timeout: 25_000 }); + await page.waitForTimeout(1500); +} + +/** 右键一个 transcript 块并点「重新生成」;返回菜单是否提供该条目。 */ +async function clickRegenerate(page: Page, block: import('@playwright/test').Locator): Promise { + const target = block.first(); + await target.scrollIntoViewIfNeeded({ timeout: 8000 }); + try { + await target.click({ button: 'right', force: true, timeout: 8000 }); + } catch { + const box = await target.boundingBox(); + if (!box) return false; + await target.dispatchEvent('contextmenu', { bubbles: true, cancelable: true, button: 2, clientX: box.x + 20, clientY: box.y + 10 }); + } + await page.waitForTimeout(600); + const item = page.locator('[role="menuitem"]', { hasText: /Regenerate|重新生成/ }).first(); + if (!(await item.isVisible().catch(() => false))) { + await page.keyboard.press('Escape'); + return false; + } + await item.click(); + return true; +} + +test.describe('AgentHub Golden Flows(真实栈)', () => { + // 串行:四条流共享同一个 IP 的 ID 登录配额,并行只会互相限流。 + test.describe.configure({ mode: 'serial' }); + test.setTimeout(240_000); + + // ID 对 /api/auth/login 按 IP 限流(实测连续第 3 次起 429):整个 describe 只登录 + // 一次,web/desktop 两种 device_type 的 hub 会话共用同一个 ID cookie jar 派生。 + let sharedApi: APIRequestContext; + let webSession: HubSession; + let desktopSession: HubSession; + + test.beforeAll(async () => { + sharedApi = await pwRequest.newContext(); + webSession = await realHubSession(sharedApi, creds.AGENTHUB_E2E_USER_EMAIL ?? '', creds.AGENTHUB_E2E_USER_PASSWORD ?? '', 'web'); + desktopSession = await realHubSession(sharedApi, creds.AGENTHUB_E2E_USER_EMAIL ?? '', creds.AGENTHUB_E2E_USER_PASSWORD ?? '', 'desktop'); + }); + + test.afterAll(async () => { + await sharedApi?.dispose(); + }); + + test('GF1 真登录后主界面拿到真实 Hub 数据(会话与消息非 demo)', async ({ request, browser }) => { + const prov = await provisionConversation(sharedApi, webSession, desktopSession, 'GF1'); + + const context = await browser.newContext({ viewport: { width: 1440, height: 810 }, locale: 'en-US' }); + await context.route('**/challenges.cloudflare.com/**', (r) => r.abort()); + const page = await context.newPage(); + await loginInBrowser(page); + await openSessionByName(page, prov.sessionName); + // 真数据断言:用户消息与 agent 回复都来自 Hub(文本逐字匹配 provisioning 内容)。 + await expect(page.getByText(prov.agentReplyText).first()).toBeVisible({ timeout: 20_000 }); + await context.close(); + }); + + test('GF2 「重新生成」发真 task id 且成功(#2274 B-1 回归门)', async ({ request, browser }) => { + const prov = await provisionConversation(sharedApi, webSession, desktopSession, 'GF2'); + + const context = await browser.newContext({ viewport: { width: 1440, height: 810 }, locale: 'en-US' }); + await context.route('**/challenges.cloudflare.com/**', (r) => r.abort()); + const page = await context.newPage(); + const regenerateCalls: Array<{ url: string; status: number }> = []; + page.on('response', (res) => { + if (res.url().includes('/regenerate')) regenerateCalls.push({ url: res.url(), status: res.status() }); + }); + await loginInBrowser(page); + await openSessionByName(page, prov.sessionName); + const block = page.locator('[data-block-id^="hub-message-"]', { hasText: prov.agentReplyText }).first(); + await expect(block).toBeVisible({ timeout: 20_000 }); + + const offered = await clickRegenerate(page, page.locator('[data-block-id^="hub-message-"]').filter({ hasText: prov.agentReplyText })); + expect(offered, 'regenerate entry offered for a stamped agent reply').toBe(true); + + await expect.poll(() => regenerateCalls.length, { timeout: 15_000 }).toBeGreaterThan(0); + // 身份合同:URL 里必须是**任务 id**,且请求成功(B-1 修复前这里是 message/client_msg id + 404)。 + expect(regenerateCalls[0]!.url).toContain(`/web/agent-tasks/${prov.taskId}/regenerate`); + expect(regenerateCalls[0]!.status).toBe(200); + await expect(page.getByText(/Regenerating|重新生成中/i).first()).toBeVisible({ timeout: 10_000 }); + await context.close(); + }); + + test('GF3 未登录 demo 不提供会失败的「重新生成」(诚实门回归)', async ({ browser }) => { + const context = await browser.newContext({ viewport: { width: 1440, height: 810 }, locale: 'en-US' }); + await context.route('**/challenges.cloudflare.com/**', (r) => r.abort()); + const page = await context.newPage(); + const regenerateCalls: string[] = []; + page.on('request', (req) => { + if (req.url().includes('/regenerate')) regenerateCalls.push(req.url()); + }); + await page.goto(`${WEB}/workbench/`, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(4000); + expect(await page.evaluate(() => sessionStorage.getItem('agenthub_hub_token') !== null)).toBe(false); + + const blocks = await page.evaluate(() => Array.from(document.querySelectorAll('[data-block-id]')).map((e) => e.getAttribute('data-block-id') ?? '')); + expect(blocks.length, 'demo transcript renders blocks').toBeGreaterThan(0); + for (const id of blocks.slice(0, 6)) { + const offered = await clickRegenerate(page, page.locator(`[data-block-id="${id}"]`)); + expect(offered, `demo block ${id} must not offer regenerate`).toBe(false); + } + expect(regenerateCalls).toEqual([]); + await context.close(); + }); + + test('GF4 Edge 回调落地后 transcript 无需手动刷新(invalidation 回归门)', async ({ request, browser }) => { + const prov = await provisionConversation(sharedApi, webSession, desktopSession, 'GF4'); + + const context = await browser.newContext({ viewport: { width: 1440, height: 810 }, locale: 'en-US' }); + await context.route('**/challenges.cloudflare.com/**', (r) => r.abort()); + const page = await context.newPage(); + await loginInBrowser(page); + await openSessionByName(page, prov.sessionName); + await expect(page.getByText(prov.agentReplyText).first()).toBeVisible({ timeout: 20_000 }); + + // 页面保持打开:再触发一个任务并走真实回调,新 agent 消息必须自己出现。 + const msg2 = await hubJson(sharedApi, 'post', `/client/sessions/${prov.sessionId}/messages`, webSession.token, { + content_type: 'text', + content: JSON.stringify({ content: 'GF4: second question, answer again.' }), + }); + expect(msg2.status).toBe(200); + const task2 = await hubJson(sharedApi, 'post', '/web/agent-tasks', webSession.token, { + trigger_message_id: msg2.body?.data?.message_id, + agent_instance_id: (await hubJson(sharedApi, 'post', `/client/sessions/${prov.sessionId}/agents`, webSession.token, { agent_type: 'claude-code', display_name: 'GF Agent 2' })).body?.data?.id, + }); + expect(task2.status).toBe(200); + const taskId2 = task2.body?.data?.id as string; + const runId2 = `gf-run2-${crypto.randomBytes(6).toString('hex')}`; + const secondReply = 'GF4: second answer arrived without a manual reload.'; + for (const [path, payload] of [ + [`/edge/agent-tasks/${taskId2}/ack`, { edge_run_id: runId2 }], + [`/edge/agent-tasks/${taskId2}/done`, { edge_run_id: runId2, final_content: secondReply }], + ] as Array<[string, unknown]>) { + const res = await sharedApi.post(`${HUB}${path}`, { data: payload, headers: { Authorization: `Bearer ${desktopSession.token}` } }); + expect(res.status(), `edge callback ${path.split('/').pop()}`).toBe(200); + } + // 无 reload、无手动 refetch:WS/invalidation 必须把新消息带进 DOM。 + await expect(page.getByText(secondReply).first()).toBeVisible({ timeout: 30_000 }); + await context.close(); + }); +}); diff --git a/app/web/playwright.real.config.ts b/app/web/playwright.real.config.ts index 808dc2812..11ab4b592 100644 --- a/app/web/playwright.real.config.ts +++ b/app/web/playwright.real.config.ts @@ -17,6 +17,8 @@ import { fileURLToPath } from 'node:url'; // - chat-real.spec.ts Hub/Edge API + IM lifecycle (dev-secret JWT lane) // - real-oidc-login.spec.ts real browser OIDC login + chat flow (#1839 B2) // - private-url-preview.spec.ts private-URL gate real-scenario (#1922 item 4) +// - golden-flows.spec.ts round-74 Golden Flows:真登录真数据 / regenerate 真 +// task identity / demo 诚实门 / edge 回调后自动刷新 // // CI status: never run in CI (e2e-smoke only runs smoke.spec.ts under // playwright.config.ts). Run locally with all services up: @@ -42,7 +44,7 @@ export default defineConfig({ testDir: '../e2e', // 显式列表 = 真实栈 lane 的 spec 清单(run-real-e2e-lane.sh 默认不带位置 // 过滤运行本列表全部 spec;新增 real spec 必须在此注册)。 - testMatch: ['chat-real.spec.ts', 'real-oidc-login.spec.ts', 'private-url-preview.spec.ts'], + testMatch: ['chat-real.spec.ts', 'real-oidc-login.spec.ts', 'private-url-preview.spec.ts', 'golden-flows.spec.ts'], timeout: 30_000, expect: { timeout: 5_000 }, retries: process.env.CI ? 2 : 0, From ada21606bacdd0de654b75a5efa0c772e38cc440 Mon Sep 17 00:00:00 2001 From: DeliciousBuding Date: Fri, 4 Sep 2026 15:53:07 +0800 Subject: [PATCH 3/3] =?UTF-8?q?test(hub-test):=20=E4=BF=AE=E5=A5=BD?= =?UTF-8?q?=E8=A2=AB=20B-1=20content=20stamp=20=E6=89=93=E7=BA=A2=E7=9A=84?= =?UTF-8?q?=20edge=20=E5=9B=9E=E8=B0=83=E5=A5=91=E7=BA=A6=E9=9B=86?= =?UTF-8?q?=E6=88=90=E6=B5=8B=E8=AF=95=EF=BC=8C=E5=B9=B6=E6=8A=8A=E5=AE=83?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E6=88=90=20stamp=20=E7=9A=84=20DB=20?= =?UTF-8?q?=E7=BA=A7=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 上 Backend integration (PostgreSQL + Redis) 判红,本机复现一致: backend_e2e_hub_edge_contract_test.go:107 load final agent message: record not found。 根因不是 stamp 错了,是测试的断言方式错了。该用例按「整值相等」找最终 agent 消息: WHERE content = finalContent。content 是 jsonb,比较是语义级的(不吃空格与键序), 但仍然是整值匹配;而 B-1 之后落库的值 = 回调传来的 final_content 再加上 agentevent.StampAgentTaskRef 盖的 agent_task ref。多出来的这个键正是本次改动的目的, 所以整值匹配在 stamp 落地的那一刻必然失败——它断言的是实现细节(落库内容逐字等于 edge 发来的内容),不是契约。 改法: - 定位改成「该会话该 agent 实例 seq_id 最大的一条」,这本身就是最终消息的定义; - content 现在是混合形状(text 是字符串、agent_task 是对象),解码类型从 map[string]string 换成 map[string]json.RawMessage,否则解码本身就报错; - 语义断言保留:content_type=text、text 逐字等于 "Hub Edge callback contract final"; - 新增契约断言:agent_task.task_id 必须等于 task.ID。这是 B-1 那条 identity 合同的 DB 级一半(另一半是 transcript normalizer 与真实 Web→Hub 流,已在 PR 正文的 PRE/POST 真流证据里)。 证据: - 本机真 PostgreSQL:该用例 PRE 复现 record not found,改后 PASS; 全量 go test -tags integration ./tests/integration/ -count=1 = ok 82.073s、rc=0。 - 变异测试 1 发(M8):把 StampAgentTaskRef 直接 return content 短路掉 ⇒ 新断言翻红 且诊断精确(decode stamped agent_task ref: unexpected end of JSON input, content={"text": "Hub Edge callback contract final"}),证明它不是空断言。 Co-authored-by: Cursor --- .../backend_e2e_hub_edge_contract_test.go | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/hub-server/tests/integration/backend_e2e_hub_edge_contract_test.go b/hub-server/tests/integration/backend_e2e_hub_edge_contract_test.go index f1a3c3246..83c940fa4 100644 --- a/hub-server/tests/integration/backend_e2e_hub_edge_contract_test.go +++ b/hub-server/tests/integration/backend_e2e_hub_edge_contract_test.go @@ -101,7 +101,16 @@ func TestBackendE2E_HubEdgeCallbackContract_DBWS_NoCLI(t *testing.T) { } var finalMessage model.Message - if err := db.Where("session_id = ? AND sender_type = ? AND sender_id = ? AND content = ?", agent.SessionID, model.SenderTypeAgent, task.AgentInstanceID, finalContent). + // The lookup must not match on the whole content value. `content` is jsonb, so + // the comparison is semantic (whitespace and key order insensitive) but still + // whole-value — and since #2274 B-1 the stored value is the callback's + // final_content PLUS an `agent_task` ref (agentevent.StampAgentTaskRef). That + // extra key is the point of the change, so a whole-value match made this test + // fail the moment the stamp landed: it was asserting an implementation detail + // (content equals exactly what the edge sent) instead of the contract. The + // latest agent message in the session IS the final message; the semantics are + // asserted below. + if err := db.Where("session_id = ? AND sender_type = ? AND sender_id = ?", agent.SessionID, model.SenderTypeAgent, task.AgentInstanceID). Order("seq_id DESC"). First(&finalMessage).Error; err != nil { t.Fatalf("load final agent message: %v", err) @@ -109,12 +118,32 @@ func TestBackendE2E_HubEdgeCallbackContract_DBWS_NoCLI(t *testing.T) { if finalMessage.ContentType != model.ContentTypeText { t.Fatalf("final message content_type = %q, want %q", finalMessage.ContentType, model.ContentTypeText) } - var finalPayload map[string]string + // Content is now a mixed-shape object (string `text`, object `agent_task`), + // so decode into RawMessage instead of map[string]string. + var finalPayload map[string]json.RawMessage if err := json.Unmarshal([]byte(finalMessage.Content), &finalPayload); err != nil { t.Fatalf("decode final message content: %v", err) } - if finalPayload["text"] != "Hub Edge callback contract final" { - t.Fatalf("final message text = %q, want Hub Edge callback contract final", finalPayload["text"]) + var finalText string + if err := json.Unmarshal(finalPayload["text"], &finalText); err != nil { + t.Fatalf("decode final message text: %v (content=%s)", err, finalMessage.Content) + } + if finalText != "Hub Edge callback contract final" { + t.Fatalf("final message text = %q, want Hub Edge callback contract final", finalText) + } + // #2274 B-1: the stored message must carry its producing task, or the web + // shell has no server-truthful task id to regenerate with — it used to send a + // message identifier to an endpoint that requires a task id (live 404). This + // is the DB-level half of that contract; the transcript normalizer and the + // real Web->Hub flow are the other two. + var agentTaskRef struct { + TaskID string `json:"task_id"` + } + if err := json.Unmarshal(finalPayload["agent_task"], &agentTaskRef); err != nil { + t.Fatalf("decode stamped agent_task ref: %v (content=%s)", err, finalMessage.Content) + } + if agentTaskRef.TaskID != task.ID { + t.Fatalf("stamped agent_task.task_id = %q, want %q", agentTaskRef.TaskID, task.ID) } }