From afb7cdab95a27471c10c1e9c871ed43f33de3807 Mon Sep 17 00:00:00 2001 From: dwebxr Date: Tue, 22 Sep 2026 07:39:20 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(agent):=20=E3=80=8CAgent=20=E3=81=AB?= =?UTF-8?q?=E9=A0=BC=E3=82=81=E3=82=8B=E3=81=93=E3=81=A8=E3=80=8D=E2=80=94?= =?UTF-8?q?=20=E3=82=BB=E3=83=83=E3=83=88=E3=82=A2=E3=83=83=E3=83=97?= =?UTF-8?q?=E5=BE=8C=E3=81=AB=E3=81=9D=E3=81=AE=E3=81=BE=E3=81=BE=E8=B2=BC?= =?UTF-8?q?=E3=82=8C=E3=82=8B=E4=BE=9D=E9=A0=BC=E6=96=87=205=20=E6=9C=AC?= =?UTF-8?q?=20(=E3=82=B3=E3=83=94=E3=83=BC=E3=81=A4=E3=81=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arc Portal の「あなたのエージェントができること」を参考に、/agent の「Agent を接続」の下へ コピー可能な依頼文を並べる。例はすべて実在の商品とツールに基づく (カタログの一覧・ JPYC Service Monitor の購入・店の注文・購入履歴 = wallet_history・上限の確認)。 - 支払いが起きるのは 1 本だけで、依頼文そのものに上限額 (3 JPYC) を書く。タグで 無料 / 支払いあり / 支払いは自分で を色分け - 「3 JPYC」は価格 SOT (JPYC_SERVICES_RESOURCE.priceJpyc) と DISCLOSED_X402_FEE から導出して テストで固定 (価格や利用料を変えたら落ちる) - 文言 SOT は lib/agentPage.ts (server から props で渡す・messages には置かない) - 1 枚のカードに行を並べる。依頼文ごとのカードだと mobile で 1,135px → 731px に圧縮 (磨き上げ P1 で削った全長を戻さない) - コピーは useHydrationSafeAvailable で server と初回描画を揃える・計測は成功時に id だけ (依頼文の本文は送らない)・a11y 名は可視テキスト + aria-describedby で 5 つのボタンを区別 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HeizmagJBgL5peL5mQpxkc --- app/[locale]/agent/page.tsx | 2 + components/agent/AgentTryPrompts.tsx | 51 +++++++ lib/agentPage.ts | 44 ++++++ .../components/agent/AgentTryPrompts.test.tsx | 144 ++++++++++++++++++ tests/lib/agentPage.test.ts | 27 ++++ 5 files changed, 268 insertions(+) create mode 100644 components/agent/AgentTryPrompts.tsx create mode 100644 tests/components/agent/AgentTryPrompts.test.tsx diff --git a/app/[locale]/agent/page.tsx b/app/[locale]/agent/page.tsx index e0ee94d4..ff1cfbae 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..7f9ecde8 --- /dev/null +++ b/components/agent/AgentTryPrompts.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { useState } from 'react'; +import { track } from '@vercel/analytics'; +import { useCopyToClipboard, useHydrationSafeAvailable } from '@/hooks/useCopyToClipboard'; +import type { AgentPageContent } from '@/lib/agentPage'; + +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({ c }: { 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} +
  • + ))} +
