-
Notifications
You must be signed in to change notification settings - Fork 3.5k
fix(tinyplace): wire handle transfer end-to-end (Closes #4929) #4998
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
senamakel
merged 93 commits into
tinyhumansai:main
from
M3gA-Mind:fix/GH-4929-tinyplace-handle-transfer
Jul 22, 2026
Merged
Changes from all commits
Commits
Show all changes
93 commits
Select commit
Hold shift + click to select a range
143512c
fix(tinyplace): wire handle transfer end-to-end (registry.transfer)
M3gA-Mind 8febe00
Merge remote-tracking branch 'upstream/main' into fix/GH-4929-tinypla…
M3gA-Mind 2c089f2
address review: fmt, PII-safe log, primary-transfer guard, i18n
M3gA-Mind 96bb836
Merge remote-tracking branch 'upstream/main' into fix/GH-4929-tinypla…
M3gA-Mind 8fc1e71
harden transfer confirm UX + drop redundant log prefix (review)
M3gA-Mind 9289c14
chore(release): v0.61.2 [skip ci]
github-actions[bot] fafe0bf
feat(flows): workflow-builder agent-friendliness plumbing — schema-aw…
senamakel 1c43761
fix(flows): guarantee a terminal state on every flows_build turn (no …
graycyrus cc3c402
fix(flows): save-safety — no silent live-arming on update, flag empty…
graycyrus 376a8ca
fix(flows): copilot proposals reach the canvas — recognition, race-gu…
graycyrus b1fdc40
fix(flows): live-updating runs lists + row-action cleanup (enable tog…
graycyrus dbfcf5a
fix(flows): exempt workflow proposal tools from tokenjuice compaction…
graycyrus 193a399
fix(flows): close stale-read race in flows_update disarm + actionable…
graycyrus e339e51
fix(chat): don't wipe fresh streaming/tool-timeline on a completed hy…
graycyrus 1c741f6
fix(flows): correct save_workflow + workflow_builder contract text (d…
graycyrus 9b2afb9
feat(core): compile-time media feature gate (media_generation + image…
oxoxDev eb490b5
fix(flows): canvas Accept persists the proposal (accept = review + sa…
graycyrus d6b135c
fix(flows): circuit-breaker counts validate/dry_run body-level ok:fal…
graycyrus e369d24
chore(staging): v0.61.3 [skip ci]
github-actions[bot] d976d54
chore(release): v0.61.4 [skip ci]
github-actions[bot] 82abc5c
feat(rewards): surface the Rewards page via a cloud-gated sidebar foo…
YellowSnnowmann 7d4770e
fix(flows): show "awaiting approval" state in runs lists when a run i…
graycyrus 6fa793b
chore(rust): enforce warning-free clippy (#4872)
senamakel 54e012f
fix(theme): polish Matrix + HAL 9000 (and other dark) presets to WCAG…
M3gA-Mind 9445c13
fix(voice): ship the voice domain in the desktop build (#4901) (#4917)
CodeGhost21 6b6cddf
chore(release): v0.61.5 [skip ci]
github-actions[bot] 40401ad
feat(memory): ingest Codex and Claude coding sessions (#4863)
senamakel 0112572
fix(agent): provider-aware super-context + delegation guardrails for …
sanil-23 91b9219
feat(chat): stop-response button + ESC to interrupt and re-edit promp…
M3gA-Mind deddc7c
feat(tools): generate_document tool emitting real .docx (#4847 Proble…
M3gA-Mind 3794b47
fix(agent-harness): enforce required structured-output block every tu…
M3gA-Mind 8e8c5af
perf(ui): pause mesh-gradient background when window is unfocused/hid…
M3gA-Mind b278f8e
feat(accessibility): ranked UI-element matcher for reliable selection…
M3gA-Mind 01bb34d
perf(services): start login-gated services concurrently to unblock co…
M3gA-Mind f91b55b
fix(orchestration): surface async sub-agent failures/awaiting-user in…
M3gA-Mind 38161fe
fix(inference): honor Retry-After in the streaming retry loop (#4895)…
M3gA-Mind 1bdf362
fix(channels): correct channel-setup nav paths, add in-app guidance (…
YellowSnnowmann f9838ab
fix(flows): surface connected platform_user_id so the builder wires s…
graycyrus 27ff4d5
fix(i18n): repair grammar across locale copy (#4939)
senamakel f7f56da
feat(web3): compile-time web3 feature gate for wallet/web3/x402 (#480…
oxoxDev 86479a3
fix(flows): expose platform_user_id in flows_list_connections schema …
graycyrus ee5f570
fix(flows): persist agentic task insights expand state across copilot…
graycyrus ea005c9
fix(flows): copilot chat stays scrollable (auto-scroll only when pinn…
graycyrus 30fcb1f
fix(conversations): key the proactive-thread insights fallback (discl…
graycyrus ce8b7e9
fix(flows): stable issue-order for tool-call timeline (monotonic seq …
graycyrus 6bd9514
feat(agent): public accessor for last-turn usage totals (#4940)
senamakel b8aaaa8
fix(conversations): add required seq to ToolTimelineEntry test fixtur…
M3gA-Mind 4b0fd25
fix(app): forward voice + tokenjuice-treesitter to the desktop build …
oxoxDev 4f3a361
feat(core): compile-time meet feature gate (#4800) (#4915)
oxoxDev c7009d9
feat(safety): Emergency Stop for desktop automation (#4255) (#4600)
CodeGhost21 5f55f33
feat(core): compile-time skills feature gate (#4798) (#4913)
oxoxDev 78546ff
fix(build): forward tokenjuice-treesitter + gate feature-forwarding d…
CodeGhost21 31aba3b
fix(flows): trail-off backstop no longer clobbers real questions (#4956)
graycyrus 24c63b1
fix(flows): resolve a non-owner Slack DM recipient via a lookup node …
graycyrus e7ceea9
feat(core): compile-time flows feature gate (#4797) (#4912)
oxoxDev 2e787e3
fix(memory): exclude same-session docs from recall so agents don't re…
graycyrus ef93bd9
feat(core): compile-time mcp feature gate (#4799) (#4914)
oxoxDev c74fbf0
fix(flows): close Composio tool_call tier-gate bypass (C1) (#5000)
graycyrus a56cd26
fix(flows): preserve canvas viewport and skip redundant remount on sa…
graycyrus 4422254
fix(tinyplace): handle DM to a non-contact gracefully (Closes #4921) …
M3gA-Mind dc33928
fix(tinyplace): paginate the Tiny Place ledger listing (#4952)
M3gA-Mind 542b7f8
fix(memory): use stable document_id as sync upsert key (fixes #4947 B…
M3gA-Mind 36bf306
fix(settings): scope Clear App Data reset to the signed-in user (#495…
M3gA-Mind 1bf9c25
fix(conversations): reset agentic task insights override on turn sett…
graycyrus 988b117
fix(flows): builder chat hygiene — run-tool honesty (Bld #4) + reply …
graycyrus 5f6f510
fix(tinyplace): paginate the Agent World feed (Closes #4923) (#4989)
M3gA-Mind 8c1b2dd
fix(conversations): drive agentic task insights reset off turn lifecy…
graycyrus 1bb5360
fix(flows): dismissible/auto-clearing run-error banner, no longer ove…
graycyrus 4fa8475
fix(flows): resolve-before-you-ask — wire runtime lookups instead of …
graycyrus 1868c18
fix(flows): make Composio tier gating effect-aware (reads no longer p…
graycyrus 5c2af38
fix(tinyplace): live-update open DM thread via inbox stream (#4988)
M3gA-Mind 6bf499d
fix(tinyplace): open the profile when a Directory entry is clicked (C…
M3gA-Mind 79c669b
fix(i18n): localize the Codex auth button (Closes #4852) (#4992)
M3gA-Mind 7790c5d
fix(composio): stop auto-registering unsyncable toolkits as memory so…
M3gA-Mind 929f56d
fix(tinyplace): real-time feed updates via feeds stream (#4926) (#4994)
M3gA-Mind 24864aa
refactor(core): relocate the web chat conduit out of channels/ (#5002…
oxoxDev 9117b08
fix(tauri): resolve macOS-gated clippy -D warnings blocking pre-push …
oxoxDev 9975450
fix(composio): gate per-action tools on their full contract (#4853) (…
M3gA-Mind baeda24
feat(tinyplace): add own-profile editing (name/bio/avatar) to Agent W…
M3gA-Mind 1ec3396
feat(tinyplace): public profile viewer for arbitrary handles (#4931) …
M3gA-Mind d0ba4e9
fix(webview-accounts): keep X/Twitter Google (GSI) sign-in popup in-a…
M3gA-Mind 310bfa1
fix(core): unbreak the gates-off build and make the smoke lane run te…
oxoxDev 6a833dc
fix(learning): decouple always-on learning subscribers from channels …
oxoxDev 4264c97
Merge remote-tracking branch 'upstream/main'
senamakel d02b5e7
Merge branch 'main' into pr/4998
senamakel 1adfc28
Merge remote-tracking branch 'upstream/main' into pr/4998
senamakel 2bd9f45
fix(tinyplace): fail closed on unknown primary state
senamakel 00071c9
ci: acknowledge composio gated tests
senamakel f1edcd4
Merge remote-tracking branch 'upstream/main' into fix/GH-4929-tinypla…
M3gA-Mind 6d07c4e
Merge remote-tracking branch 'upstream/main' into fix/GH-4929-tinypla…
M3gA-Mind e6c5773
fix(tinyplace): real read-back + idempotent/validated handle transfer…
M3gA-Mind 5d0480d
fix(tinyplace): address CodeRabbit — drop redundant read-back GET + i…
M3gA-Mind 177304c
test(agentworld): update ProfilesSection transfer tests for M3 disabl…
M3gA-Mind File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
95 changes: 95 additions & 0 deletions
95
app/src/agentworld/components/TransferHandleModal.test.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| /** | ||
| * Tests for TransferHandleModal (GH-4929) — the confirm + execute dialog for a | ||
| * Tiny Place handle transfer. A transfer is destructive/irreversible, so these | ||
| * assert the wiring to `apiClient.registry.transfer`, that confirm is gated on a | ||
| * recipient, and that the flow fails CLOSED (error keeps the dialog open and | ||
| * never reports success). | ||
| * | ||
| * All handles/recipients are generic placeholders, never real identities. | ||
| */ | ||
| import { screen, waitFor } from '@testing-library/react'; | ||
| import userEvent from '@testing-library/user-event'; | ||
| import { beforeEach, describe, expect, test, vi } from 'vitest'; | ||
|
|
||
| import { renderWithProviders } from '../../test/test-utils'; | ||
| import { apiClient } from '../AgentWorldShell'; | ||
| import TransferHandleModal from './TransferHandleModal'; | ||
|
|
||
| vi.mock('../AgentWorldShell', () => ({ apiClient: { registry: { transfer: vi.fn() } } })); | ||
|
|
||
| const transfer = vi.mocked(apiClient.registry.transfer); | ||
|
|
||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
|
|
||
| function setup() { | ||
| const onClose = vi.fn(); | ||
| const onTransferred = vi.fn(); | ||
| renderWithProviders( | ||
| <TransferHandleModal handle="alpha" onClose={onClose} onTransferred={onTransferred} /> | ||
| ); | ||
| return { onClose, onTransferred }; | ||
| } | ||
|
|
||
| describe('TransferHandleModal', () => { | ||
| test('shows the handle + irreversible warning and gates confirm on a recipient', () => { | ||
| setup(); | ||
| expect(screen.getByTestId('transfer-handle-modal')).toBeInTheDocument(); | ||
| expect(screen.getByText('@alpha')).toBeInTheDocument(); | ||
| expect(screen.getByText(/permanent and cannot be undone/i)).toBeInTheDocument(); | ||
| // Confirm is disabled until a recipient is entered. | ||
| expect(screen.getByTestId('transfer-handle-confirm')).toBeDisabled(); | ||
| }); | ||
|
|
||
| test('keeps confirm disabled until the exact handle is re-typed', async () => { | ||
| const user = userEvent.setup(); | ||
| setup(); | ||
| const confirmBtn = screen.getByTestId('transfer-handle-confirm'); | ||
| // A recipient alone is not enough for a destructive action. | ||
| await user.type(screen.getByPlaceholderText(/Recipient @handle/i), 'bravo'); | ||
| expect(confirmBtn).toBeDisabled(); | ||
| // A wrong handle keeps it disabled. | ||
| await user.type(screen.getByTestId('transfer-handle-confirm-input'), 'wrong'); | ||
| expect(confirmBtn).toBeDisabled(); | ||
| // The exact handle (case- and @-insensitive) enables it. | ||
| await user.clear(screen.getByTestId('transfer-handle-confirm-input')); | ||
| await user.type(screen.getByTestId('transfer-handle-confirm-input'), '@ALPHA'); | ||
| expect(confirmBtn).toBeEnabled(); | ||
| }); | ||
|
|
||
| test('confirming transfers to the resolved recipient, then closes on success', async () => { | ||
| const user = userEvent.setup(); | ||
| transfer.mockResolvedValueOnce({ identity: { username: 'alpha' } as never }); | ||
| const { onClose, onTransferred } = setup(); | ||
|
|
||
| await user.type(screen.getByPlaceholderText(/Recipient @handle/i), '@bravo'); | ||
| // The irreversible action is gated behind re-typing the handle. | ||
| await user.type(screen.getByTestId('transfer-handle-confirm-input'), '@alpha'); | ||
| await user.click(screen.getByTestId('transfer-handle-confirm')); | ||
|
|
||
| // Leading @ is stripped before the RPC; handle passed through verbatim. | ||
| await waitFor(() => expect(transfer).toHaveBeenCalledWith('alpha', 'bravo')); | ||
| await waitFor(() => expect(onTransferred).toHaveBeenCalledTimes(1)); | ||
| expect(onClose).toHaveBeenCalledTimes(1); | ||
| expect(screen.queryByTestId('transfer-handle-error')).not.toBeInTheDocument(); | ||
| }); | ||
|
|
||
| test('fails closed: on error it shows the message and does not report success', async () => { | ||
| const user = userEvent.setup(); | ||
| transfer.mockRejectedValueOnce(new Error('recipient handle is not registered on tiny.place')); | ||
| const { onClose, onTransferred } = setup(); | ||
|
|
||
| await user.type(screen.getByPlaceholderText(/Recipient @handle/i), 'bravo'); | ||
| await user.type(screen.getByTestId('transfer-handle-confirm-input'), 'alpha'); | ||
| await user.click(screen.getByTestId('transfer-handle-confirm')); | ||
|
|
||
| await waitFor(() => | ||
| expect(screen.getByTestId('transfer-handle-error')).toHaveTextContent(/not registered/i) | ||
| ); | ||
| // Fail closed: no success callbacks, dialog stays open. | ||
| expect(onTransferred).not.toHaveBeenCalled(); | ||
| expect(onClose).not.toHaveBeenCalled(); | ||
| expect(screen.getByTestId('transfer-handle-modal')).toBeInTheDocument(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| /** | ||
| * TransferHandleModal — confirm + execute a Tiny Place handle transfer (GH-4929). | ||
| * | ||
| * A handle transfer is DESTRUCTIVE and irreversible for the sender: on success | ||
| * the recipient becomes the handle's sole owner. So this modal states that | ||
| * plainly, requires an explicit recipient, requires the user to re-type the | ||
| * handle to confirm intent, and takes an explicit confirm click — and it fails | ||
| * **closed**: on any error it keeps the dialog open with the message and never | ||
| * reports success. The core handler resolves the recipient @handle and | ||
| * read-back-confirms the new owner before this promise resolves, so a resolved | ||
| * transfer means the reassignment actually landed. | ||
| */ | ||
| import debugFactory from 'debug'; | ||
| import { useCallback, useState } from 'react'; | ||
|
|
||
| import Button from '../../components/ui/Button'; | ||
| import { ModalShell } from '../../components/ui/ModalShell'; | ||
| import { useT } from '../../lib/i18n/I18nContext'; | ||
| import { apiClient } from '../AgentWorldShell'; | ||
|
|
||
| // Namespaced already ('agentworld:identity'), so messages carry no prefix. | ||
| const debug = debugFactory('agentworld:identity'); | ||
|
|
||
| export interface TransferHandleModalProps { | ||
| /** The handle being transferred away (without a leading @). */ | ||
| handle: string; | ||
| onClose: () => void; | ||
| /** Called after a confirmed, read-back-verified transfer. */ | ||
| onTransferred: () => void; | ||
| } | ||
|
|
||
| /** Normalize a handle for comparison: strip leading @, trim, lowercase. */ | ||
| function normalizeHandle(value: string): string { | ||
| return value.trim().replace(/^@+/, '').toLowerCase(); | ||
| } | ||
|
|
||
| export default function TransferHandleModal({ | ||
| handle, | ||
| onClose, | ||
| onTransferred, | ||
| }: TransferHandleModalProps) { | ||
| const { t } = useT(); | ||
| const [recipient, setRecipient] = useState(''); | ||
| const [confirmText, setConfirmText] = useState(''); | ||
| const [submitting, setSubmitting] = useState(false); | ||
| const [error, setError] = useState<string | null>(null); | ||
|
|
||
| const handleClean = handle.replace(/^@+/, ''); | ||
| // Guard the irreversible action: the user must re-type the exact handle. | ||
| const confirmMatches = normalizeHandle(confirmText) === normalizeHandle(handleClean); | ||
|
|
||
| const submit = useCallback(async () => { | ||
| const target = recipient.trim().replace(/^@+/, ''); | ||
| if (!target) { | ||
| setError(t('agentWorld.transferHandle.recipientRequired')); | ||
| return; | ||
| } | ||
| // Belt-and-suspenders: the button is disabled without a match, but never | ||
| // execute a destructive transfer unless the typed confirmation matches. | ||
| if (!confirmMatches) { | ||
| setError(t('agentWorld.transferHandle.confirmMismatch')); | ||
| return; | ||
| } | ||
| setSubmitting(true); | ||
| setError(null); | ||
| // Never log the handle or recipient — both identify a user. | ||
| debug('handle transfer requested'); | ||
| try { | ||
| // Send the normalized handle (not the raw prop) so the invariant is local | ||
| // and doesn't rest on every caller pre-cleaning the value (#4998 review). | ||
| await apiClient.registry.transfer(handleClean, target); | ||
| debug('handle transfer confirmed'); | ||
| onTransferred(); | ||
| onClose(); | ||
| } catch (err) { | ||
| // Fail closed: keep the dialog open, show why, report no success. | ||
| // Log only the status (no raw error — it can carry backend/SDK detail); | ||
| // the raw message still surfaces in the UI via setError. | ||
| debug('handle transfer failed'); | ||
| setError(String(err)); | ||
| setSubmitting(false); | ||
| } | ||
| }, [recipient, confirmMatches, handleClean, t, onTransferred, onClose]); | ||
|
|
||
| return ( | ||
| <ModalShell | ||
| title={t('agentWorld.transferHandle.title')} | ||
| titleId="agentworld-transfer-handle-title" | ||
| maxWidthClassName="max-w-sm" | ||
| onClose={submitting ? () => undefined : onClose}> | ||
| <div className="space-y-4" data-testid="transfer-handle-modal"> | ||
| <p className="text-sm text-content">@{handleClean}</p> | ||
| <p className="text-xs text-red-600 dark:text-red-400"> | ||
| {t('agentWorld.transferHandle.warning')} | ||
| </p> | ||
|
|
||
| <input | ||
| type="text" | ||
| value={recipient} | ||
| onChange={e => { | ||
| setRecipient(e.target.value); | ||
| setError(null); | ||
| }} | ||
| disabled={submitting} | ||
| placeholder={t('agentWorld.transferHandle.recipientPlaceholder')} | ||
| aria-label={t('agentWorld.transferHandle.recipientPlaceholder')} | ||
| className="w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-content placeholder-content-faint outline-none focus:border-primary-500" | ||
| /> | ||
|
|
||
| {/* Type-to-confirm guard for the irreversible action. */} | ||
| <div className="space-y-1"> | ||
| <p className="text-xs text-content-muted"> | ||
| {t('agentWorld.transferHandle.confirmLabel')} | ||
| </p> | ||
| <input | ||
| type="text" | ||
| value={confirmText} | ||
| onChange={e => { | ||
| setConfirmText(e.target.value); | ||
| setError(null); | ||
| }} | ||
| disabled={submitting} | ||
| placeholder={`@${handleClean}`} | ||
| aria-label={t('agentWorld.transferHandle.confirmLabel')} | ||
| data-testid="transfer-handle-confirm-input" | ||
| className="w-full rounded-md border border-line-strong bg-surface px-3 py-2 text-sm text-content placeholder-content-faint outline-none focus:border-primary-500" | ||
| /> | ||
| </div> | ||
|
|
||
| {error && ( | ||
| <p className="text-xs text-red-600 dark:text-red-400" data-testid="transfer-handle-error"> | ||
| {error} | ||
| </p> | ||
| )} | ||
|
|
||
| <div className="flex justify-end gap-2"> | ||
| <Button variant="secondary" size="sm" onClick={onClose} disabled={submitting}> | ||
| {t('common.cancel')} | ||
| </Button> | ||
| <Button | ||
| variant="primary" | ||
| size="sm" | ||
| tone="danger" | ||
| onClick={() => void submit()} | ||
| disabled={submitting || !recipient.trim() || !confirmMatches} | ||
| data-testid="transfer-handle-confirm"> | ||
| {submitting | ||
| ? t('agentWorld.transferHandle.submitting') | ||
| : t('agentWorld.transferHandle.confirm')} | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| </ModalShell> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.