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);