+
+ ); +} diff --git a/lib/agentPage.ts b/lib/agentPage.ts index 920cdd9e..1ecfa2e7 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 に貼るだけです。', + 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.', + copy: 'Copy prompt', + copied: 'Copied', + paidNote: 'The smaller of the cap in the prompt and the limit set on the agent side applies.', + 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/tests/components/agent/AgentTryPrompts.test.tsx b/tests/components/agent/AgentTryPrompts.test.tsx new file mode 100644 index 00000000..e1ea28f0 --- /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', { 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..34d2db18 100644 --- a/tests/lib/agentPage.test.ts +++ b/tests/lib/agentPage.test.ts @@ -1,6 +1,7 @@ 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 +14,32 @@ 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 を直接参照する。 + const price = Number(JPYC_SERVICES_RESOURCE.priceJpyc); + const fee = Math.max(DISCLOSED_X402_FEE.floorJpyc, price * DISCLOSED_X402_FEE.bps / 10000); + const total = price + fee; + 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) => Number(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); From 3f5897dff0a3b68e69e8f71d036bdba0d732c330 Mon Sep 17 00:00:00 2001 From: dwebxr Date: Tue, 22 Sep 2026 07:52:48 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(agent):=20=E3=80=8CAgent=20=E3=81=AB?= =?UTF-8?q?=E9=A0=BC=E3=82=81=E3=82=8B=E3=81=93=E3=81=A8=E3=80=8D=E3=81=AE?= =?UTF-8?q?=E8=A8=88=E6=B8=AC=E3=82=92=E5=85=B1=E9=80=9A=E7=B5=8C=E8=B7=AF?= =?UTF-8?q?=E3=81=B8=E3=83=BB=E8=AA=AD=E3=81=BF=E4=B8=8A=E3=81=92=E3=82=92?= =?UTF-8?q?=201=20=E3=81=8B=E6=89=80=E3=81=AB=E3=83=BB=E4=BE=A1=E6=A0=BC?= =?UTF-8?q?=E3=83=95=E3=82=A7=E3=83=B3=E3=82=B9=E3=82=92=E6=95=B4=E6=95=B0?= =?UTF-8?q?=E6=BC=94=E7=AE=97=E3=81=AB=20(Opus=205=20=E3=83=81=E3=82=A7?= =?UTF-8?q?=E3=83=83=E3=82=AF=E5=8F=8D=E6=98=A0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 計測: @vercel/analytics を直接呼んでいたのを lib/agentTrack.ts の trackAgentEvent に統一。 他の agent イベントと同じく locale を付ける (ja / en を分けて見られない唯一のイベントだった)。 送るのは依頼文の id だけで本文は送らない - 読み上げ: 5 つのボタンの名前そのものを aria-live にしていたため、名前の変更と live region が 二重に読まれていた → ボタンの外に role=status を 1 つだけ置く - 価格フェンス: Number 演算だと小数価格で偽 fail (0.3 + 1 = 1.2999…) → 実装と同じ atomic の BigInt 演算 (parseUnits / formatUnits) に Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HeizmagJBgL5peL5mQpxkc --- app/[locale]/agent/page.tsx | 2 +- components/agent/AgentTryPrompts.tsx | 16 +++++++--------- lib/agentTrack.ts | 10 +++++++--- .../components/agent/AgentTryPrompts.test.tsx | 18 +++++++++--------- tests/lib/agentPage.test.ts | 11 +++++++---- 5 files changed, 31 insertions(+), 26 deletions(-) diff --git a/app/[locale]/agent/page.tsx b/app/[locale]/agent/page.tsx index ff1cfbae..4cc6b5ed 100644 --- a/app/[locale]/agent/page.tsx +++ b/app/[locale]/agent/page.tsx @@ -32,7 +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 index 7f9ecde8..26a1749c 100644 --- a/components/agent/AgentTryPrompts.tsx +++ b/components/agent/AgentTryPrompts.tsx @@ -1,9 +1,9 @@ 'use client'; import { useState } from 'react'; -import { track } from '@vercel/analytics'; 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', @@ -12,7 +12,7 @@ const tagColors = { }; // 文言は server page から受け取り、lib/agentPage → lib/legal を client bundle に入れない。 -export function AgentTryPrompts({ c }: { c: AgentPageContent['tryPrompts'] }) { +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); @@ -32,13 +32,9 @@ export function AgentTryPrompts({ c }: { c: AgentPageContent['tryPrompts'] }) { + // 依頼文の本文は送らず id だけ。計測の失敗は trackAgentEvent が隔離する (掟 13)。 + trackAgentEvent('agent_try_prompt_copy', { locale, id: item.id }); + }}>{copied && copiedId === item.id ? c.copied : c.copy} ) : null}

{item.prompt}

