feat(agent-world): add job-apply agent tool + apply success UX (#3848)

This commit is contained in:
Cyrus Gray
2026-06-19 23:22:07 +05:30
committed by GitHub
parent bec1f4fd5d
commit 540e8fb6c0
20 changed files with 651 additions and 54 deletions
@@ -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(<JobsSection />);
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(<JobsSection />);
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(<JobsSection />);
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({
+92 -54
View File
@@ -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<string | null>(null);
const successTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<ModalShell
onClose={onClose}
title="Apply for Job"
title={t('agentworld.jobs.applyModal.title')}
titleId="apply-modal-title"
maxWidthClassName="max-w-lg">
<form
onSubmit={e => {
void handleSubmit(e);
}}
className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-stone-700 dark:text-neutral-300">
Cover Letter
</label>
<textarea
rows={4}
value={coverLetter}
onChange={e => 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 ? (
<div
role="status"
aria-live="polite"
className="flex flex-col items-center gap-3 py-6 text-center">
<p className="text-sm font-medium text-green-700 dark:text-green-400">
{t('agentworld.jobs.applyModal.successHeading')}
</p>
<p className="text-xs text-stone-500 dark:text-neutral-400">
{t('agentworld.jobs.applyModal.successBody')}
</p>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-stone-700 dark:text-neutral-300">
Bid Amount
</label>
<input
type="text"
value={bidAmount}
onChange={e => 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"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-stone-700 dark:text-neutral-300">
Estimated Delivery
</label>
<input
type="text"
value={estimatedDelivery}
onChange={e => 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"
/>
</div>
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
<div className="flex justify-end gap-2 pt-1">
<Button type="button" onClick={onClose}>
Cancel
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? 'Applying…' : 'Submit Application'}
</Button>
</div>
</form>
) : (
<form
onSubmit={e => {
void handleSubmit(e);
}}
className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-stone-700 dark:text-neutral-300">
{t('agentworld.jobs.applyModal.coverLetterLabel')}
</label>
<textarea
rows={4}
value={coverLetter}
onChange={e => 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')}
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-stone-700 dark:text-neutral-300">
{t('agentworld.jobs.applyModal.bidAmountLabel')}
</label>
<input
type="text"
value={bidAmount}
onChange={e => 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')}
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-stone-700 dark:text-neutral-300">
{t('agentworld.jobs.applyModal.deliveryLabel')}
</label>
<input
type="text"
value={estimatedDelivery}
onChange={e => 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')}
/>
</div>
{error && (
<p role="alert" className="text-xs text-red-600 dark:text-red-400">
{error}
</p>
)}
<div className="flex justify-end gap-2 pt-1">
<Button type="button" onClick={onClose} disabled={submitting}>
{t('agentworld.jobs.applyModal.cancel')}
</Button>
<Button type="submit" disabled={submitting}>
{submitting
? t('agentworld.jobs.applyModal.submitting')
: t('agentworld.jobs.applyModal.submit')}
</Button>
</div>
</form>
)}
</ModalShell>
);
}
@@ -1244,7 +1280,9 @@ export default function JobsSection() {
<ApplyModal
jobId={applyingJobId}
onClose={() => setApplyingJobId(null)}
onApplied={() => setApplyingJobId(null)}
onApplied={() => {
refetchJobs();
}}
/>
)}
{disputeJobId && (
+12
View File
@@ -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;
+13
View File
@@ -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;
+13
View File
@@ -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;
+15
View File
@@ -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;
+13
View File
@@ -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;
+13
View File
@@ -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;
+13
View File
@@ -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;
+13
View File
@@ -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;
+14
View File
@@ -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;
+13
View File
@@ -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;
+13
View File
@@ -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;
+13
View File
@@ -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;
+13
View File
@@ -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;
+12
View File
@@ -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;
+1
View File
@@ -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;
+270
View File
@@ -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<ToolResult> {
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<Vec<String>> = args
.get("past_work")
.and_then(|v| if v.is_null() { None } else { Some(v) })
.map(|v| {
serde_json::from_value::<Vec<String>>(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));
}
}
+1
View File
@@ -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::*;
+5
View File
@@ -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!(