From 540e8fb6c0a72854d762ab6854b134eaf3b46b6e Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:22:07 +0530 Subject: [PATCH] feat(agent-world): add job-apply agent tool + apply success UX (#3848) --- app/src/agentworld/pages/JobsSection.test.tsx | 99 +++++++ app/src/agentworld/pages/JobsSection.tsx | 146 ++++++---- app/src/lib/i18n/ar.ts | 12 + app/src/lib/i18n/bn.ts | 13 + app/src/lib/i18n/de.ts | 13 + app/src/lib/i18n/en.ts | 15 + app/src/lib/i18n/es.ts | 13 + app/src/lib/i18n/fr.ts | 13 + app/src/lib/i18n/hi.ts | 13 + app/src/lib/i18n/id.ts | 13 + app/src/lib/i18n/it.ts | 14 + app/src/lib/i18n/ko.ts | 13 + app/src/lib/i18n/pl.ts | 13 + app/src/lib/i18n/pt.ts | 13 + app/src/lib/i18n/ru.ts | 13 + app/src/lib/i18n/zh-CN.ts | 12 + src/openhuman/tinyplace/mod.rs | 1 + src/openhuman/tinyplace/tools.rs | 270 ++++++++++++++++++ src/openhuman/tools/mod.rs | 1 + src/openhuman/tools/ops.rs | 5 + 20 files changed, 651 insertions(+), 54 deletions(-) create mode 100644 src/openhuman/tinyplace/tools.rs diff --git a/app/src/agentworld/pages/JobsSection.test.tsx b/app/src/agentworld/pages/JobsSection.test.tsx index 4baa333eb..e38446d8f 100644 --- a/app/src/agentworld/pages/JobsSection.test.tsx +++ b/app/src/agentworld/pages/JobsSection.test.tsx @@ -472,6 +472,105 @@ describe('Jobs write actions', () => { }); }); + test('Apply success shows success state and triggers refetch', async () => { + const user = userEvent.setup(); + vi.mocked(fetchWalletStatus).mockResolvedValue(sampleWalletStatus as any); + // First call: initial load; second call should happen after successful apply. + vi.mocked(apiClient.graphql.jobs).mockResolvedValue({ jobs: [sampleJob], count: 1 }); + vi.mocked(apiClient.jobsWrite.apply).mockResolvedValue(sampleProposal as any); + + render(); + await waitFor(() => { + expect(screen.getByText('Build a dashboard widget')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Build a dashboard widget')); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^apply$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^apply$/i })); + + await user.click(screen.getByRole('button', { name: /submit application/i })); + + // After success, the jobs list should be refetched (called a second time). + await waitFor(() => { + expect(vi.mocked(apiClient.graphql.jobs)).toHaveBeenCalledTimes(2); + }); + }); + + test('Apply form shows error on failure', async () => { + const user = userEvent.setup(); + vi.mocked(fetchWalletStatus).mockResolvedValue(sampleWalletStatus as any); + vi.mocked(apiClient.graphql.jobs).mockResolvedValue({ jobs: [sampleJob], count: 1 }); + vi.mocked(apiClient.jobsWrite.apply).mockRejectedValue(new Error('Network error')); + + render(); + await waitFor(() => { + expect(screen.getByText('Build a dashboard widget')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Build a dashboard widget')); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^apply$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^apply$/i })); + + await user.click(screen.getByRole('button', { name: /submit application/i })); + + await waitFor(() => { + // The error message should appear in the modal. + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent(/network error/i); + }); + + // Modal stays open so user can retry. + expect(screen.getByRole('button', { name: /submit application/i })).toBeInTheDocument(); + // Refetch should NOT have been called on failure. + expect(vi.mocked(apiClient.graphql.jobs)).toHaveBeenCalledTimes(1); + }); + + test('Apply button shows loading state while submitting', async () => { + const user = userEvent.setup(); + vi.mocked(fetchWalletStatus).mockResolvedValue(sampleWalletStatus as any); + vi.mocked(apiClient.graphql.jobs).mockResolvedValue({ jobs: [sampleJob], count: 1 }); + + let resolveApply: (v: unknown) => void; + vi.mocked(apiClient.jobsWrite.apply).mockReturnValue( + new Promise(resolve => { + resolveApply = resolve; + }) as any + ); + + render(); + await waitFor(() => { + expect(screen.getByText('Build a dashboard widget')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Build a dashboard widget')); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^apply$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^apply$/i })); + + await user.click(screen.getByRole('button', { name: /submit application/i })); + + // While the request is in-flight, the button shows "Applying..." and is disabled. + await waitFor(() => { + const btn = screen.getByRole('button', { name: /applying/i }); + expect(btn).toBeInTheDocument(); + expect(btn).toBeDisabled(); + }); + + // Clean up the pending promise. + resolveApply!(sampleProposal); + }); + test('Own job shows Cancel Job and View Proposals buttons', async () => { const user = userEvent.setup(); vi.mocked(fetchWalletStatus).mockResolvedValue({ diff --git a/app/src/agentworld/pages/JobsSection.tsx b/app/src/agentworld/pages/JobsSection.tsx index 73206f6e8..850b88348 100644 --- a/app/src/agentworld/pages/JobsSection.tsx +++ b/app/src/agentworld/pages/JobsSection.tsx @@ -14,7 +14,7 @@ * Pattern mirrors LedgerSection / FeedSection: useState + useEffect fetch, * PanelScaffold wrapper, StatusBlock for loading/error/empty states. */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import PanelScaffold from '../../components/layout/PanelScaffold'; import Button from '../../components/ui/Button'; @@ -368,11 +368,23 @@ function ApplyModal({ onClose: () => void; onApplied: () => void; }) { + const { t } = useT(); const [coverLetter, setCoverLetter] = useState(''); const [bidAmount, setBidAmount] = useState(''); const [estimatedDelivery, setEstimatedDelivery] = useState(''); const [submitting, setSubmitting] = useState(false); + const [succeeded, setSucceeded] = useState(false); const [error, setError] = useState(null); + const successTimerRef = useRef | null>(null); + + // Clear the auto-close timer on unmount to avoid state updates after teardown. + useEffect(() => { + return () => { + if (successTimerRef.current) { + clearTimeout(successTimerRef.current); + } + }; + }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -385,8 +397,12 @@ function ApplyModal({ }; try { await apiClient.jobsWrite.apply(jobId, params); + setSucceeded(true); onApplied(); - onClose(); + // Auto-close after a short success display window. + successTimerRef.current = setTimeout(() => { + onClose(); + }, 1500); } catch (err) { setError(String(err)); } finally { @@ -397,60 +413,80 @@ function ApplyModal({ return ( - { - void handleSubmit(e); - }} - className="space-y-3"> - - - Cover Letter - - setCoverLetter(e.target.value)} - className="w-full rounded border border-stone-300 bg-white px-2.5 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100" - placeholder="Describe your experience and why you're a good fit" - /> + {succeeded ? ( + + + {t('agentworld.jobs.applyModal.successHeading')} + + + {t('agentworld.jobs.applyModal.successBody')} + - - - Bid Amount - - setBidAmount(e.target.value)} - className="w-full rounded border border-stone-300 bg-white px-2.5 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100" - placeholder="e.g. 450 USDC" - /> - - - - Estimated Delivery - - setEstimatedDelivery(e.target.value)} - className="w-full rounded border border-stone-300 bg-white px-2.5 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100" - placeholder="e.g. 2 weeks" - /> - - {error && {error}} - - - Cancel - - - {submitting ? 'Applying…' : 'Submit Application'} - - - + ) : ( + { + void handleSubmit(e); + }} + className="space-y-3"> + + + {t('agentworld.jobs.applyModal.coverLetterLabel')} + + setCoverLetter(e.target.value)} + className="w-full rounded border border-stone-300 bg-white px-2.5 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100" + placeholder={t('agentworld.jobs.applyModal.coverLetterPlaceholder')} + /> + + + + {t('agentworld.jobs.applyModal.bidAmountLabel')} + + setBidAmount(e.target.value)} + className="w-full rounded border border-stone-300 bg-white px-2.5 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100" + placeholder={t('agentworld.jobs.applyModal.bidAmountPlaceholder')} + /> + + + + {t('agentworld.jobs.applyModal.deliveryLabel')} + + setEstimatedDelivery(e.target.value)} + className="w-full rounded border border-stone-300 bg-white px-2.5 py-1.5 text-sm text-stone-900 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-100" + placeholder={t('agentworld.jobs.applyModal.deliveryPlaceholder')} + /> + + {error && ( + + {error} + + )} + + + {t('agentworld.jobs.applyModal.cancel')} + + + {submitting + ? t('agentworld.jobs.applyModal.submitting') + : t('agentworld.jobs.applyModal.submit')} + + + + )} ); } @@ -1244,7 +1280,9 @@ export default function JobsSection() { setApplyingJobId(null)} - onApplied={() => setApplyingJobId(null)} + onApplied={() => { + refetchJobs(); + }} /> )} {disputeJobId && ( diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index f4e090dc5..59043b08c 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -5621,6 +5621,18 @@ const messages: TranslationMap = { 'settings.profiles.editor.notFound': 'الملف غير موجود', 'settings.profiles.editor.saving': 'جارٍ الحفظ…', 'settings.profiles.editor.idRequired': 'لا يمكن أن يكون معرّف الملف فارغًا', + 'agentworld.jobs.applyModal.title': 'التقدم للوظيفة', + 'agentworld.jobs.applyModal.successHeading': 'تم تقديم العرض بنجاح!', + 'agentworld.jobs.applyModal.successBody': 'سيراجع ناشر الوظيفة طلبك.', + 'agentworld.jobs.applyModal.coverLetterLabel': 'خطاب التغطية', + 'agentworld.jobs.applyModal.coverLetterPlaceholder': 'صف خبرتك وسبب ملاءمتك لهذا الدور', + 'agentworld.jobs.applyModal.bidAmountLabel': 'مبلغ العطاء', + 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'مثال: 450 USDC', + 'agentworld.jobs.applyModal.deliveryLabel': 'وقت التسليم المتوقع', + 'agentworld.jobs.applyModal.deliveryPlaceholder': 'مثال: أسبوعان', + 'agentworld.jobs.applyModal.cancel': 'إلغاء', + 'agentworld.jobs.applyModal.submit': 'إرسال الطلب', + 'agentworld.jobs.applyModal.submitting': 'جارٍ التقديم…', }; export default messages; diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 64f363954..0c9e5947d 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -5735,6 +5735,19 @@ const messages: TranslationMap = { 'settings.profiles.editor.notFound': 'প্রোফাইল পাওয়া যায়নি', 'settings.profiles.editor.saving': 'সংরক্ষণ হচ্ছে…', 'settings.profiles.editor.idRequired': 'প্রোফাইল আইডি খালি রাখা যাবে না', + 'agentworld.jobs.applyModal.title': 'চাকরির জন্য আবেদন করুন', + 'agentworld.jobs.applyModal.successHeading': 'প্রস্তাব সফলভাবে জমা দেওয়া হয়েছে!', + 'agentworld.jobs.applyModal.successBody': 'চাকরি পোস্টকারী আপনার আবেদন পর্যালোচনা করবেন।', + 'agentworld.jobs.applyModal.coverLetterLabel': 'কভার লেটার', + 'agentworld.jobs.applyModal.coverLetterPlaceholder': + 'আপনার অভিজ্ঞতা এবং কেন আপনি উপযুক্ত তা বর্ণনা করুন', + 'agentworld.jobs.applyModal.bidAmountLabel': 'বিড পরিমাণ', + 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'যেমন: 450 USDC', + 'agentworld.jobs.applyModal.deliveryLabel': 'আনুমানিক ডেলিভারি', + 'agentworld.jobs.applyModal.deliveryPlaceholder': 'যেমন: 2 সপ্তাহ', + 'agentworld.jobs.applyModal.cancel': 'বাতিল', + 'agentworld.jobs.applyModal.submit': 'আবেদন জমা দিন', + 'agentworld.jobs.applyModal.submitting': 'আবেদন করা হচ্ছে…', }; export default messages; diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index de46c7cec..87eaa99bf 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -5894,6 +5894,19 @@ const messages: TranslationMap = { 'settings.profiles.editor.notFound': 'Profil nicht gefunden', 'settings.profiles.editor.saving': 'Wird gespeichert…', 'settings.profiles.editor.idRequired': 'Die Profil-Kennung darf nicht leer sein', + 'agentworld.jobs.applyModal.title': 'Auf Stelle bewerben', + 'agentworld.jobs.applyModal.successHeading': 'Vorschlag erfolgreich eingereicht!', + 'agentworld.jobs.applyModal.successBody': 'Der Auftraggeber wird Ihre Bewerbung prüfen.', + 'agentworld.jobs.applyModal.coverLetterLabel': 'Anschreiben', + 'agentworld.jobs.applyModal.coverLetterPlaceholder': + 'Beschreiben Sie Ihre Erfahrung und warum Sie gut geeignet sind', + 'agentworld.jobs.applyModal.bidAmountLabel': 'Gebotsbetrag', + 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'z. B. 450 USDC', + 'agentworld.jobs.applyModal.deliveryLabel': 'Voraussichtliche Lieferzeit', + 'agentworld.jobs.applyModal.deliveryPlaceholder': 'z. B. 2 Wochen', + 'agentworld.jobs.applyModal.cancel': 'Abbrechen', + 'agentworld.jobs.applyModal.submit': 'Bewerbung einreichen', + 'agentworld.jobs.applyModal.submitting': 'Wird eingereicht…', }; export default messages; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index fc99b6a69..92cc8e969 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -6000,6 +6000,21 @@ const en: TranslationMap = { 'notch.speaking': 'Speaking…', 'notch.transcribing': 'Transcribing…', 'notch.executing': 'Executing…', + + // ── Agent World: Jobs apply modal ───────────────────────────────────────── + 'agentworld.jobs.applyModal.title': 'Apply for Job', + 'agentworld.jobs.applyModal.successHeading': 'Proposal submitted successfully!', + 'agentworld.jobs.applyModal.successBody': 'The job poster will review your application.', + 'agentworld.jobs.applyModal.coverLetterLabel': 'Cover Letter', + 'agentworld.jobs.applyModal.coverLetterPlaceholder': + "Describe your experience and why you're a good fit", + 'agentworld.jobs.applyModal.bidAmountLabel': 'Bid Amount', + 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'e.g. 450 USDC', + 'agentworld.jobs.applyModal.deliveryLabel': 'Estimated Delivery', + 'agentworld.jobs.applyModal.deliveryPlaceholder': 'e.g. 2 weeks', + 'agentworld.jobs.applyModal.cancel': 'Cancel', + 'agentworld.jobs.applyModal.submit': 'Submit Application', + 'agentworld.jobs.applyModal.submitting': 'Applying…', }; export default en; diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index b3b9bfd56..075bdb502 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -5859,6 +5859,19 @@ const messages: TranslationMap = { 'settings.profiles.editor.notFound': 'Perfil no encontrado', 'settings.profiles.editor.saving': 'Guardando…', 'settings.profiles.editor.idRequired': 'El identificador del perfil no puede estar vacío', + 'agentworld.jobs.applyModal.title': 'Solicitar trabajo', + 'agentworld.jobs.applyModal.successHeading': '¡Propuesta enviada con éxito!', + 'agentworld.jobs.applyModal.successBody': 'El publicador del trabajo revisará tu solicitud.', + 'agentworld.jobs.applyModal.coverLetterLabel': 'Carta de presentación', + 'agentworld.jobs.applyModal.coverLetterPlaceholder': + 'Describe tu experiencia y por qué eres la persona adecuada', + 'agentworld.jobs.applyModal.bidAmountLabel': 'Monto de la oferta', + 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'ej. 450 USDC', + 'agentworld.jobs.applyModal.deliveryLabel': 'Entrega estimada', + 'agentworld.jobs.applyModal.deliveryPlaceholder': 'ej. 2 semanas', + 'agentworld.jobs.applyModal.cancel': 'Cancelar', + 'agentworld.jobs.applyModal.submit': 'Enviar solicitud', + 'agentworld.jobs.applyModal.submitting': 'Enviando…', }; export default messages; diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 566a12353..ca6555005 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -5876,6 +5876,19 @@ const messages: TranslationMap = { 'settings.profiles.editor.notFound': 'Profil introuvable', 'settings.profiles.editor.saving': 'Enregistrement…', 'settings.profiles.editor.idRequired': "L'identifiant du profil ne peut pas être vide", + 'agentworld.jobs.applyModal.title': "Postuler à l'offre", + 'agentworld.jobs.applyModal.successHeading': 'Proposition soumise avec succès !', + 'agentworld.jobs.applyModal.successBody': 'Le recruteur examinera votre candidature.', + 'agentworld.jobs.applyModal.coverLetterLabel': 'Lettre de motivation', + 'agentworld.jobs.applyModal.coverLetterPlaceholder': + 'Décrivez votre expérience et pourquoi vous êtes le bon candidat', + 'agentworld.jobs.applyModal.bidAmountLabel': 'Montant proposé', + 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'ex. 450 USDC', + 'agentworld.jobs.applyModal.deliveryLabel': 'Délai estimé', + 'agentworld.jobs.applyModal.deliveryPlaceholder': 'ex. 2 semaines', + 'agentworld.jobs.applyModal.cancel': 'Annuler', + 'agentworld.jobs.applyModal.submit': 'Soumettre la candidature', + 'agentworld.jobs.applyModal.submitting': 'Envoi en cours…', }; export default messages; diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index d681788ae..7f4755321 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -5738,6 +5738,19 @@ const messages: TranslationMap = { 'settings.profiles.editor.notFound': 'प्रोफ़ाइल नहीं मिली', 'settings.profiles.editor.saving': 'सहेजा जा रहा है…', 'settings.profiles.editor.idRequired': 'प्रोफ़ाइल आईडी खाली नहीं हो सकती', + 'agentworld.jobs.applyModal.title': 'नौकरी के लिए आवेदन करें', + 'agentworld.jobs.applyModal.successHeading': 'प्रस्ताव सफलतापूर्वक सबमिट हुआ!', + 'agentworld.jobs.applyModal.successBody': 'नौकरी पोस्टर आपके आवेदन की समीक्षा करेगा।', + 'agentworld.jobs.applyModal.coverLetterLabel': 'कवर लेटर', + 'agentworld.jobs.applyModal.coverLetterPlaceholder': + 'अपना अनुभव और इस भूमिका के लिए उपयुक्तता बताएं', + 'agentworld.jobs.applyModal.bidAmountLabel': 'बोली राशि', + 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'उदा. 450 USDC', + 'agentworld.jobs.applyModal.deliveryLabel': 'अनुमानित डिलीवरी', + 'agentworld.jobs.applyModal.deliveryPlaceholder': 'उदा. 2 सप्ताह', + 'agentworld.jobs.applyModal.cancel': 'रद्द करें', + 'agentworld.jobs.applyModal.submit': 'आवेदन सबमिट करें', + 'agentworld.jobs.applyModal.submitting': 'आवेदन हो रहा है…', }; export default messages; diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 17bd90928..d315195fa 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -5755,6 +5755,19 @@ const messages: TranslationMap = { 'settings.profiles.editor.notFound': 'Profil tidak ditemukan', 'settings.profiles.editor.saving': 'Menyimpan…', 'settings.profiles.editor.idRequired': 'ID profil tidak boleh kosong', + 'agentworld.jobs.applyModal.title': 'Lamar Pekerjaan', + 'agentworld.jobs.applyModal.successHeading': 'Proposal berhasil dikirim!', + 'agentworld.jobs.applyModal.successBody': 'Pemasang lowongan akan meninjau lamaran Anda.', + 'agentworld.jobs.applyModal.coverLetterLabel': 'Surat Lamaran', + 'agentworld.jobs.applyModal.coverLetterPlaceholder': + 'Jelaskan pengalaman Anda dan mengapa Anda cocok untuk posisi ini', + 'agentworld.jobs.applyModal.bidAmountLabel': 'Jumlah Tawaran', + 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'mis. 450 USDC', + 'agentworld.jobs.applyModal.deliveryLabel': 'Estimasi Pengiriman', + 'agentworld.jobs.applyModal.deliveryPlaceholder': 'mis. 2 minggu', + 'agentworld.jobs.applyModal.cancel': 'Batal', + 'agentworld.jobs.applyModal.submit': 'Kirim Lamaran', + 'agentworld.jobs.applyModal.submitting': 'Melamar…', }; export default messages; diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index c8fe32e24..2b62ac97a 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -5844,6 +5844,20 @@ const messages: TranslationMap = { 'settings.profiles.editor.notFound': 'Profilo non trovato', 'settings.profiles.editor.saving': 'Salvataggio…', 'settings.profiles.editor.idRequired': "L'identificativo del profilo non può essere vuoto", + 'agentworld.jobs.applyModal.title': 'Candidati al lavoro', + 'agentworld.jobs.applyModal.successHeading': 'Proposta inviata con successo!', + 'agentworld.jobs.applyModal.successBody': + "Il pubblicatore dell'offerta esaminerà la tua candidatura.", + 'agentworld.jobs.applyModal.coverLetterLabel': 'Lettera di presentazione', + 'agentworld.jobs.applyModal.coverLetterPlaceholder': + 'Descrivi la tua esperienza e perché sei adatto a questo ruolo', + 'agentworld.jobs.applyModal.bidAmountLabel': 'Importo offerta', + 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'es. 450 USDC', + 'agentworld.jobs.applyModal.deliveryLabel': 'Consegna stimata', + 'agentworld.jobs.applyModal.deliveryPlaceholder': 'es. 2 settimane', + 'agentworld.jobs.applyModal.cancel': 'Annulla', + 'agentworld.jobs.applyModal.submit': 'Invia candidatura', + 'agentworld.jobs.applyModal.submitting': 'Candidatura in corso…', }; export default messages; diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index c2e9bdca4..1f215343e 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -5682,6 +5682,19 @@ const messages: TranslationMap = { 'settings.profiles.editor.notFound': '프로필을 찾을 수 없습니다', 'settings.profiles.editor.saving': '저장 중…', 'settings.profiles.editor.idRequired': '프로필 식별자는 비워 둘 수 없습니다', + 'agentworld.jobs.applyModal.title': '채용 지원', + 'agentworld.jobs.applyModal.successHeading': '제안이 성공적으로 제출되었습니다!', + 'agentworld.jobs.applyModal.successBody': '채용 공고 게시자가 지원서를 검토할 것입니다.', + 'agentworld.jobs.applyModal.coverLetterLabel': '자기소개서', + 'agentworld.jobs.applyModal.coverLetterPlaceholder': + '귀하의 경험과 이 직무에 적합한 이유를 설명하세요', + 'agentworld.jobs.applyModal.bidAmountLabel': '제안 금액', + 'agentworld.jobs.applyModal.bidAmountPlaceholder': '예: 450 USDC', + 'agentworld.jobs.applyModal.deliveryLabel': '예상 납기', + 'agentworld.jobs.applyModal.deliveryPlaceholder': '예: 2주', + 'agentworld.jobs.applyModal.cancel': '취소', + 'agentworld.jobs.applyModal.submit': '지원서 제출', + 'agentworld.jobs.applyModal.submitting': '지원 중…', }; export default messages; diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index b6f8cb21c..51b7fcf50 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -5827,6 +5827,19 @@ const messages: TranslationMap = { 'settings.profiles.editor.notFound': 'Nie znaleziono profilu', 'settings.profiles.editor.saving': 'Zapisywanie…', 'settings.profiles.editor.idRequired': 'Identyfikator profilu nie może być pusty', + 'agentworld.jobs.applyModal.title': 'Aplikuj na stanowisko', + 'agentworld.jobs.applyModal.successHeading': 'Oferta złożona pomyślnie!', + 'agentworld.jobs.applyModal.successBody': 'Pracodawca przejrzy Twoją aplikację.', + 'agentworld.jobs.applyModal.coverLetterLabel': 'List motywacyjny', + 'agentworld.jobs.applyModal.coverLetterPlaceholder': + 'Opisz swoje doświadczenie i dlaczego pasujesz do tej roli', + 'agentworld.jobs.applyModal.bidAmountLabel': 'Kwota oferty', + 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'np. 450 USDC', + 'agentworld.jobs.applyModal.deliveryLabel': 'Szacowany czas realizacji', + 'agentworld.jobs.applyModal.deliveryPlaceholder': 'np. 2 tygodnie', + 'agentworld.jobs.applyModal.cancel': 'Anuluj', + 'agentworld.jobs.applyModal.submit': 'Wyślij aplikację', + 'agentworld.jobs.applyModal.submitting': 'Wysyłanie…', }; export default messages; diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 16d69c60f..0662f5885 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -5839,6 +5839,19 @@ const messages: TranslationMap = { 'settings.profiles.editor.notFound': 'Perfil não encontrado', 'settings.profiles.editor.saving': 'Salvando…', 'settings.profiles.editor.idRequired': 'O identificador do perfil não pode estar vazio', + 'agentworld.jobs.applyModal.title': 'Candidatar-se à vaga', + 'agentworld.jobs.applyModal.successHeading': 'Proposta enviada com sucesso!', + 'agentworld.jobs.applyModal.successBody': 'O publicador da vaga irá analisar a sua candidatura.', + 'agentworld.jobs.applyModal.coverLetterLabel': 'Carta de apresentação', + 'agentworld.jobs.applyModal.coverLetterPlaceholder': + 'Descreva a sua experiência e por que você é adequado para esta função', + 'agentworld.jobs.applyModal.bidAmountLabel': 'Valor da proposta', + 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'ex. 450 USDC', + 'agentworld.jobs.applyModal.deliveryLabel': 'Prazo estimado de entrega', + 'agentworld.jobs.applyModal.deliveryPlaceholder': 'ex. 2 semanas', + 'agentworld.jobs.applyModal.cancel': 'Cancelar', + 'agentworld.jobs.applyModal.submit': 'Enviar candidatura', + 'agentworld.jobs.applyModal.submitting': 'A enviar…', }; export default messages; diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index eb215f6f5..a8fe1b3ac 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -5800,6 +5800,19 @@ const messages: TranslationMap = { 'settings.profiles.editor.notFound': 'Профиль не найден', 'settings.profiles.editor.saving': 'Сохранение…', 'settings.profiles.editor.idRequired': 'Идентификатор профиля не может быть пустым', + 'agentworld.jobs.applyModal.title': 'Откликнуться на вакансию', + 'agentworld.jobs.applyModal.successHeading': 'Предложение успешно отправлено!', + 'agentworld.jobs.applyModal.successBody': 'Работодатель рассмотрит вашу заявку.', + 'agentworld.jobs.applyModal.coverLetterLabel': 'Сопроводительное письмо', + 'agentworld.jobs.applyModal.coverLetterPlaceholder': + 'Опишите свой опыт и почему вы подходите для этой роли', + 'agentworld.jobs.applyModal.bidAmountLabel': 'Сумма предложения', + 'agentworld.jobs.applyModal.bidAmountPlaceholder': 'напр. 450 USDC', + 'agentworld.jobs.applyModal.deliveryLabel': 'Ориентировочный срок выполнения', + 'agentworld.jobs.applyModal.deliveryPlaceholder': 'напр. 2 недели', + 'agentworld.jobs.applyModal.cancel': 'Отмена', + 'agentworld.jobs.applyModal.submit': 'Отправить заявку', + 'agentworld.jobs.applyModal.submitting': 'Отправка…', }; export default messages; diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 30bbf8a51..3aaf99af3 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -5447,6 +5447,18 @@ const messages: TranslationMap = { 'settings.profiles.editor.notFound': '未找到配置', 'settings.profiles.editor.saving': '正在保存…', 'settings.profiles.editor.idRequired': '配置标识不能为空', + 'agentworld.jobs.applyModal.title': '申请职位', + 'agentworld.jobs.applyModal.successHeading': '提案提交成功!', + 'agentworld.jobs.applyModal.successBody': '招聘方将审核您的申请。', + 'agentworld.jobs.applyModal.coverLetterLabel': '求职信', + 'agentworld.jobs.applyModal.coverLetterPlaceholder': '描述您的经验以及为何适合此职位', + 'agentworld.jobs.applyModal.bidAmountLabel': '报价金额', + 'agentworld.jobs.applyModal.bidAmountPlaceholder': '例:450 USDC', + 'agentworld.jobs.applyModal.deliveryLabel': '预计交付时间', + 'agentworld.jobs.applyModal.deliveryPlaceholder': '例:2周', + 'agentworld.jobs.applyModal.cancel': '取消', + 'agentworld.jobs.applyModal.submit': '提交申请', + 'agentworld.jobs.applyModal.submitting': '申请中…', }; export default messages; diff --git a/src/openhuman/tinyplace/mod.rs b/src/openhuman/tinyplace/mod.rs index 927911144..c73c4540b 100644 --- a/src/openhuman/tinyplace/mod.rs +++ b/src/openhuman/tinyplace/mod.rs @@ -32,6 +32,7 @@ mod schemas; pub(crate) mod signal_store; mod state; pub(crate) mod streams; +pub mod tools; #[cfg(test)] mod signal_e2e_tests; diff --git a/src/openhuman/tinyplace/tools.rs b/src/openhuman/tinyplace/tools.rs new file mode 100644 index 000000000..b40714db8 --- /dev/null +++ b/src/openhuman/tinyplace/tools.rs @@ -0,0 +1,270 @@ +//! LLM-callable agent tools for the `tinyplace` domain. +//! +//! Exposes write actions (job proposal submission) to the agent tool-call +//! pipeline. The candidate is always resolved server-side from the wallet +//! signer (`signer.agent_id()`) to prevent impersonation — it is never +//! accepted as a tool argument. + +use async_trait::async_trait; +use serde_json::json; +use tinyplace::types::ProposalCreateRequest; + +use crate::openhuman::tinyplace::ops::{global_state, map_err}; +use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; + +const LOG_PREFIX: &str = "[tinyplace][tool]"; + +// ── TinyplaceJobApplyTool ───────────────────────────────────────────────────── + +/// Submit a proposal (apply) to an open tiny.place job on behalf of the user. +/// +/// The candidate identity is always derived from the user's wallet signer +/// (`signer.agent_id()`) — it cannot be overridden via tool arguments, +/// preventing any impersonation of another user. +/// +/// Job proposals are free (directory-signed POST, no x402 payment). The +/// escrow/payment only happens when the job poster selects a candidate. +pub struct TinyplaceJobApplyTool; + +#[async_trait] +impl Tool for TinyplaceJobApplyTool { + fn name(&self) -> &str { + "tinyplace_job_apply" + } + + fn description(&self) -> &str { + "Submit a proposal (apply) to an open tiny.place job on behalf of the user. \ + Requires job_id. Optionally include a cover_letter, bid_amount (e.g. '450 USDC'), \ + estimated_delivery (e.g. '2 weeks'), and past_work URLs. \ + The candidate is always resolved from the user's wallet signer — it cannot \ + be supplied as an argument. Proposals are free (no payment required). \ + This is a write action: it submits an application on the user's behalf." + } + + fn parameters_schema(&self) -> serde_json::Value { + json!({ + "type": "object", + "properties": { + "job_id": { + "type": "string", + "description": "The tiny.place job ID to apply for." + }, + "cover_letter": { + "type": "string", + "description": "Optional cover letter describing experience and fit for the role." + }, + "bid_amount": { + "type": "string", + "description": "Optional bid amount, e.g. '450 USDC'." + }, + "estimated_delivery": { + "type": "string", + "description": "Optional estimated delivery time, e.g. '2 weeks'." + }, + "past_work": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional list of past work URLs or descriptions." + } + }, + "required": ["job_id"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let job_id = args + .get("job_id") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .ok_or_else(|| anyhow::anyhow!("missing required parameter 'job_id'"))?; + + let cover_letter = args + .get("cover_letter") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + + let bid_amount = args + .get("bid_amount") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + + let estimated_delivery = args + .get("estimated_delivery") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string); + + let past_work: Option> = args + .get("past_work") + .and_then(|v| if v.is_null() { None } else { Some(v) }) + .map(|v| { + serde_json::from_value::>(v.clone()) + .map_err(|e| anyhow::anyhow!("invalid 'past_work' param: {e}")) + }) + .transpose()?; + + log::debug!( + "{LOG_PREFIX} tinyplace_job_apply job_id={job_id} \ + has_cover_letter={} has_bid={} has_delivery={} past_work_count={}", + cover_letter.is_some(), + bid_amount.is_some(), + estimated_delivery.is_some(), + past_work.as_ref().map(|v| v.len()).unwrap_or(0), + ); + + // Resolve candidate anti-spoof: always derived from the wallet signer. + // The agent cannot supply a candidate arg — the signer is the source of truth. + let client = global_state() + .client() + .await + .map_err(|e| anyhow::anyhow!("tinyplace client unavailable: {e}"))?; + + let signer = client + .http() + .signer() + .ok_or_else(|| anyhow::anyhow!("tiny.place signer unavailable; unlock your wallet"))?; + + // Candidate is always from the signer — not from tool arguments. + let candidate = signer.agent_id(); + + log::debug!("{LOG_PREFIX} tinyplace_job_apply candidate_resolved=true job_id={job_id}"); + + let request = ProposalCreateRequest { + candidate, + cover_letter, + bid_amount, + estimated_delivery, + past_work, + }; + + let result = client + .jobs + .apply(&job_id, &request) + .await + .map_err(|e| anyhow::anyhow!("{}", map_err(e)))?; + + log::debug!( + "{LOG_PREFIX} tinyplace_job_apply success proposal_id={}", + result.proposal_id + ); + + let output = serde_json::to_string(&result) + .map_err(|e| anyhow::anyhow!("tinyplace serialise: {e}"))?; + + Ok(ToolResult::success(output)) + } + + fn permission_level(&self) -> PermissionLevel { + // Write — submits a proposal on the user's behalf. + PermissionLevel::Write + } + + fn external_effect(&self) -> bool { + // POSTs a proposal to an external service. + true + } + + fn is_concurrency_safe(&self, _args: &serde_json::Value) -> bool { + false + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::openhuman::tools::traits::{PermissionLevel, ToolScope}; + use serde_json::json; + + #[test] + fn tool_metadata() { + let tool = TinyplaceJobApplyTool; + assert_eq!(tool.name(), "tinyplace_job_apply"); + assert_eq!(tool.permission_level(), PermissionLevel::Write); + assert_eq!(tool.scope(), ToolScope::All); + assert!(tool.external_effect()); + assert!(!tool.is_concurrency_safe(&json!({}))); + } + + #[test] + fn parameters_schema_requires_job_id() { + let schema = TinyplaceJobApplyTool.parameters_schema(); + let required = schema["required"].as_array().expect("required array"); + assert!(required.iter().any(|v| v.as_str() == Some("job_id"))); + + // Candidate must NOT be in the schema — it's resolved server-side. + let props = schema["properties"].as_object().expect("properties object"); + assert!( + !props.contains_key("candidate"), + "candidate must not be a tool argument (anti-spoof)" + ); + assert!(props.contains_key("job_id")); + assert!(props.contains_key("cover_letter")); + assert!(props.contains_key("bid_amount")); + assert!(props.contains_key("estimated_delivery")); + assert!(props.contains_key("past_work")); + } + + #[tokio::test] + async fn missing_job_id_returns_error_before_client() { + // Passes empty args — should fail with a clear error before + // attempting any network call or client initialisation. + let result = TinyplaceJobApplyTool.execute(json!({})).await; + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("job_id"), + "error should mention 'job_id', got: {msg}" + ); + } + + #[tokio::test] + async fn missing_job_id_with_other_fields_still_errors() { + let result = TinyplaceJobApplyTool + .execute(json!({ + "cover_letter": "Great project", + "bid_amount": "100 USDC" + })) + .await; + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("job_id")); + } + + #[tokio::test] + async fn blank_job_id_returns_error_before_client() { + let result = TinyplaceJobApplyTool + .execute(json!({ "job_id": " " })) + .await; + assert!(result.is_err()); + let msg = result.unwrap_err().to_string(); + assert!(msg.contains("job_id")); + } + + #[test] + fn proposal_create_request_shape_from_tool_args() { + // Verify the ProposalCreateRequest struct can be constructed as the + // tool would produce it — candidate always from signer, never from args. + let candidate = "agent_1abc".to_string(); + let request = ProposalCreateRequest { + candidate: candidate.clone(), + cover_letter: Some("I can do this".to_string()), + bid_amount: Some("200 USDC".to_string()), + estimated_delivery: Some("1 week".to_string()), + past_work: Some(vec!["https://example.com/project".to_string()]), + }; + assert_eq!(request.candidate, candidate); + assert_eq!(request.cover_letter.as_deref(), Some("I can do this")); + assert_eq!(request.bid_amount.as_deref(), Some("200 USDC")); + assert_eq!(request.estimated_delivery.as_deref(), Some("1 week")); + assert_eq!(request.past_work.as_ref().map(|v| v.len()), Some(1)); + } +} diff --git a/src/openhuman/tools/mod.rs b/src/openhuman/tools/mod.rs index 32d9b716a..b7b677dd9 100644 --- a/src/openhuman/tools/mod.rs +++ b/src/openhuman/tools/mod.rs @@ -44,6 +44,7 @@ pub use crate::openhuman::skill_runtime::tools::*; pub use crate::openhuman::task_sources::tools::*; pub use crate::openhuman::team::tools::*; pub use crate::openhuman::threads::tools::*; +pub use crate::openhuman::tinyplace::tools::*; pub use crate::openhuman::todos::tools::*; pub use crate::openhuman::wallet::tools::*; pub use crate::openhuman::whatsapp_data::tools::*; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 768eb6af5..783b95feb 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -515,6 +515,11 @@ pub fn all_tools_with_runtime( Box::new(WorkspaceUpdatePersonaTool::new(config.clone())), Box::new(WorkspaceResetPersonaTool::new(config.clone())), Box::new(WorkspaceInitTool), + // tiny.place agent tools — submit proposals on behalf of the user. + // Write-level: requires supervised/full autonomy to run without prompting. + // Always registered; actual network call fails gracefully when the wallet + // is locked or TINYPLACE_API_BASE_URL is unavailable. + Box::new(TinyplaceJobApplyTool), ]; log::debug!(
+ {t('agentworld.jobs.applyModal.successHeading')} +
+ {t('agentworld.jobs.applyModal.successBody')} +
{error}
+ {error} +