@@ -46,6 +42,8 @@ export function AgentTryPrompts({ c }: { c: AgentPageContent['tryPrompts'] }) { ))} + {/* 結果の読み上げは 1 か所だけ。ボタンの名前そのものを live region にすると、名前の変更と二重に読まれる。 */} +

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

); } 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 index e1ea28f0..45c8954e 100644 --- a/tests/components/agent/AgentTryPrompts.test.tsx +++ b/tests/components/agent/AgentTryPrompts.test.tsx @@ -21,7 +21,7 @@ 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(); + 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'); @@ -41,14 +41,14 @@ describe.each(['ja', 'en'])('AgentTryPrompts (%s)', (locale) => { 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(); + 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', { id: item.id }); + 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); } @@ -58,7 +58,7 @@ describe.each(['ja', 'en'])('AgentTryPrompts (%s)', (locale) => { it('uses visible button names and distinct prompt descriptions without aria-label overrides', () => { userEvent.setup(); - const { container } = render(); + const { container } = render(); expect(container.querySelector('[aria-label]')).toBeNull(); const buttons = screen.getAllByRole('button', { name: c.copy }); expect(buttons).toHaveLength(5); @@ -77,7 +77,7 @@ describe.each(['ja', 'en'])('AgentTryPrompts (%s)', (locale) => { let root: Root | undefined; try { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined }); - const html = renderToString(); + 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) } }); @@ -86,7 +86,7 @@ describe.each(['ja', 'en'])('AgentTryPrompts (%s)', (locale) => { function HydrationProbe() { // passive effect が clipboard の有無を反映する前の、client 初回 commit を観測する。 useLayoutEffect(() => { initialClientHtml = container.innerHTML; }, []); - return ; + return ; } const onRecoverableError = vi.fn(); await act(async () => { @@ -108,7 +108,7 @@ describe.each(['ja', 'en'])('AgentTryPrompts (%s)', (locale) => { const user = userEvent.setup(); let rejectCopy!: (reason: Error) => void; vi.spyOn(navigator.clipboard, 'writeText').mockReturnValue(new Promise((_, reject) => { rejectCopy = reject; })); - render(); + render(); await user.click(screen.getAllByRole('button', { name: c.copy })[0]); expect(track).not.toHaveBeenCalled(); expect(screen.queryByRole('button', { name: c.copied })).toBeNull(); @@ -122,7 +122,7 @@ describe.each(['ja', 'en'])('AgentTryPrompts (%s)', (locale) => { const user = userEvent.setup(); const write = vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(); vi.mocked(track).mockImplementationOnce(() => { throw new Error('analytics unavailable'); }); - render(); + render(); const button = screen.getAllByRole('button', { name: c.copy })[1]; await user.click(button); expect(write).toHaveBeenCalledWith(c.items[1].prompt); @@ -134,7 +134,7 @@ describe.each(['ja', 'en'])('AgentTryPrompts (%s)', (locale) => { userEvent.setup(); vi.spyOn(navigator.clipboard, 'writeText').mockResolvedValue(); vi.useFakeTimers(); - render(); + 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); }); diff --git a/tests/lib/agentPage.test.ts b/tests/lib/agentPage.test.ts index 34d2db18..bad3dddb 100644 --- a/tests/lib/agentPage.test.ts +++ b/tests/lib/agentPage.test.ts @@ -1,3 +1,4 @@ +import { formatUnits, parseUnits } from 'viem'; import { existsSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { agentPageContentFor, agentPageMetadata } from '@/lib/agentPage'; @@ -30,13 +31,15 @@ describe('agent page content', () => { }); 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 を直接参照する。 - const price = Number(JPYC_SERVICES_RESOURCE.priceJpyc); - const fee = Math.max(DISCLOSED_X402_FEE.floorJpyc, price * DISCLOSED_X402_FEE.bps / 10000); - const total = price + fee; + // 実装 (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) => Number(match[1])); + const amounts = [...(text ?? '').matchAll(/(\d+(?:\.\d+)?) JPYC/g)].map((match) => match[1]); expect(amounts).toEqual([total]); } }); From 4fdf919daed6e99d45941da9b07ea87f5d57c8d9 Mon Sep 17 00:00:00 2001 From: dwebxr Date: Tue, 22 Sep 2026 07:55:45 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(agent):=20=E3=80=8CAgent=20=E3=81=AB?= =?UTF-8?q?=E9=A0=BC=E3=82=81=E3=82=8B=E3=81=93=E3=81=A8=E3=80=8D=E3=81=AE?= =?UTF-8?q?=20lead=20=E3=81=A8=E6=94=AF=E6=89=95=E3=81=84=E3=81=AE?= =?UTF-8?q?=E6=B3=A8=E8=A8=98=E3=82=92=E4=BA=8B=E5=AE=9F=E3=81=AB=E5=90=88?= =?UTF-8?q?=E3=82=8F=E3=81=9B=E3=82=8B=20(user=20=E6=89=BF=E8=AA=8D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lead: 「Agent が支払う」で接続したときの例で、店の注文は「人が支払う」でも使える、を 1 句 (human-pays = order profile には 5 本中 4 本のツールが無い) - paidNote: 「小さいほうが効く」は不正確 (Agent 側の上限のほうが小さいと支払いは拒否される) → 「依頼文の上限を超える支払いは行われない・Agent 側の上限のほうが小さいときは拒否」に Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HeizmagJBgL5peL5mQpxkc --- lib/agentPage.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/agentPage.ts b/lib/agentPage.ts index 1ecfa2e7..f53cf8db 100644 --- a/lib/agentPage.ts +++ b/lib/agentPage.ts @@ -364,10 +364,10 @@ const ja: AgentPageContent = { }, tryPrompts: { title: 'Agent に頼めること', - lead: 'セットアップが済んだら、そのまま話しかけてください。コピーして Agent に貼るだけです。', + lead: 'セットアップが済んだら、そのまま話しかけてください。コピーして Agent に貼るだけです。「Agent が支払う」で接続したときの例で、店の注文は「人が支払う」でも使えます。', copy: 'プロンプトをコピー', copied: 'コピーしました', - paidNote: '上限は、依頼文に書いた額と Agent 側の設定の小さいほうが効きます。', + 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 行にまとめてください。' }, @@ -564,10 +564,10 @@ const en: AgentPageContent = { }, 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.', + 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: 'The smaller of the cap in the prompt and the limit set on the agent side applies.', + 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.' },