diff --git a/app/[locale]/agent/page.tsx b/app/[locale]/agent/page.tsx index e0ee94d4..4cc6b5ed 100644 --- a/app/[locale]/agent/page.tsx +++ b/app/[locale]/agent/page.tsx @@ -4,6 +4,7 @@ import { Suspense } from 'react'; import { setRequestLocale } from 'next-intl/server'; import { AppShell } from '@/components/AppShell'; import { AgentConnect } from '@/components/agent/AgentConnect'; +import { AgentTryPrompts } from '@/components/agent/AgentTryPrompts'; import { AgentConfigGenerator } from '@/components/agent/AgentConfigGenerator'; import { AgentWalletCard } from '@/components/agent/AgentWalletCard'; import { AgentSafety } from '@/components/agent/AgentSafety'; @@ -31,6 +32,7 @@ export default async function AgentPage({ params }: { params: Promise<{ locale: {/* fallback は実カードの復元前 (外枠 + 見出し) と同じ形にする — 大きな予約 → 縮む → 伸びる、の 2 回シフトを避ける。 */}

{c.wallet.title}

}>
+

{c.modes.title}

diff --git a/components/agent/AgentTryPrompts.tsx b/components/agent/AgentTryPrompts.tsx new file mode 100644 index 00000000..26a1749c --- /dev/null +++ b/components/agent/AgentTryPrompts.tsx @@ -0,0 +1,49 @@ +'use client'; + +import { useState } from 'react'; +import { useCopyToClipboard, useHydrationSafeAvailable } from '@/hooks/useCopyToClipboard'; +import type { AgentPageContent } from '@/lib/agentPage'; +import { trackAgentEvent } from '@/lib/agentTrack'; + +const tagColors = { + free: 'bg-slate-100 text-slate-700', + paid: 'bg-amber-100 text-amber-900', + human: 'bg-emerald-100 text-emerald-900', +}; + +// 文言は server page から受け取り、lib/agentPage → lib/legal を client bundle に入れない。 +export function AgentTryPrompts({ locale, c }: { locale: string; c: AgentPageContent['tryPrompts'] }) { + const { copy, copied, available: clipboardAvailable } = useCopyToClipboard(); + const available = useHydrationSafeAvailable(clipboardAvailable); + const [copiedId, setCopiedId] = useState(null); + + return ( + // 1 枚のカードに行を並べる (依頼文ごとにカードを分けると mobile で 1,100px を超え、磨き上げで削った全長を戻してしまう)。 +
+

{c.title}

+

{c.lead}

+
    + {c.items.map((item) => ( +
  • + {/* タグとコピーを 1 行に並べ、依頼文はその下 (ボタンを依頼文の下に積むと mobile で 1 行ぶんずつ伸びる)。 */} +
    + {item.tag} + {available ? ( + + ) : null} +
    +

    {item.prompt}

    + {item.kind === 'paid' ?

    {c.paidNote}

    : null} +
  • + ))} +
+ {/* 結果の読み上げは 1 か所だけ。ボタンの名前そのものを live region にすると、名前の変更と二重に読まれる。 */} +

{copied && copiedId !== null ? c.copied : ''}

+
+ ); +} diff --git a/lib/agentPage.ts b/lib/agentPage.ts index 920cdd9e..f53cf8db 100644 --- a/lib/agentPage.ts +++ b/lib/agentPage.ts @@ -153,6 +153,22 @@ export type AgentPageContent = { readonly statsPartial: string; readonly publicNote: string; }; + /** セットアップ後に Agent へそのまま貼れる依頼文 (user 承認 2026-09-22)。 */ + readonly tryPrompts: { + readonly title: string; + readonly lead: string; + readonly copy: string; + readonly copied: string; + /** 支払いが起きる依頼文にだけ出す注記。 */ + readonly paidNote: string; + readonly items: readonly { + readonly id: 'catalog' | 'buy-monitor' | 'order' | 'history' | 'limits'; + /** free = 支払いなし / paid = Agent が支払う / human = 人が支払う。 */ + readonly kind: 'free' | 'paid' | 'human'; + readonly tag: string; + readonly prompt: string; + }[]; + }; readonly next: { readonly title: string; readonly body: string; @@ -346,6 +362,20 @@ const ja: AgentPageContent = { statsPartial: '50 件より前は集計できません', publicNote: 'Polygon 上の JPYC の送受信 (公開情報) です。何を購入したかは表示しません。0 JPYC の送信は除いています。', }, + tryPrompts: { + title: 'Agent に頼めること', + lead: 'セットアップが済んだら、そのまま話しかけてください。コピーして Agent に貼るだけです。「Agent が支払う」で接続したときの例で、店の注文は「人が支払う」でも使えます。', + copy: 'プロンプトをコピー', + copied: 'コピーしました', + paidNote: '依頼文の上限を超える支払いは行われません。Agent 側の上限のほうが小さいときは、支払いは拒否されます。', + items: [ + { id: 'catalog', kind: 'free', tag: '無料', prompt: 'OpenPay で今買える JPYC のデータと API を一覧にして、それぞれの価格と利用料を教えてください。支払いはしないでください。' }, + { id: 'buy-monitor', kind: 'paid', tag: '支払いあり・3 JPYC', prompt: 'JPYC Service Monitor を上限 3 JPYC で購入して、この 1 か月に変わった点を 5 行にまとめてください。' }, + { id: 'order', kind: 'human', tag: '支払いは自分で', prompt: 'JPYC で注文できる店を探して、メニューと合計額を見せてください。支払いは私がします。' }, + { id: 'history', kind: 'free', tag: '無料', prompt: '最近なにを買ったか、金額と取引ハッシュつきで見せてください。' }, + { id: 'limits', kind: 'free', tag: '無料', prompt: 'いまの支払い上限と、今日使った額を教えてください。' }, + ], + }, next: { title: '買えるものを見る', body: 'セットアップが済んだら、Agent が JPYC で購入できるリソースを AI ストアで確認できます。', @@ -532,6 +562,20 @@ const en: AgentPageContent = { statsPartial: 'Can’t total beyond the latest 50', publicNote: 'JPYC transfers on Polygon (public data). What was purchased is not shown. 0 JPYC transfers are left out.', }, + tryPrompts: { + title: 'What you can ask your agent', + lead: 'Once setup is done, just talk to it. Copy a prompt and paste it to your agent. These examples are for the “Agent pays” setup; ordering from a shop also works with “You pay”.', + copy: 'Copy prompt', + copied: 'Copied', + paidNote: 'Nothing above the cap in the prompt is paid. If the limit on the agent side is lower, the payment is refused.', + items: [ + { id: 'catalog', kind: 'free', tag: 'Free', prompt: 'List the JPYC data and APIs I can buy on OpenPay right now, with the price and fee for each. Do not pay.' }, + { id: 'buy-monitor', kind: 'paid', tag: 'Pays · 3 JPYC', prompt: 'Buy the JPYC Service Monitor with a 3 JPYC cap and summarize what changed in the last month in five lines.' }, + { id: 'order', kind: 'human', tag: 'You pay yourself', prompt: 'Find shops where I can order with JPYC and show me the menu and the total. I will pay myself.' }, + { id: 'history', kind: 'free', tag: 'Free', prompt: 'Show me what you bought recently, with amounts and transaction hashes.' }, + { id: 'limits', kind: 'free', tag: 'Free', prompt: 'Tell me my current spending limits and how much I have spent today.' }, + ], + }, next: { title: 'See what it can buy', body: 'Once set up, browse the AI Store for the resources your agent can buy with JPYC.', diff --git a/lib/agentTrack.ts b/lib/agentTrack.ts index c21b9294..b3960cef 100644 --- a/lib/agentTrack.ts +++ b/lib/agentTrack.ts @@ -3,13 +3,15 @@ import { track } from '@vercel/analytics'; import type { AgentClient, AgentMode, AgentOpenInApp } from '@/lib/agentSetup'; -type AgentEventName = 'agent_prompt_copy' | 'agent_open_in' | 'agent_config_generate' | 'agent_config_copy' | 'agent_store_click' | 'agent_fund_send'; +type AgentEventName = 'agent_prompt_copy' | 'agent_open_in' | 'agent_config_generate' | 'agent_config_copy' | 'agent_store_click' | 'agent_fund_send' | 'agent_try_prompt_copy'; type ConfigProperties = { locale: string; client: AgentClient; mode: AgentMode }; export function trackAgentEvent(name: 'agent_config_generate' | 'agent_config_copy', properties: ConfigProperties): void; export function trackAgentEvent(name: 'agent_prompt_copy' | 'agent_store_click' | 'agent_fund_send', properties: { locale: string }): void; export function trackAgentEvent(name: 'agent_open_in', properties: { locale: string; app: AgentOpenInApp }): void; -export function trackAgentEvent(name: AgentEventName, properties: { locale: string; client?: AgentClient; mode?: AgentMode; app?: AgentOpenInApp }): void { +/** 「Agent に頼めること」のコピー。送るのは依頼文の id だけで、本文は送らない。 */ +export function trackAgentEvent(name: 'agent_try_prompt_copy', properties: { locale: string; id: string }): void; +export function trackAgentEvent(name: AgentEventName, properties: { locale: string; client?: AgentClient; mode?: AgentMode; app?: AgentOpenInApp; id?: string }): void { try { // 掟 13: 計測障害をコピーやリンク遷移などの UI 操作へ波及させない隔離。 // 明示したプロパティだけを送信し、アドレス・上限値・ホスト名は送らない。 @@ -17,7 +19,9 @@ export function trackAgentEvent(name: AgentEventName, properties: { locale: stri ? { locale: properties.locale, client: properties.client!, mode: properties.mode! } : name === 'agent_open_in' ? { locale: properties.locale, app: properties.app! } - : { locale: properties.locale }; + : name === 'agent_try_prompt_copy' + ? { locale: properties.locale, id: properties.id! } + : { locale: properties.locale }; track(name, data); } catch { // 付帯処理の失敗で本来の操作を止めない。 diff --git a/tests/components/agent/AgentTryPrompts.test.tsx b/tests/components/agent/AgentTryPrompts.test.tsx new file mode 100644 index 00000000..45c8954e --- /dev/null +++ b/tests/components/agent/AgentTryPrompts.test.tsx @@ -0,0 +1,144 @@ +import { useLayoutEffect } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { hydrateRoot, type Root } from 'react-dom/client'; +import { renderToString } from 'react-dom/server'; +import { act, fireEvent, render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { track } from '@vercel/analytics'; +import { AgentTryPrompts } from '@/components/agent/AgentTryPrompts'; +import { COPIED_FEEDBACK_MS } from '@/hooks/useCopyToClipboard'; +import { agentPageContentFor } from '@/lib/agentPage'; + +vi.mock('@vercel/analytics', () => ({ track: vi.fn() })); + +beforeEach(() => vi.clearAllMocks()); +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe.each(['ja', 'en'])('AgentTryPrompts (%s)', (locale) => { + const c = agentPageContentFor(locale).tryPrompts; + + it('shows the heading, lead and five selectable prompts with payment notes only on paid items', () => { + render(); + expect(screen.getByRole('heading', { level: 2, name: c.title })).toBeVisible(); + expect(screen.getByText(c.lead)).toBeVisible(); + const items = within(screen.getByRole('list')).getAllByRole('listitem'); + expect(items).toHaveLength(5); + for (const [index, item] of c.items.entries()) { + const row = within(items[index]); + expect(row.getByText(item.tag)).toBeVisible(); + const prompt = row.getByText(item.prompt); + expect(prompt.tagName).toBe('P'); + expect(prompt.textContent).toBe(item.prompt); + if (item.kind === 'paid') expect(row.getByText(c.paidNote)).toBeVisible(); + else expect(row.queryByText(c.paidNote)).toBeNull(); + } + expect(screen.getAllByText(c.paidNote)).toHaveLength(c.items.filter((item) => item.kind === 'paid').length); + }); + + it('copies each exact prompt, changes only its button and tracks only its ID after success', async () => { + const user = userEvent.setup(); + const write = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(); + render(); + const buttons = screen.getAllByRole('button', { name: c.copy }); + expect(buttons).toHaveLength(5); + + for (const [index, item] of c.items.entries()) { + await user.click(buttons[index]); + expect(write).toHaveBeenNthCalledWith(index + 1, item.prompt); + expect(track).toHaveBeenNthCalledWith(index + 1, 'agent_try_prompt_copy', { locale, id: item.id }); + expect(screen.getAllByRole('button', { name: c.copied })).toEqual([buttons[index]]); + expect(screen.getAllByRole('button', { name: c.copy })).toHaveLength(4); + } + expect(write).toHaveBeenCalledTimes(5); + expect(track).toHaveBeenCalledTimes(5); + }); + + it('uses visible button names and distinct prompt descriptions without aria-label overrides', () => { + userEvent.setup(); + const { container } = render(); + expect(container.querySelector('[aria-label]')).toBeNull(); + const buttons = screen.getAllByRole('button', { name: c.copy }); + expect(buttons).toHaveLength(5); + for (const [index, item] of c.items.entries()) { + expect(buttons[index]).toHaveAccessibleName(c.copy); + expect(buttons[index]).toHaveAccessibleDescription(item.prompt); + expect(document.getElementById(buttons[index].getAttribute('aria-describedby')!)?.textContent).toBe(item.prompt); + } + expect(new Set(buttons.map((button) => button.getAttribute('aria-describedby'))).size).toBe(5); + }); + + it.each([false, true])('matches SSR to the initial client render and then respects clipboard availability (%s)', async (hasClipboard) => { + const descriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard'); + const container = document.createElement('div'); + document.body.appendChild(container); + let root: Root | undefined; + try { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined }); + const html = renderToString(); + container.innerHTML = html; + expect(within(container).getAllByRole('button', { name: c.copy })).toHaveLength(5); + if (hasClipboard) Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: vi.fn().mockResolvedValue(undefined) } }); + + let initialClientHtml = ''; + function HydrationProbe() { + // passive effect が clipboard の有無を反映する前の、client 初回 commit を観測する。 + useLayoutEffect(() => { initialClientHtml = container.innerHTML; }, []); + return ; + } + const onRecoverableError = vi.fn(); + await act(async () => { + root = hydrateRoot(container, , { onRecoverableError }); + }); + expect(initialClientHtml).toBe(html); + expect(onRecoverableError).not.toHaveBeenCalled(); + expect(within(container).queryAllByRole('button')).toHaveLength(hasClipboard ? 5 : 0); + for (const item of c.items) expect(within(container).getByText(item.prompt).textContent).toBe(item.prompt); + } finally { + if (root) await act(async () => root?.unmount()); + container.remove(); + if (descriptor) Object.defineProperty(navigator, 'clipboard', descriptor); + else Reflect.deleteProperty(navigator, 'clipboard'); + } + }); + + it('does not show success or track while a copy is pending or after it fails', async () => { + const user = userEvent.setup(); + let rejectCopy!: (reason: Error) => void; + vi.spyOn(navigator.clipboard, 'writeText').mockReturnValue(new Promise((_, reject) => { rejectCopy = reject; })); + render(); + await user.click(screen.getAllByRole('button', { name: c.copy })[0]); + expect(track).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: c.copied })).toBeNull(); + await act(async () => { rejectCopy(new Error('clipboard denied')); }); + expect(track).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: c.copied })).toBeNull(); + expect(screen.getAllByRole('button', { name: c.copy })).toHaveLength(5); + }); + + it('keeps successful copy feedback when analytics throws', async () => { + const user = userEvent.setup(); + const write = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(); + vi.mocked(track).mockImplementationOnce(() => { throw new Error('analytics unavailable'); }); + render(); + const button = screen.getAllByRole('button', { name: c.copy })[1]; + await user.click(button); + expect(write).toHaveBeenCalledWith(c.items[1].prompt); + expect(button).toHaveAccessibleName(c.copied); + expect(button).toHaveAccessibleDescription(c.items[1].prompt); + }); + + it('clears copied feedback after the shared hook timeout', async () => { + userEvent.setup(); + vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(); + vi.useFakeTimers(); + render(); + await act(async () => { fireEvent.click(screen.getAllByRole('button', { name: c.copy })[0]); }); + expect(screen.getAllByRole('button', { name: c.copied })).toHaveLength(1); + act(() => { vi.advanceTimersByTime(COPIED_FEEDBACK_MS); }); + expect(screen.queryByRole('button', { name: c.copied })).toBeNull(); + expect(screen.getAllByRole('button', { name: c.copy })).toHaveLength(5); + }); +}); diff --git a/tests/lib/agentPage.test.ts b/tests/lib/agentPage.test.ts index d98d1fcb..bad3dddb 100644 --- a/tests/lib/agentPage.test.ts +++ b/tests/lib/agentPage.test.ts @@ -1,6 +1,8 @@ +import { formatUnits, parseUnits } from 'viem'; import { existsSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { agentPageContentFor, agentPageMetadata } from '@/lib/agentPage'; +import { JPYC_SERVICES_RESOURCE } from '@/lib/directory/paidResources'; import { DISCLOSED_X402_FEE } from '@/lib/legal'; function shape(value: unknown): unknown { @@ -13,6 +15,34 @@ describe('agent page content', () => { it('has matching ja/en key structures', () => { expect(shape(agentPageContentFor('ja'))).toEqual(shape(agentPageContentFor('en'))); }); + it('keeps try-prompt IDs and payment kinds in the same order in ja/en', () => { + const ja = agentPageContentFor('ja').tryPrompts.items; + const en = agentPageContentFor('en').tryPrompts.items; + expect(ja).toHaveLength(5); + expect(new Set(ja.map((item) => item.id)).size).toBe(5); + expect(ja.map(({ id, kind }) => ({ id, kind }))).toEqual(en.map(({ id, kind }) => ({ id, kind }))); + }); + it.each(['ja', 'en'])('includes a spending cap in every paid prompt in %s', (locale) => { + const paid = agentPageContentFor(locale).tryPrompts.items.filter((item) => item.kind === 'paid'); + expect(paid.length).toBeGreaterThan(0); + for (const item of paid) { + expect(item.prompt).toMatch(locale === 'ja' ? /上限 \d+(?:\.\d+)? JPYC/ : /\d+(?:\.\d+)? JPYC cap/); + } + }); + it.each(['ja', 'en'])('keeps the monitor prompt and tag total aligned with the price and disclosed fee in %s', (locale) => { + // /api/paid/jpyc/services が handleFirstPartyPaidGet に渡す価格 SoT を直接参照する。 + // 実装 (lib/x402/fee.ts) と同じ atomic の整数演算。Number だと小数価格で 0.3 + 1 = 1.2999… の偽 fail になる。 + const priceWei = parseUnits(JPYC_SERVICES_RESOURCE.priceJpyc, 18); + const floorWei = parseUnits(String(DISCLOSED_X402_FEE.floorJpyc), 18); + const percentWei = priceWei * BigInt(DISCLOSED_X402_FEE.bps) / 10000n; + const total = formatUnits(priceWei + (percentWei > floorWei ? percentWei : floorWei), 18); + const item = agentPageContentFor(locale).tryPrompts.items.find((item) => item.id === 'buy-monitor'); + expect(item?.kind).toBe('paid'); + for (const text of [item?.prompt, item?.tag]) { + const amounts = [...(text ?? '').matchAll(/(\d+(?:\.\d+)?) JPYC/g)].map((match) => match[1]); + expect(amounts).toEqual([total]); + } + }); it.each(['ja', 'en'])('explains where the agent wallet comes from in the empty state in %s', (locale) => { const c = agentPageContentFor(locale); for (const value of [c.wallet.emptyLead, c.wallet.emptyConnectCta, c.wallet.manualEntry]) expect(value.trim().length).toBeGreaterThan(0);