|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * objectui#11667 — the AI chat's tool-approval card and Builder handoff card |
| 5 | + * speak the session's language. |
| 6 | + * |
| 7 | + * `ChatPane` handed `ChatbotEnhanced` three literals for the inline HITL card |
| 8 | + * ("Approve & run", "Reject", and the deny reason "Operator rejected from |
| 9 | + * chat") and left the ADR-0057 P4 handoff card on the component's English |
| 10 | + * defaults ("Build this in the Builder", "Open in Builder →", and the |
| 11 | + * superseded card's tooltip "A newer request is available"). Every other label |
| 12 | + * on the same screen was already a `console.ai.*` key, so a zh-CN reader met |
| 13 | + * these six in English. |
| 14 | + * |
| 15 | + * Rendered as shipped: the REAL `ChatbotEnhanced` under a real `I18nProvider`, |
| 16 | + * with only the transport (`useObjectChat`) stubbed so the conversation is a |
| 17 | + * fixture — one tool call awaiting approval, and two handoff cards, so one of |
| 18 | + * them is the superseded card whose tooltip is the sixth string. The deny |
| 19 | + * reason is read where it is RECORDED: the body of the reject request the real |
| 20 | + * `useHitlInChat` sends. |
| 21 | + */ |
| 22 | +import '@testing-library/jest-dom/vitest'; |
| 23 | +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; |
| 24 | +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; |
| 25 | +import { MemoryRouter } from 'react-router-dom'; |
| 26 | +import React from 'react'; |
| 27 | + |
| 28 | +/** The six strings the card names, as they used to reach every locale. */ |
| 29 | +const ENGLISH = [ |
| 30 | + 'Approve & run', |
| 31 | + 'Reject', |
| 32 | + 'Operator rejected from chat', |
| 33 | + 'Build this in the Builder', |
| 34 | + 'Open in Builder →', |
| 35 | + 'A newer request is available', |
| 36 | +] as const; |
| 37 | + |
| 38 | +/** |
| 39 | + * The conversation: one destructive tool call held for approval (the shape |
| 40 | + * `mapMessages` lifts from a `pending_approval` result), then two |
| 41 | + * `suggest_builder` handoffs — the older one is superseded by the newer one. |
| 42 | + * Module-scoped so the stubbed hook hands back one stable array. |
| 43 | + */ |
| 44 | +const MESSAGES = [ |
| 45 | + { id: 'u1', role: 'user', content: 'delete the task' }, |
| 46 | + { |
| 47 | + id: 'a1', |
| 48 | + role: 'assistant', |
| 49 | + content: '', |
| 50 | + toolInvocations: [ |
| 51 | + { |
| 52 | + toolCallId: 'call-approve', |
| 53 | + toolName: 'action_delete_task', |
| 54 | + args: { id: 't1' }, |
| 55 | + state: 'approval-requested', |
| 56 | + pendingActionId: 'pa_11667', |
| 57 | + }, |
| 58 | + ], |
| 59 | + }, |
| 60 | + { |
| 61 | + id: 'a2', |
| 62 | + role: 'assistant', |
| 63 | + content: '', |
| 64 | + toolInvocations: [ |
| 65 | + { |
| 66 | + toolCallId: 'call-handoff-old', |
| 67 | + toolName: 'suggest_builder', |
| 68 | + state: 'output-available', |
| 69 | + result: { handoff: 'build', prompt: 'Build a CRM' }, |
| 70 | + builderHandoff: { prompt: 'Build a CRM' }, |
| 71 | + }, |
| 72 | + ], |
| 73 | + }, |
| 74 | + { |
| 75 | + id: 'a3', |
| 76 | + role: 'assistant', |
| 77 | + content: '', |
| 78 | + toolInvocations: [ |
| 79 | + { |
| 80 | + toolCallId: 'call-handoff-new', |
| 81 | + toolName: 'suggest_builder', |
| 82 | + state: 'output-available', |
| 83 | + result: { handoff: 'build', prompt: 'Build a CRM with deals' }, |
| 84 | + builderHandoff: { prompt: 'Build a CRM with deals' }, |
| 85 | + }, |
| 86 | + ], |
| 87 | + }, |
| 88 | +]; |
| 89 | + |
| 90 | +const sendMessage = vi.fn(); |
| 91 | + |
| 92 | +vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => { |
| 93 | + const actual = await importOriginal<Record<string, unknown>>(); |
| 94 | + return { |
| 95 | + ...actual, |
| 96 | + // `ChatbotEnhanced` stays REAL — the rendered card is the surface under test. |
| 97 | + useObjectChat: () => ({ |
| 98 | + messages: MESSAGES, |
| 99 | + isLoading: false, |
| 100 | + error: undefined, |
| 101 | + sendMessage, |
| 102 | + stop: vi.fn(), |
| 103 | + reload: vi.fn(), |
| 104 | + clear: vi.fn(), |
| 105 | + setMessages: vi.fn(), |
| 106 | + }), |
| 107 | + useAiModels: () => ({ models: [], defaultModelId: undefined }), |
| 108 | + }; |
| 109 | +}); |
| 110 | + |
| 111 | +vi.mock('../../../providers/MetadataProvider', async (importOriginal) => { |
| 112 | + const actual = await importOriginal<Record<string, unknown>>(); |
| 113 | + return { ...actual, useMetadata: () => ({ apps: [] }) }; |
| 114 | +}); |
| 115 | +vi.mock('../../../providers/AdapterProvider', async (importOriginal) => { |
| 116 | + const actual = await importOriginal<Record<string, unknown>>(); |
| 117 | + return { ...actual, useAdapter: () => null }; |
| 118 | +}); |
| 119 | + |
| 120 | +import { I18nProvider } from '@object-ui/i18n'; |
| 121 | +import type { AgentDescriptor } from '@object-ui/plugin-chatbot'; |
| 122 | +import { ChatPane } from '../AiChatPage'; |
| 123 | + |
| 124 | +window.matchMedia = ((query: string) => ({ |
| 125 | + matches: false, |
| 126 | + media: query, |
| 127 | + onchange: null, |
| 128 | + addEventListener: () => {}, |
| 129 | + removeEventListener: () => {}, |
| 130 | + addListener: () => {}, |
| 131 | + removeListener: () => {}, |
| 132 | + dispatchEvent: () => false, |
| 133 | +})) as unknown as typeof window.matchMedia; |
| 134 | + |
| 135 | +function renderPane(language: string) { |
| 136 | + return render( |
| 137 | + <I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }} persistLanguage={false}> |
| 138 | + <MemoryRouter initialEntries={['/ai/ask/conv-1']}> |
| 139 | + <ChatPane |
| 140 | + agents={[{ name: 'ask', label: 'Ask' } as unknown as AgentDescriptor]} |
| 141 | + agentsLoading={false} |
| 142 | + agentsError={undefined} |
| 143 | + activeAgent="ask" |
| 144 | + chatApi="/api/v1/ai/agents/ask/chat" |
| 145 | + apiBase="/api/v1/ai" |
| 146 | + conversationId="conv-1" |
| 147 | + initialMessages={[]} |
| 148 | + pendingFirstMessageRef={{ current: null }} |
| 149 | + onSent={vi.fn()} |
| 150 | + onShare={vi.fn()} |
| 151 | + /> |
| 152 | + </MemoryRouter> |
| 153 | + </I18nProvider>, |
| 154 | + ); |
| 155 | +} |
| 156 | + |
| 157 | +/** |
| 158 | + * Everything a reader can meet on the page: the text, and the attributes that |
| 159 | + * surface as tooltips or accessible names. The superseded card's string lives |
| 160 | + * ONLY in a `title`, so text alone would miss it. |
| 161 | + */ |
| 162 | +function readable(root: HTMLElement): string[] { |
| 163 | + const out = [root.textContent ?? '']; |
| 164 | + for (const el of Array.from(root.querySelectorAll('*'))) { |
| 165 | + for (const attr of ['title', 'aria-label', 'placeholder']) { |
| 166 | + const v = el.getAttribute(attr); |
| 167 | + if (v) out.push(v); |
| 168 | + } |
| 169 | + } |
| 170 | + return out; |
| 171 | +} |
| 172 | + |
| 173 | +function englishFound(root: HTMLElement): string[] { |
| 174 | + const texts = readable(root); |
| 175 | + return ENGLISH.filter((s) => texts.some((t) => t.includes(s))); |
| 176 | +} |
| 177 | + |
| 178 | +/** The reject request `useHitlInChat` sends — where the deny reason is recorded. */ |
| 179 | +function rejectRequests(fetchMock: ReturnType<typeof vi.fn>) { |
| 180 | + return fetchMock.mock.calls.filter(([url]) => String(url).endsWith('/pending-actions/pa_11667/reject')); |
| 181 | +} |
| 182 | + |
| 183 | +/** The slice of `Response` the page's requests read here. */ |
| 184 | +interface FakeResponse { |
| 185 | + ok: boolean; |
| 186 | + status: number; |
| 187 | + text: () => Promise<string>; |
| 188 | + json: () => Promise<unknown>; |
| 189 | +} |
| 190 | + |
| 191 | +let fetchMock: ReturnType<typeof vi.fn>; |
| 192 | + |
| 193 | +beforeEach(() => { |
| 194 | + window.localStorage.clear(); |
| 195 | + sendMessage.mockReset(); |
| 196 | + fetchMock = vi.fn(async (url: unknown): Promise<FakeResponse> => { |
| 197 | + if (String(url).endsWith('/pending-actions/pa_11667/reject')) { |
| 198 | + const body = JSON.stringify({ status: 'rejected', id: 'pa_11667' }); |
| 199 | + return { ok: true, status: 200, text: async () => body, json: async () => JSON.parse(body) }; |
| 200 | + } |
| 201 | + return { ok: false, status: 404, text: async () => '', json: async () => ({}) }; |
| 202 | + }); |
| 203 | + vi.stubGlobal('fetch', fetchMock); |
| 204 | +}); |
| 205 | +afterEach(() => { |
| 206 | + cleanup(); |
| 207 | + vi.unstubAllGlobals(); |
| 208 | +}); |
| 209 | + |
| 210 | +describe('AI chat tool-approval and Builder handoff labels follow the UI locale (objectui#11667)', () => { |
| 211 | + it('zh-CN: none of the six English strings reaches the reader, and each surface renders in Chinese', async () => { |
| 212 | + const { container } = renderPane('zh-CN'); |
| 213 | + |
| 214 | + // Each surface is on the page — so the absence below is a reading, not an |
| 215 | + // empty render. |
| 216 | + expect(await screen.findByRole('button', { name: '通过并执行' })).toBeInTheDocument(); |
| 217 | + expect(screen.getByRole('button', { name: '拒绝' })).toBeInTheDocument(); |
| 218 | + expect(screen.getAllByText('在构建器中搭建')).toHaveLength(2); |
| 219 | + expect(screen.getByTestId('builder-handoff-open')).toHaveTextContent('在构建器中打开 →'); |
| 220 | + expect(screen.getByTestId('builder-handoff-superseded')).toHaveAttribute('title', '已有更新的请求'); |
| 221 | + |
| 222 | + expect(englishFound(container)).toEqual([]); |
| 223 | + |
| 224 | + // The deny reason, where it is recorded: the reject request's body. |
| 225 | + fireEvent.click(screen.getByRole('button', { name: '拒绝' })); |
| 226 | + await waitFor(() => expect(rejectRequests(fetchMock)).toHaveLength(1)); |
| 227 | + const [, init] = rejectRequests(fetchMock)[0]; |
| 228 | + expect(JSON.parse(String((init as RequestInit).body))).toEqual({ reason: '操作员已在对话中拒绝' }); |
| 229 | + // …and the model's next turn reads the same translated reason. |
| 230 | + await waitFor(() => expect(sendMessage).toHaveBeenCalledTimes(1)); |
| 231 | + expect(String(sendMessage.mock.calls[0][0])).toContain('操作员已在对话中拒绝'); |
| 232 | + expect(String(sendMessage.mock.calls[0][0])).not.toContain('Operator rejected from chat'); |
| 233 | + }); |
| 234 | + |
| 235 | + it('en: the same fixture shows the English labels — the probe above can see them', async () => { |
| 236 | + // Control for the zh-CN reading: same fixture, same probe, English UI. |
| 237 | + // "Approve & run" is the one string English readers no longer see — the |
| 238 | + // card now borrows the AI Approvals inbox's wording for the same decision. |
| 239 | + const { container } = renderPane('en'); |
| 240 | + expect(await screen.findByRole('button', { name: 'Approve & Execute' })).toBeInTheDocument(); |
| 241 | + expect(englishFound(container)).toEqual(ENGLISH.filter((s) => s !== 'Approve & run' && s !== 'Operator rejected from chat')); |
| 242 | + |
| 243 | + await act(async () => { |
| 244 | + fireEvent.click(screen.getByRole('button', { name: 'Reject' })); |
| 245 | + }); |
| 246 | + await waitFor(() => expect(rejectRequests(fetchMock)).toHaveLength(1)); |
| 247 | + const [, init] = rejectRequests(fetchMock)[0]; |
| 248 | + expect(JSON.parse(String((init as RequestInit).body))).toEqual({ reason: 'Operator rejected from chat' }); |
| 249 | + }); |
| 250 | +}); |
0 commit comments