Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
326 changes: 326 additions & 0 deletions app/e2e/golden-flows.spec.ts

Large diffs are not rendered by default.

32 changes: 32 additions & 0 deletions app/shared/src/transcript/normalizeHubMessages.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
5 changes: 5 additions & 0 deletions app/shared/src/transcript/normalizeHubMessages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
Expand Down
10 changes: 10 additions & 0 deletions app/shared/src/transcript/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion app/web/playwright.real.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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,
Expand Down
61 changes: 61 additions & 0 deletions app/web/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<App />);
Expand All @@ -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(<App />);
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(<App />);
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: {
Expand Down
20 changes: 16 additions & 4 deletions app/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -255,13 +255,21 @@ function WebWorkbenchRoot() {
void agentList.refetch();
}, [agentList]);

const handleRegenerate = useCallback((blockId: string): Promise<void> => {
const handleRegenerate = useCallback((_blockId: string, taskId: string): Promise<void> => {
// #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);
}, []);

Expand Down Expand Up @@ -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={
Expand Down
2 changes: 1 addition & 1 deletion app/workbench/src/AgentHubWorkbenchTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> | void) | undefined;
onRegenerate?: ((blockId: string, taskId: string) => Promise<void> | 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
Expand Down
7 changes: 4 additions & 3 deletions app/workbench/src/useWorkbenchTranscriptChrome.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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');

Expand Down
2 changes: 1 addition & 1 deletion app/workbench/src/useWorkbenchTranscriptChrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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> | void) | undefined;
onRegenerate?: ((blockId: string, taskId: string) => Promise<void> | 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
Expand Down
58 changes: 51 additions & 7 deletions app/workbench/src/workbenchTranscriptChromeActionMappers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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();

Expand All @@ -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');
});
Expand All @@ -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');
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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' }> = [
Expand Down
Loading
Loading