mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
refactor(workflows): complete skills→workflows rename (internal + wire/FE) [#3324 follow-up] (#3412)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
* The Intelligence page's "Workflows" tab — the single home for installed
|
||||
* workflows (the unified primitive: a goal + the procedure to reach it,
|
||||
* authored as SKILL.md bundles and served by the `workflows_*` JSON-RPC via
|
||||
* `skillsApi`).
|
||||
* `workflowsApi`).
|
||||
*
|
||||
* Owns the full workflow surface that used to live on the Connections page:
|
||||
* - lists discovered workflows as cards,
|
||||
@@ -21,7 +21,7 @@ import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { skillsApi, type SkillSummary } from '../../services/api/skillsApi';
|
||||
import { workflowsApi, type WorkflowSummary } from '../../services/api/workflowsApi';
|
||||
import type { ToastNotification } from '../../types/intelligence';
|
||||
import CreateSkillModal from '../skills/CreateSkillModal';
|
||||
import UnifiedSkillCard from '../skills/SkillCard';
|
||||
@@ -34,10 +34,10 @@ const log = debug('intelligence:workflows');
|
||||
export default function WorkflowsTab() {
|
||||
const { t } = useT();
|
||||
const navigate = useNavigate();
|
||||
const [workflows, setWorkflows] = useState<SkillSummary[]>([]);
|
||||
const [workflows, setWorkflows] = useState<WorkflowSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||
const [uninstallCandidate, setUninstallCandidate] = useState<SkillSummary | null>(null);
|
||||
const [uninstallCandidate, setUninstallCandidate] = useState<WorkflowSummary | null>(null);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [toasts, setToasts] = useState<ToastNotification[]>([]);
|
||||
|
||||
@@ -49,9 +49,9 @@ export default function WorkflowsTab() {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async (): Promise<SkillSummary[]> => {
|
||||
const refresh = useCallback(async (): Promise<WorkflowSummary[]> => {
|
||||
try {
|
||||
const list = await skillsApi.listSkills();
|
||||
const list = await workflowsApi.listWorkflows();
|
||||
log('listWorkflows ok count=%d', list.length);
|
||||
setLoadError(null);
|
||||
setWorkflows(list);
|
||||
@@ -100,7 +100,7 @@ export default function WorkflowsTab() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Load error — shown instead of the empty state when listSkills fails,
|
||||
{/* Load error — shown instead of the empty state when listWorkflows fails,
|
||||
so an outage doesn't read as "you have no workflows". */}
|
||||
{loadError && !loading ? (
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { SkillSummary } from '../../../services/api/skillsApi';
|
||||
import type { WorkflowSummary } from '../../../services/api/workflowsApi';
|
||||
import WorkflowsTab from '../WorkflowsTab';
|
||||
|
||||
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (k: string) => k }) }));
|
||||
@@ -18,7 +18,7 @@ vi.mock('../../skills/CreateSkillModal', () => ({
|
||||
onCreated,
|
||||
onClose,
|
||||
}: {
|
||||
onCreated: (wf: SkillSummary) => void;
|
||||
onCreated: (wf: WorkflowSummary) => void;
|
||||
onClose: () => void;
|
||||
}) => (
|
||||
<div data-testid="create-modal-stub">
|
||||
@@ -62,7 +62,7 @@ vi.mock('../../skills/UninstallSkillConfirmDialog', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
const seeded = (overrides: Partial<SkillSummary>): SkillSummary => ({
|
||||
const seeded = (overrides: Partial<WorkflowSummary>): WorkflowSummary => ({
|
||||
id: 'wf-1',
|
||||
name: 'WF 1',
|
||||
description: 'A workflow.',
|
||||
@@ -82,22 +82,25 @@ const seeded = (overrides: Partial<SkillSummary>): SkillSummary => ({
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const listSkills = vi.fn();
|
||||
vi.mock('../../../services/api/skillsApi', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../services/api/skillsApi')>(
|
||||
'../../../services/api/skillsApi'
|
||||
const listWorkflows = vi.fn();
|
||||
vi.mock('../../../services/api/workflowsApi', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../../services/api/workflowsApi')>(
|
||||
'../../../services/api/workflowsApi'
|
||||
);
|
||||
return { ...actual, skillsApi: { ...actual.skillsApi, listSkills: () => listSkills() } };
|
||||
return {
|
||||
...actual,
|
||||
workflowsApi: { ...actual.workflowsApi, listWorkflows: () => listWorkflows() },
|
||||
};
|
||||
});
|
||||
|
||||
describe('WorkflowsTab', () => {
|
||||
beforeEach(() => {
|
||||
navigate.mockReset();
|
||||
listSkills.mockReset();
|
||||
listWorkflows.mockReset();
|
||||
});
|
||||
|
||||
it('lists workflows from skillsApi with the create entry point', async () => {
|
||||
listSkills.mockResolvedValue([
|
||||
it('lists workflows from workflowsApi with the create entry point', async () => {
|
||||
listWorkflows.mockResolvedValue([
|
||||
seeded({ id: 'user-wf', name: 'User WF', scope: 'user' }),
|
||||
seeded({ id: 'project-wf', name: 'Project WF', scope: 'project' }),
|
||||
]);
|
||||
@@ -115,7 +118,7 @@ describe('WorkflowsTab', () => {
|
||||
});
|
||||
|
||||
it('navigates to the locked runner page when a card is opened', async () => {
|
||||
listSkills.mockResolvedValue([seeded({ id: 'user-wf', name: 'User WF', scope: 'user' })]);
|
||||
listWorkflows.mockResolvedValue([seeded({ id: 'user-wf', name: 'User WF', scope: 'user' })]);
|
||||
render(<WorkflowsTab />);
|
||||
await waitFor(() => expect(screen.getByTestId('workflow-open-user-wf')).toBeInTheDocument());
|
||||
|
||||
@@ -124,7 +127,7 @@ describe('WorkflowsTab', () => {
|
||||
});
|
||||
|
||||
it('opens create and lands on the new workflow runner after create', async () => {
|
||||
listSkills.mockResolvedValue([]);
|
||||
listWorkflows.mockResolvedValue([]);
|
||||
render(<WorkflowsTab />);
|
||||
await waitFor(() => expect(screen.getByTestId('workflows-create-btn')).toBeInTheDocument());
|
||||
|
||||
@@ -133,8 +136,8 @@ describe('WorkflowsTab', () => {
|
||||
expect(navigate).toHaveBeenCalledWith('/workflows/run?workflow=new-wf&lock=1');
|
||||
});
|
||||
|
||||
it('shows an error panel with retry when listSkills fails (not the empty state)', async () => {
|
||||
listSkills.mockRejectedValueOnce(new Error('backend down'));
|
||||
it('shows an error panel with retry when listWorkflows fails (not the empty state)', async () => {
|
||||
listWorkflows.mockRejectedValueOnce(new Error('backend down'));
|
||||
render(<WorkflowsTab />);
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId('workflows-load-error')).toBeInTheDocument());
|
||||
@@ -142,13 +145,15 @@ describe('WorkflowsTab', () => {
|
||||
expect(screen.queryByText('workflows.empty.title')).not.toBeInTheDocument();
|
||||
|
||||
// Retry re-fetches and renders the list.
|
||||
listSkills.mockResolvedValueOnce([seeded({ id: 'user-wf', name: 'User WF', scope: 'user' })]);
|
||||
listWorkflows.mockResolvedValueOnce([
|
||||
seeded({ id: 'user-wf', name: 'User WF', scope: 'user' }),
|
||||
]);
|
||||
fireEvent.click(screen.getByText('common.retry'));
|
||||
await waitFor(() => expect(screen.getByText('User WF')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('removes the workflow from the list after uninstall, keyed by id', async () => {
|
||||
listSkills.mockResolvedValue([seeded({ id: 'user-wf', name: 'User WF', scope: 'user' })]);
|
||||
listWorkflows.mockResolvedValue([seeded({ id: 'user-wf', name: 'User WF', scope: 'user' })]);
|
||||
render(<WorkflowsTab />);
|
||||
await waitFor(() => expect(screen.getByTestId('workflow-card-user-wf')).toBeInTheDocument());
|
||||
|
||||
@@ -162,7 +167,7 @@ describe('WorkflowsTab', () => {
|
||||
});
|
||||
|
||||
it('renders the empty state when no workflows are installed', async () => {
|
||||
listSkills.mockResolvedValue([]);
|
||||
listWorkflows.mockResolvedValue([]);
|
||||
render(<WorkflowsTab />);
|
||||
await waitFor(() => expect(screen.getByText('workflows.empty.title')).toBeInTheDocument());
|
||||
expect(screen.queryByTestId('workflows-list')).not.toBeInTheDocument();
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import { type SkillSummary } from '../../services/api/skillsApi';
|
||||
import { type WorkflowSummary } from '../../services/api/workflowsApi';
|
||||
import CreateWorkflowForm from './CreateWorkflowForm';
|
||||
|
||||
const log = debug('skills:create-modal');
|
||||
@@ -29,9 +29,9 @@ const CREATE_FORM_ID = 'create-skill-modal-form';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onCreated: (skill: SkillSummary) => void;
|
||||
onCreated: (skill: WorkflowSummary) => void;
|
||||
/** When set, the modal edits this workflow instead of creating a new one. */
|
||||
editing?: SkillSummary;
|
||||
editing?: WorkflowSummary;
|
||||
}
|
||||
|
||||
export default function CreateSkillModal({ onClose, onCreated, editing }: Props) {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* - All form fields (name, description, scope, license, author,
|
||||
* tags, allowed-tools).
|
||||
* - Slug preview + validation (name and description required).
|
||||
* - Submit handler that calls `skillsApi.createSkill` and surfaces
|
||||
* - Submit handler that calls `workflowsApi.createWorkflow` and surfaces
|
||||
* the result via `onCreated(skill)` / error string via inline
|
||||
* `<div role="alert">`.
|
||||
*
|
||||
@@ -41,12 +41,12 @@ import {
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import {
|
||||
type CreateSkillInput,
|
||||
type CreateSkillInputDef,
|
||||
type SkillScope,
|
||||
type SkillSummary,
|
||||
skillsApi,
|
||||
} from '../../services/api/skillsApi';
|
||||
type CreateWorkflowInput,
|
||||
type CreateWorkflowInputDef,
|
||||
type WorkflowScope,
|
||||
type WorkflowSummary,
|
||||
workflowsApi,
|
||||
} from '../../services/api/workflowsApi';
|
||||
|
||||
/** Mirrors `SkillCreateInputDef` shape used as wire payload, with one
|
||||
* extra `localId` for stable React keys across re-renders (the wire
|
||||
@@ -80,7 +80,7 @@ const log = debug('skills:create-form');
|
||||
export interface CreateSkillFormHandle {
|
||||
/** True iff name+description are present and no submit is in flight. */
|
||||
isValid: () => boolean;
|
||||
/** True while skillsApi.createSkill is in flight. */
|
||||
/** True while workflowsApi.createWorkflow is in flight. */
|
||||
isSubmitting: () => boolean;
|
||||
/** Imperatively trigger submit. Resolves once the round-trip finishes. */
|
||||
submit: () => Promise<void>;
|
||||
@@ -94,7 +94,7 @@ export interface CreateSkillFormProps {
|
||||
*/
|
||||
formId: string;
|
||||
/** Called with the freshly-created skill on success. */
|
||||
onCreated: (skill: SkillSummary) => void;
|
||||
onCreated: (skill: WorkflowSummary) => void;
|
||||
/**
|
||||
* Called whenever validity / submission state changes so the
|
||||
* wrapper can sync its submit button's disabled state without
|
||||
@@ -110,7 +110,7 @@ export interface CreateSkillFormProps {
|
||||
* allowed-tools (not exposed as editable fields) are carried through so
|
||||
* they're preserved on save.
|
||||
*/
|
||||
editing?: SkillSummary;
|
||||
editing?: WorkflowSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,7 +156,7 @@ const CreateWorkflowForm = forwardRef<CreateSkillFormHandle, CreateSkillFormProp
|
||||
// dashboard-created skills. Project-scoped skills are still
|
||||
// creatable by editing the workspace skill files directly. The
|
||||
// backend payload still requires `scope` so we hold it as a const.
|
||||
const scope: SkillScope = 'user';
|
||||
const scope: WorkflowScope = 'user';
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [inputs, setInputs] = useState<InputRow[]>([]);
|
||||
@@ -217,7 +217,7 @@ const CreateWorkflowForm = forwardRef<CreateSkillFormHandle, CreateSkillFormProp
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const desc = await skillsApi.describeSkill(editing.id);
|
||||
const desc = await workflowsApi.describeWorkflow(editing.id);
|
||||
if (cancelled) return;
|
||||
if (desc.when_to_use) setWhenToUse(desc.when_to_use);
|
||||
setInputs(
|
||||
@@ -243,7 +243,7 @@ const CreateWorkflowForm = forwardRef<CreateSkillFormHandle, CreateSkillFormProp
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (!formValid) return;
|
||||
const payload: CreateSkillInput = {
|
||||
const payload: CreateWorkflowInput = {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
scope,
|
||||
@@ -264,8 +264,8 @@ const CreateWorkflowForm = forwardRef<CreateSkillFormHandle, CreateSkillFormProp
|
||||
}
|
||||
}
|
||||
if (inputs.length > 0) {
|
||||
payload.inputs = inputs.map<CreateSkillInputDef>((r) => {
|
||||
const def: CreateSkillInputDef = {
|
||||
payload.inputs = inputs.map<CreateWorkflowInputDef>((r) => {
|
||||
const def: CreateWorkflowInputDef = {
|
||||
name: r.name.trim(),
|
||||
required: r.required,
|
||||
};
|
||||
@@ -282,8 +282,8 @@ const CreateWorkflowForm = forwardRef<CreateSkillFormHandle, CreateSkillFormProp
|
||||
setError(null);
|
||||
try {
|
||||
const saved = editing
|
||||
? await skillsApi.updateSkill(payload)
|
||||
: await skillsApi.createSkill(payload);
|
||||
? await workflowsApi.updateWorkflow(payload)
|
||||
: await workflowsApi.createWorkflow(payload);
|
||||
log('submit-ok id=%s edit=%s', saved.id, isEdit);
|
||||
onCreated(saved);
|
||||
} catch (err) {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* optional timeout in seconds.
|
||||
* - While the RPC is in flight we show a "Fetching…" indicator and
|
||||
* disable close / backdrop-dismiss so the caller sees the outcome.
|
||||
* - On success we surface the list of `newSkills` (ids that appeared
|
||||
* - On success we surface the list of `newWorkflows` (ids that appeared
|
||||
* post-install) plus captured fetch log / parse-warning panes, then
|
||||
* hand the result back to the caller via `onInstalled` so the
|
||||
* parent can refetch the list and auto-select the row.
|
||||
@@ -34,10 +34,10 @@ import debug from 'debug';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import {
|
||||
skillsApi,
|
||||
type InstallSkillFromUrlResult,
|
||||
type SkillSummary,
|
||||
} from '../../services/api/skillsApi';
|
||||
workflowsApi,
|
||||
type InstallWorkflowFromUrlResult,
|
||||
type WorkflowSummary,
|
||||
} from '../../services/api/workflowsApi';
|
||||
import { trackEvent } from '../../services/analytics';
|
||||
|
||||
const log = debug('skills:install-dialog');
|
||||
@@ -47,17 +47,17 @@ interface Props {
|
||||
/**
|
||||
* Fires when the backend reports the install succeeded. The parent is
|
||||
* responsible for refetching the skills list (the RPC already returns
|
||||
* the freshly-added ids, but the caller may want full `SkillSummary`
|
||||
* rows). `newSkills` lists ids that appeared post-install.
|
||||
* the freshly-added ids, but the caller may want full `WorkflowSummary`
|
||||
* rows). `newWorkflows` lists ids that appeared post-install.
|
||||
*/
|
||||
onInstalled: (result: InstallSkillFromUrlResult) => void;
|
||||
onInstalled: (result: InstallWorkflowFromUrlResult) => void;
|
||||
/**
|
||||
* Optional: used only for symmetry with `CreateSkillModal`. When
|
||||
* supplied and the caller wants to auto-open the detail drawer for a
|
||||
* specific skill, they can resolve the full `SkillSummary` and call
|
||||
* specific skill, they can resolve the full `WorkflowSummary` and call
|
||||
* this directly. Not invoked by the dialog itself.
|
||||
*/
|
||||
onSelectSkill?: (skill: SkillSummary) => void;
|
||||
onSelectSkill?: (skill: WorkflowSummary) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,7 +151,7 @@ export default function InstallSkillDialog({ onClose, onInstalled }: Props) {
|
||||
const [timeoutSecs, setTimeoutSecs] = useState<string>('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<InstallSkillFromUrlResult | null>(null);
|
||||
const [result, setResult] = useState<InstallWorkflowFromUrlResult | null>(null);
|
||||
|
||||
const firstFieldRef = useRef<HTMLInputElement | null>(null);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
@@ -201,14 +201,14 @@ export default function InstallSkillDialog({ onClose, onInstalled }: Props) {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const installed = await skillsApi.installSkillFromUrl(payload);
|
||||
const installed = await workflowsApi.installWorkflowFromUrl(payload);
|
||||
log(
|
||||
'submit-ok new=%d stdout=%d stderr=%d',
|
||||
installed.newSkills.length,
|
||||
installed.newWorkflows.length,
|
||||
installed.stdout.length,
|
||||
installed.stderr.length
|
||||
);
|
||||
for (const skillId of installed.newSkills) {
|
||||
for (const skillId of installed.newWorkflows) {
|
||||
trackEvent('skill_install', { skill_id: skillId });
|
||||
}
|
||||
setResult(installed);
|
||||
@@ -383,16 +383,16 @@ export default function InstallSkillDialog({ onClose, onInstalled }: Props) {
|
||||
<div>
|
||||
<p className="font-semibold">{t('skills.install.installComplete')}</p>
|
||||
<p className="mt-1">
|
||||
{result.newSkills.length > 0
|
||||
{result.newWorkflows.length > 0
|
||||
? t('skills.install.successDiscovered').replace(
|
||||
'{count}',
|
||||
String(result.newSkills.length)
|
||||
String(result.newWorkflows.length)
|
||||
)
|
||||
: t('skills.install.successNoNewIds')}
|
||||
</p>
|
||||
{result.newSkills.length > 0 ? (
|
||||
{result.newWorkflows.length > 0 ? (
|
||||
<ul className="mt-1 list-disc pl-5 font-mono">
|
||||
{result.newSkills.map(id => (
|
||||
{result.newWorkflows.map(id => (
|
||||
<li key={id}>{id}</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
type CatalogEntry,
|
||||
} from '../../services/api/skillRegistryApi';
|
||||
import {
|
||||
skillsApi,
|
||||
type InstallSkillFromUrlResult,
|
||||
type SkillSummary,
|
||||
} from '../../services/api/skillsApi';
|
||||
workflowsApi,
|
||||
type InstallWorkflowFromUrlResult,
|
||||
type WorkflowSummary,
|
||||
} from '../../services/api/workflowsApi';
|
||||
import EmptyStateCard from '../EmptyStateCard';
|
||||
import InstallSkillDialog from './InstallSkillDialog';
|
||||
import UninstallSkillConfirmDialog from './UninstallSkillConfirmDialog';
|
||||
@@ -67,7 +67,7 @@ function SourceBadge({ sourceId }: { sourceId: string }) {
|
||||
}
|
||||
|
||||
interface SkillTileProps {
|
||||
skill: SkillSummary;
|
||||
skill: WorkflowSummary;
|
||||
onUninstall: () => void;
|
||||
}
|
||||
|
||||
@@ -267,7 +267,7 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
|
||||
const { t } = useT();
|
||||
const [view, setView] = useState<ExplorerView>('registry');
|
||||
|
||||
const [skills, setSkills] = useState<SkillSummary[]>([]);
|
||||
const [skills, setSkills] = useState<WorkflowSummary[]>([]);
|
||||
const [skillsLoading, setSkillsLoading] = useState(true);
|
||||
const [skillsError, setSkillsError] = useState<string | null>(null);
|
||||
|
||||
@@ -279,14 +279,14 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [formatFilter, setFormatFilter] = useState<string>('all');
|
||||
const [installDialogOpen, setInstallDialogOpen] = useState(false);
|
||||
const [uninstallTarget, setUninstallTarget] = useState<SkillSummary | null>(null);
|
||||
const [uninstallTarget, setUninstallTarget] = useState<WorkflowSummary | null>(null);
|
||||
|
||||
const fetchSkills = useCallback(async () => {
|
||||
log('fetchSkills: start');
|
||||
setSkillsLoading(true);
|
||||
setSkillsError(null);
|
||||
try {
|
||||
const result = await skillsApi.listSkills();
|
||||
const result = await workflowsApi.listWorkflows();
|
||||
log('fetchSkills: count=%d', result.length);
|
||||
setSkills(result);
|
||||
} catch (err) {
|
||||
@@ -368,16 +368,16 @@ export default function SkillsExplorerTab({ onToast }: SkillsExplorerTabProps) {
|
||||
}, [catalog]);
|
||||
|
||||
const handleInstalled = useCallback(
|
||||
(result: InstallSkillFromUrlResult) => {
|
||||
log('handleInstalled: newSkills=%d', result.newSkills.length);
|
||||
(result: InstallWorkflowFromUrlResult) => {
|
||||
log('handleInstalled: newSkills=%d', result.newWorkflows.length);
|
||||
void fetchSkills();
|
||||
if (result.newSkills.length > 0) {
|
||||
if (result.newWorkflows.length > 0) {
|
||||
onToast?.({
|
||||
type: 'success',
|
||||
title: t('skills.install.installComplete'),
|
||||
message: t('skills.install.successDiscovered').replace(
|
||||
'{count}',
|
||||
String(result.newSkills.length)
|
||||
String(result.newWorkflows.length)
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* ---------------------------
|
||||
*
|
||||
* Small centered confirm modal for destructive uninstall of a user-scope
|
||||
* SKILL.md skill. Wraps `skillsApi.uninstallSkill` which calls
|
||||
* SKILL.md skill. Wraps `workflowsApi.uninstallWorkflow` which calls
|
||||
* `openhuman.workflows_uninstall` on the Rust side — that RPC only accepts
|
||||
* user-scope installs (`~/.openhuman/skills/<name>/`) and refuses project
|
||||
* and legacy scopes. The card that opens this dialog is responsible for
|
||||
@@ -29,23 +29,23 @@ import debug from 'debug';
|
||||
|
||||
import { useT } from '../../lib/i18n/I18nContext';
|
||||
import {
|
||||
skillsApi,
|
||||
type SkillSummary,
|
||||
type UninstallSkillResult,
|
||||
} from '../../services/api/skillsApi';
|
||||
workflowsApi,
|
||||
type WorkflowSummary,
|
||||
type UninstallWorkflowResult,
|
||||
} from '../../services/api/workflowsApi';
|
||||
import { trackEvent } from '../../services/analytics';
|
||||
|
||||
const log = debug('skills:uninstall-dialog');
|
||||
|
||||
interface Props {
|
||||
skill: SkillSummary;
|
||||
skill: WorkflowSummary;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* Fires when the backend reports the uninstall succeeded. Parent is
|
||||
* responsible for refetching the skills list and closing any detail
|
||||
* panels that were showing this skill.
|
||||
*/
|
||||
onUninstalled: (result: UninstallSkillResult) => void;
|
||||
onUninstalled: (result: UninstallWorkflowResult) => void;
|
||||
}
|
||||
|
||||
export default function UninstallSkillConfirmDialog({ skill, onClose, onUninstalled }: Props) {
|
||||
@@ -82,7 +82,7 @@ export default function UninstallSkillConfirmDialog({ skill, onClose, onUninstal
|
||||
// `skill.id` is the on-disk slug (directory under ~/.openhuman/skills/).
|
||||
// `skill.name` is the frontmatter display name and may diverge from the
|
||||
// slug — the backend resolves by slug, so pass `id`.
|
||||
const result = await skillsApi.uninstallSkill(skill.id);
|
||||
const result = await workflowsApi.uninstallWorkflow(skill.id);
|
||||
log('confirm: done removedPath=%s', result.removedPath);
|
||||
trackEvent('skill_uninstall', { skill_id: skill.id });
|
||||
onUninstalled(result);
|
||||
|
||||
@@ -21,11 +21,11 @@ import { SCHEDULE_PRESETS } from '../../lib/cron/schedulePresets';
|
||||
import {
|
||||
type RunLogSlice,
|
||||
type ScannedRun,
|
||||
type SkillDescription,
|
||||
type SkillRunStarted,
|
||||
type SkillSummary,
|
||||
skillsApi,
|
||||
} from '../../services/api/skillsApi';
|
||||
type WorkflowDescription,
|
||||
type WorkflowRunStarted,
|
||||
type WorkflowSummary,
|
||||
workflowsApi,
|
||||
} from '../../services/api/workflowsApi';
|
||||
import {
|
||||
type CoreCronJob,
|
||||
type CoreCronRun,
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
import CreateSkillModal from './CreateSkillModal';
|
||||
import BranchPicker from './inputs/BranchPicker';
|
||||
import RepoPicker from './inputs/RepoPicker';
|
||||
import { isGithubGateFailure, parseSkillRunError } from './preflightGate';
|
||||
import { isGithubGateFailure, parseWorkflowRunError } from './preflightGate';
|
||||
import ScheduledCronCard from './ScheduledCronCard';
|
||||
import SmartIssuePicker from './SmartIssuePicker';
|
||||
|
||||
@@ -95,7 +95,7 @@ type InputValue = string | number | boolean;
|
||||
interface RunState {
|
||||
status: 'idle' | 'submitting' | 'started' | 'error';
|
||||
message?: string;
|
||||
result?: SkillRunStarted;
|
||||
result?: WorkflowRunStarted;
|
||||
}
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ export function parseScheduledInputs(
|
||||
/**
|
||||
* Default form value for an input based on its declared type. Strings/
|
||||
* integers default to empty (renders as placeholder); booleans to false.
|
||||
* `runSkill` later trims and drops empty optional fields before sending
|
||||
* `runWorkflow` later trims and drops empty optional fields before sending
|
||||
* them over the wire.
|
||||
*/
|
||||
function defaultForType(type: string): InputValue {
|
||||
@@ -175,13 +175,13 @@ function defaultForType(type: string): InputValue {
|
||||
}
|
||||
|
||||
/**
|
||||
* Project the form-state map back into the JSON inputs shape `skills_run`
|
||||
* Project the form-state map back into the JSON inputs shape `workflows_run`
|
||||
* expects: trim strings, coerce integer-typed fields to numbers, drop
|
||||
* empty optional fields entirely (so the backend sees them as "not
|
||||
* provided" rather than `""`).
|
||||
*/
|
||||
function buildInputsPayload(
|
||||
description: SkillDescription,
|
||||
description: WorkflowDescription,
|
||||
values: Record<string, InputValue>
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
@@ -225,7 +225,7 @@ export interface SkillsRunnerBodyProps {
|
||||
* the skill picker. Defaults to the Settings-panel description so
|
||||
* the original placement is unchanged. (Named `headerText` rather
|
||||
* than `description` to avoid shadowing the internal `description`
|
||||
* state that holds the resolved `SkillDescription` for the picked
|
||||
* state that holds the resolved `WorkflowDescription` for the picked
|
||||
* skill.)
|
||||
*/
|
||||
headerText?: string;
|
||||
@@ -241,7 +241,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
const { t } = useT();
|
||||
|
||||
// Skill catalog (loaded once on mount)
|
||||
const [skills, setSkills] = useState<SkillSummary[]>([]);
|
||||
const [skills, setSkills] = useState<WorkflowSummary[]>([]);
|
||||
const [skillsLoading, setSkillsLoading] = useState(false);
|
||||
const [skillsError, setSkillsError] = useState<string | null>(null);
|
||||
// Edit-this-workflow modal (only meaningful when locked to a workflow).
|
||||
@@ -263,7 +263,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
!!searchParams.get('workflow') &&
|
||||
(searchParams.get('lock') === '1' || searchParams.get('focus') === 'schedule');
|
||||
const selectedWorkflow = skills.find(s => s.id === selectedSkillId);
|
||||
const [description, setDescription] = useState<SkillDescription | null>(null);
|
||||
const [description, setDescription] = useState<WorkflowDescription | null>(null);
|
||||
const [descLoading, setDescLoading] = useState(false);
|
||||
const [descError, setDescError] = useState<string | null>(null);
|
||||
|
||||
@@ -449,13 +449,13 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchParams, selectedSkillId]);
|
||||
|
||||
// ── Initial load: skills_list ──────────────────────────────────────
|
||||
// ── Initial load: workflows_list ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setSkillsLoading(true);
|
||||
setSkillsError(null);
|
||||
skillsApi
|
||||
.listSkills()
|
||||
workflowsApi
|
||||
.listWorkflows()
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
// Hide the codegraph-smoke skill — internal smoke-test only.
|
||||
@@ -466,7 +466,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('listSkills error: %s', msg);
|
||||
log('listWorkflows error: %s', msg);
|
||||
setSkillsError(msg);
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -477,7 +477,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── On selection: skills_describe ──────────────────────────────────
|
||||
// ── On selection: workflows_describe ──────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!selectedSkillId) {
|
||||
setDescription(null);
|
||||
@@ -488,8 +488,8 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
setDescLoading(true);
|
||||
setDescError(null);
|
||||
setRun({ status: 'idle' });
|
||||
skillsApi
|
||||
.describeSkill(selectedSkillId)
|
||||
workflowsApi
|
||||
.describeWorkflow(selectedSkillId)
|
||||
.then((desc) => {
|
||||
if (cancelled) return;
|
||||
setDescription(desc);
|
||||
@@ -504,7 +504,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
.catch((err: unknown) => {
|
||||
if (cancelled) return;
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('describeSkill error: %s', msg);
|
||||
log('describeWorkflow error: %s', msg);
|
||||
setDescError(msg);
|
||||
})
|
||||
.finally(() => {
|
||||
@@ -540,7 +540,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
const handleStopRun = useCallback(async (runId: string) => {
|
||||
log('stop run runId=%s', runId);
|
||||
try {
|
||||
await skillsApi.cancelRun(runId);
|
||||
await workflowsApi.cancelRun(runId);
|
||||
} catch (err) {
|
||||
log('cancelRun error: %s', err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
@@ -552,7 +552,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
// Re-entry guard: a second click before React applies the disabled state
|
||||
// would otherwise fire `workflows_run` twice and spawn two real runs.
|
||||
if (runSubmitGuardRef.current) {
|
||||
log('runSkill: ignoring re-entrant click while a run is starting');
|
||||
log('runWorkflow: ignoring re-entrant click while a run is starting');
|
||||
return;
|
||||
}
|
||||
if (missingRequired.length > 0) {
|
||||
@@ -567,8 +567,8 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
setRun({ status: 'submitting' });
|
||||
try {
|
||||
const inputs = buildInputsPayload(description, formValues);
|
||||
log('runSkill %s inputs=%o', description.id, inputs);
|
||||
const result = await skillsApi.runSkill(description.id, inputs);
|
||||
log('runWorkflow %s inputs=%o', description.id, inputs);
|
||||
const result = await workflowsApi.runWorkflow(description.id, inputs);
|
||||
setRun({ status: 'started', result });
|
||||
// Surface the new run in "Recent runs" without a manual refresh, and
|
||||
// hold the guard through a short cooldown so a second click can't spawn
|
||||
@@ -577,7 +577,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
releaseRunGuard(2500);
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log('runSkill error: %s', msg);
|
||||
log('runWorkflow error: %s', msg);
|
||||
setRun({ status: 'error', message: msg });
|
||||
releaseRunGuard(0); // allow immediate retry on failure
|
||||
}
|
||||
@@ -587,7 +587,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setRecentRunsLoading(true);
|
||||
skillsApi
|
||||
workflowsApi
|
||||
.recentRuns(selectedSkillId || undefined, 10)
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
@@ -701,7 +701,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
const slice: RunLogSlice = await skillsApi.readRunLog(runId, fromOffset);
|
||||
const slice: RunLogSlice = await workflowsApi.readRunLog(runId, fromOffset);
|
||||
if (cancelled) return;
|
||||
setViewer((prev) => {
|
||||
const prior = prev[runId]?.content ?? '';
|
||||
@@ -784,7 +784,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
);
|
||||
try {
|
||||
log('runJobNow: running %s directly with %o', selectedSkillId, inputs);
|
||||
await skillsApi.runSkill(selectedSkillId, inputs);
|
||||
await workflowsApi.runWorkflow(selectedSkillId, inputs);
|
||||
scheduleRecentRunsRefresh();
|
||||
releaseRunGuard(2500);
|
||||
} catch (err: unknown) {
|
||||
@@ -908,7 +908,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
// instead of a plain text input. Falls through to the type-based
|
||||
// string/integer/boolean handling for everything else.
|
||||
const renderField = (
|
||||
inp: SkillDescription['inputs'][number],
|
||||
inp: WorkflowDescription['inputs'][number],
|
||||
value: InputValue,
|
||||
onChange: (next: InputValue) => void
|
||||
) => {
|
||||
@@ -1065,7 +1065,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
)}
|
||||
{skillsError && (
|
||||
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
|
||||
{t('settings.skillsRunner.error.listSkills')} {skillsError}
|
||||
{t('settings.skillsRunner.error.listWorkflows')} {skillsError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -1199,7 +1199,7 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
// failed" pill above the body so the user knows
|
||||
// this isn't a generic crash — there's a concrete
|
||||
// remediation the body describes.
|
||||
const parsed = parseSkillRunError(run.message);
|
||||
const parsed = parseWorkflowRunError(run.message);
|
||||
const isGateFailure = isGithubGateFailure(parsed);
|
||||
return (
|
||||
<div
|
||||
@@ -1588,12 +1588,12 @@ export const WorkflowRunnerBody = ({ headerText, className }: SkillsRunnerBodyPr
|
||||
onClose={() => setEditOpen(false)}
|
||||
onCreated={() => {
|
||||
setEditOpen(false);
|
||||
void skillsApi
|
||||
.listSkills()
|
||||
void workflowsApi
|
||||
.listWorkflows()
|
||||
.then((list) => setSkills(list.filter((s) => s.id !== 'codegraph-smoke')))
|
||||
.catch(() => {});
|
||||
void skillsApi
|
||||
.describeSkill(selectedSkillId)
|
||||
void workflowsApi
|
||||
.describeWorkflow(selectedSkillId)
|
||||
.then(setDescription)
|
||||
.catch(() => {});
|
||||
}}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* - Escape key closes (but not while submitting).
|
||||
* - Backdrop click closes (but not while submitting).
|
||||
* - Submit is disabled when name or description is empty.
|
||||
* - Submit rekeys `allowedTools` → `'allowed-tools'` via skillsApi.createSkill.
|
||||
* - Submit rekeys `allowedTools` → `'allowed-tools'` via workflowsApi.createWorkflow.
|
||||
* - Submit calls `onCreated` with the returned skill.
|
||||
* - Submit failure surfaces an error banner and re-enables the button.
|
||||
* - Slug preview updates as the name changes.
|
||||
@@ -14,18 +14,18 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { SkillSummary } from '../../../services/api/skillsApi';
|
||||
import type { WorkflowSummary } from '../../../services/api/workflowsApi';
|
||||
import CreateSkillModal from '../CreateSkillModal';
|
||||
|
||||
vi.mock('../../../services/api/skillsApi', () => ({
|
||||
skillsApi: {
|
||||
createSkill: vi.fn(),
|
||||
updateSkill: vi.fn(),
|
||||
describeSkill: vi.fn().mockResolvedValue({ id: 'e', name: 'e', when_to_use: '', inputs: [] }),
|
||||
vi.mock('../../../services/api/workflowsApi', () => ({
|
||||
workflowsApi: {
|
||||
createWorkflow: vi.fn(),
|
||||
updateWorkflow: vi.fn(),
|
||||
describeWorkflow: vi.fn().mockResolvedValue({ id: 'e', name: 'e', when_to_use: '', inputs: [] }),
|
||||
},
|
||||
}));
|
||||
|
||||
function builtSkill(overrides: Partial<SkillSummary> = {}): SkillSummary {
|
||||
function builtSkill(overrides: Partial<WorkflowSummary> = {}): WorkflowSummary {
|
||||
return {
|
||||
id: 'my-skill',
|
||||
name: 'My Skill',
|
||||
@@ -49,8 +49,8 @@ function builtSkill(overrides: Partial<SkillSummary> = {}): SkillSummary {
|
||||
|
||||
describe('CreateSkillModal', () => {
|
||||
beforeEach(async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.createSkill).mockReset();
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.createWorkflow).mockReset();
|
||||
});
|
||||
|
||||
it('renders title and required fields', () => {
|
||||
@@ -94,13 +94,13 @@ describe('CreateSkillModal', () => {
|
||||
// form is now Name + Description + the `[[inputs]]` editor only — see
|
||||
// ScheduledCronCard / CreateWorkflowForm.tsx), so the inputs are no longer
|
||||
// collectable from the modal UI. The rekey itself still happens in
|
||||
// `skillsApi.createSkill` (services/api/skillsApi.ts → params build) and
|
||||
// is covered by the skillsApi unit tests; this test now just guards the
|
||||
// modal's submit-pipeline shape: name + description → createSkill →
|
||||
// `workflowsApi.createWorkflow` (services/api/workflowsApi.ts → params build) and
|
||||
// is covered by the workflowsApi unit tests; this test now just guards the
|
||||
// modal's submit-pipeline shape: name + description → createWorkflow →
|
||||
// onCreated.
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const created = builtSkill();
|
||||
vi.mocked(skillsApi.createSkill).mockResolvedValueOnce(created);
|
||||
vi.mocked(workflowsApi.createWorkflow).mockResolvedValueOnce(created);
|
||||
|
||||
const onCreated = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
@@ -114,7 +114,7 @@ describe('CreateSkillModal', () => {
|
||||
fireEvent.click(submit);
|
||||
});
|
||||
|
||||
expect(vi.mocked(skillsApi.createSkill)).toHaveBeenCalledWith(
|
||||
expect(vi.mocked(workflowsApi.createWorkflow)).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'My Skill',
|
||||
description: 'does stuff',
|
||||
@@ -125,8 +125,8 @@ describe('CreateSkillModal', () => {
|
||||
});
|
||||
|
||||
it('surfaces error and re-enables submit on failure', async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.createSkill).mockRejectedValueOnce(new Error('slug already exists'));
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.createWorkflow).mockRejectedValueOnce(new Error('slug already exists'));
|
||||
|
||||
render(<CreateSkillModal onClose={vi.fn()} onCreated={vi.fn()} />);
|
||||
fireEvent.change(screen.getByLabelText(/Name/), { target: { value: 'dup' } });
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* and the /skills/new page can rely on it.
|
||||
*
|
||||
* Covers:
|
||||
* - submit calls skillsApi.createSkill with the trimmed/normalised
|
||||
* - submit calls workflowsApi.createWorkflow with the trimmed/normalised
|
||||
* payload (CSVs split, optional fields omitted when empty).
|
||||
* - onStateChange is called with validity + submitting flags so
|
||||
* wrappers can sync their submit button's disabled state.
|
||||
@@ -20,16 +20,16 @@ const stableT = (key: string) => key;
|
||||
vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: stableT }) }));
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
createSkill: vi.fn(),
|
||||
updateSkill: vi.fn(),
|
||||
describeSkill: vi.fn(),
|
||||
createWorkflow: vi.fn(),
|
||||
updateWorkflow: vi.fn(),
|
||||
describeWorkflow: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/api/skillsApi', () => ({
|
||||
skillsApi: {
|
||||
createSkill: hoisted.createSkill,
|
||||
updateSkill: hoisted.updateSkill,
|
||||
describeSkill: hoisted.describeSkill,
|
||||
vi.mock('../../../services/api/workflowsApi', () => ({
|
||||
workflowsApi: {
|
||||
createWorkflow: hoisted.createWorkflow,
|
||||
updateWorkflow: hoisted.updateWorkflow,
|
||||
describeWorkflow: hoisted.describeWorkflow,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('previewSlug', () => {
|
||||
|
||||
describe('CreateWorkflowForm', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.createSkill.mockReset();
|
||||
hoisted.createWorkflow.mockReset();
|
||||
});
|
||||
|
||||
it('renders required fields and the slug preview updates as the name changes', () => {
|
||||
@@ -98,7 +98,7 @@ describe('CreateWorkflowForm', () => {
|
||||
// fields were dropped. Anyone needing project-scoped or
|
||||
// tagged skills edits the workspace SKILL.md directly.
|
||||
const created = { id: 'my-skill', name: 'My Skill', scope: 'user', legacy: false };
|
||||
hoisted.createSkill.mockResolvedValue(created);
|
||||
hoisted.createWorkflow.mockResolvedValue(created);
|
||||
const onCreated = vi.fn();
|
||||
|
||||
render(<CreateWorkflowForm formId={FORM_ID} onCreated={onCreated} />);
|
||||
@@ -116,7 +116,7 @@ describe('CreateWorkflowForm', () => {
|
||||
fireEvent.submit(document.getElementById(FORM_ID)!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hoisted.createSkill).toHaveBeenCalledWith({
|
||||
expect(hoisted.createWorkflow).toHaveBeenCalledWith({
|
||||
name: 'My Skill',
|
||||
description: 'Does the thing.',
|
||||
scope: 'user',
|
||||
@@ -126,7 +126,7 @@ describe('CreateWorkflowForm', () => {
|
||||
});
|
||||
|
||||
it('includes whenToUse in the payload when the trigger field is filled', async () => {
|
||||
hoisted.createSkill.mockResolvedValue({ id: 'wf', name: 'wf', scope: 'user', legacy: false });
|
||||
hoisted.createWorkflow.mockResolvedValue({ id: 'wf', name: 'wf', scope: 'user', legacy: false });
|
||||
render(<CreateWorkflowForm formId={FORM_ID} onCreated={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/skills.create.name/i), {
|
||||
@@ -141,7 +141,7 @@ describe('CreateWorkflowForm', () => {
|
||||
fireEvent.submit(document.getElementById(FORM_ID)!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(hoisted.createSkill).toHaveBeenCalledWith({
|
||||
expect(hoisted.createWorkflow).toHaveBeenCalledWith({
|
||||
name: 'Triage',
|
||||
description: 'Summarise the inbox.',
|
||||
scope: 'user',
|
||||
@@ -150,8 +150,8 @@ describe('CreateWorkflowForm', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces the Rust error message in an alert when createSkill rejects', async () => {
|
||||
hoisted.createSkill.mockRejectedValue(new Error('slug already exists'));
|
||||
it('surfaces the Rust error message in an alert when createWorkflow rejects', async () => {
|
||||
hoisted.createWorkflow.mockRejectedValue(new Error('slug already exists'));
|
||||
render(<CreateWorkflowForm formId={FORM_ID} onCreated={vi.fn()} />);
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/skills.create.name/i), {
|
||||
@@ -166,18 +166,18 @@ describe('CreateWorkflowForm', () => {
|
||||
expect(alert).toHaveTextContent('slug already exists');
|
||||
});
|
||||
|
||||
it('does not call createSkill if the form is invalid (no name)', async () => {
|
||||
it('does not call createWorkflow if the form is invalid (no name)', async () => {
|
||||
render(<CreateWorkflowForm formId={FORM_ID} onCreated={vi.fn()} />);
|
||||
fireEvent.submit(document.getElementById(FORM_ID)!);
|
||||
// Give the microtask queue a tick — should still be 0.
|
||||
await Promise.resolve();
|
||||
expect(hoisted.createSkill).not.toHaveBeenCalled();
|
||||
expect(hoisted.createWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Inputs editor ───────────────────────────────────────────────────
|
||||
// The form gained an optional [[inputs]] editor in 5d77839f. These
|
||||
// tests pin its contract end-to-end: the rows the user adds become the
|
||||
// `inputs` field on the createSkill payload, name validation blocks
|
||||
// `inputs` field on the createWorkflow payload, name validation blocks
|
||||
// submission, and removing a row drops it from the payload.
|
||||
|
||||
/** Fill name + description so the rest of the form is submittable. */
|
||||
@@ -199,23 +199,23 @@ describe('CreateWorkflowForm', () => {
|
||||
}
|
||||
|
||||
it('zero inputs submits a payload without an `inputs` field', async () => {
|
||||
hoisted.createSkill.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false });
|
||||
hoisted.createWorkflow.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false });
|
||||
render(<CreateWorkflowForm formId={FORM_ID} onCreated={vi.fn()} />);
|
||||
fillRequiredFields();
|
||||
fireEvent.submit(document.getElementById(FORM_ID)!);
|
||||
await waitFor(() => {
|
||||
expect(hoisted.createSkill).toHaveBeenCalledWith({
|
||||
expect(hoisted.createWorkflow).toHaveBeenCalledWith({
|
||||
name: 'My Skill',
|
||||
description: 'Does the thing.',
|
||||
scope: 'user',
|
||||
});
|
||||
});
|
||||
const payload = hoisted.createSkill.mock.calls[0]![0] as Record<string, unknown>;
|
||||
const payload = hoisted.createWorkflow.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(payload).not.toHaveProperty('inputs');
|
||||
});
|
||||
|
||||
it('one filled input row ships in the payload — additional inputs default to required: false', async () => {
|
||||
hoisted.createSkill.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false });
|
||||
hoisted.createWorkflow.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false });
|
||||
render(<CreateWorkflowForm formId={FORM_ID} onCreated={vi.fn()} />);
|
||||
fillRequiredFields();
|
||||
|
||||
@@ -227,7 +227,7 @@ describe('CreateWorkflowForm', () => {
|
||||
|
||||
fireEvent.submit(document.getElementById(FORM_ID)!);
|
||||
await waitFor(() => {
|
||||
expect(hoisted.createSkill).toHaveBeenCalledWith({
|
||||
expect(hoisted.createWorkflow).toHaveBeenCalledWith({
|
||||
name: 'My Skill',
|
||||
description: 'Does the thing.',
|
||||
scope: 'user',
|
||||
@@ -238,7 +238,7 @@ describe('CreateWorkflowForm', () => {
|
||||
});
|
||||
|
||||
it('blocks submission when an added input row has no description', async () => {
|
||||
hoisted.createSkill.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false });
|
||||
hoisted.createWorkflow.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false });
|
||||
render(<CreateWorkflowForm formId={FORM_ID} onCreated={vi.fn()} />);
|
||||
fillRequiredFields();
|
||||
|
||||
@@ -249,7 +249,7 @@ describe('CreateWorkflowForm', () => {
|
||||
fireEvent.change(nameInput, { target: { value: 'repo' } });
|
||||
fireEvent.submit(document.getElementById(FORM_ID)!);
|
||||
await Promise.resolve();
|
||||
expect(hoisted.createSkill).not.toHaveBeenCalled();
|
||||
expect(hoisted.createWorkflow).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(/descriptionError/i)).toBeInTheDocument();
|
||||
|
||||
// Fill the description → now it submits.
|
||||
@@ -257,12 +257,12 @@ describe('CreateWorkflowForm', () => {
|
||||
fireEvent.change(descInput, { target: { value: 'owner/name' } });
|
||||
fireEvent.submit(document.getElementById(FORM_ID)!);
|
||||
await waitFor(() => {
|
||||
expect(hoisted.createSkill).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.createWorkflow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('ticking Required flips the row to required: true', async () => {
|
||||
hoisted.createSkill.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false });
|
||||
hoisted.createWorkflow.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false });
|
||||
render(<CreateWorkflowForm formId={FORM_ID} onCreated={vi.fn()} />);
|
||||
fillRequiredFields();
|
||||
|
||||
@@ -277,7 +277,7 @@ describe('CreateWorkflowForm', () => {
|
||||
|
||||
fireEvent.submit(document.getElementById(FORM_ID)!);
|
||||
await waitFor(() => {
|
||||
expect(hoisted.createSkill).toHaveBeenCalledWith(
|
||||
expect(hoisted.createWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
inputs: [{ name: 'repo', required: true, description: 'owner/name' }],
|
||||
})
|
||||
@@ -286,7 +286,7 @@ describe('CreateWorkflowForm', () => {
|
||||
});
|
||||
|
||||
it('blocks submission while any row has an invalid name', async () => {
|
||||
hoisted.createSkill.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false });
|
||||
hoisted.createWorkflow.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false });
|
||||
render(<CreateWorkflowForm formId={FORM_ID} onCreated={vi.fn()} />);
|
||||
fillRequiredFields();
|
||||
|
||||
@@ -294,7 +294,7 @@ describe('CreateWorkflowForm', () => {
|
||||
// Add a row but leave the name empty — submission must be blocked.
|
||||
fireEvent.submit(document.getElementById(FORM_ID)!);
|
||||
await Promise.resolve();
|
||||
expect(hoisted.createSkill).not.toHaveBeenCalled();
|
||||
expect(hoisted.createWorkflow).not.toHaveBeenCalled();
|
||||
|
||||
// Fill the name with an invalid character (leading digit).
|
||||
const row = lastRow();
|
||||
@@ -302,14 +302,14 @@ describe('CreateWorkflowForm', () => {
|
||||
fireEvent.change(nameInput, { target: { value: '2repo' } });
|
||||
fireEvent.submit(document.getElementById(FORM_ID)!);
|
||||
await Promise.resolve();
|
||||
expect(hoisted.createSkill).not.toHaveBeenCalled();
|
||||
expect(hoisted.createWorkflow).not.toHaveBeenCalled();
|
||||
|
||||
// Inline error visible.
|
||||
expect(screen.getByText(/nameError/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('remove row drops it from the payload — submission then succeeds', async () => {
|
||||
hoisted.createSkill.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false });
|
||||
hoisted.createWorkflow.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false });
|
||||
render(<CreateWorkflowForm formId={FORM_ID} onCreated={vi.fn()} />);
|
||||
fillRequiredFields();
|
||||
|
||||
@@ -324,14 +324,14 @@ describe('CreateWorkflowForm', () => {
|
||||
// `inputs` field at all (back to the no-inputs payload shape).
|
||||
fireEvent.submit(document.getElementById(FORM_ID)!);
|
||||
await waitFor(() => {
|
||||
expect(hoisted.createSkill).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.createWorkflow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
const payload = hoisted.createSkill.mock.calls[0]![0] as Record<string, unknown>;
|
||||
const payload = hoisted.createWorkflow.mock.calls[0]![0] as Record<string, unknown>;
|
||||
expect(payload).not.toHaveProperty('inputs');
|
||||
});
|
||||
|
||||
it('integer + required=false (the default) carry through the type + required flags', async () => {
|
||||
hoisted.createSkill.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false });
|
||||
hoisted.createWorkflow.mockResolvedValue({ id: 'x', name: 'x', scope: 'user', legacy: false });
|
||||
render(<CreateWorkflowForm formId={FORM_ID} onCreated={vi.fn()} />);
|
||||
fillRequiredFields();
|
||||
|
||||
@@ -347,7 +347,7 @@ describe('CreateWorkflowForm', () => {
|
||||
|
||||
fireEvent.submit(document.getElementById(FORM_ID)!);
|
||||
await waitFor(() => {
|
||||
expect(hoisted.createSkill).toHaveBeenCalledWith(
|
||||
expect(hoisted.createWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
inputs: [
|
||||
{ name: 'issue', required: false, type: 'integer', description: 'Issue number to work on' },
|
||||
@@ -360,9 +360,9 @@ describe('CreateWorkflowForm', () => {
|
||||
|
||||
describe('CreateWorkflowForm — edit mode', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.createSkill.mockReset();
|
||||
hoisted.updateSkill.mockReset();
|
||||
hoisted.describeSkill.mockReset();
|
||||
hoisted.createWorkflow.mockReset();
|
||||
hoisted.updateWorkflow.mockReset();
|
||||
hoisted.describeWorkflow.mockReset();
|
||||
});
|
||||
|
||||
const editingSummary = {
|
||||
@@ -384,14 +384,14 @@ describe('CreateWorkflowForm — edit mode', () => {
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
it('prefills from the summary + describe, then submits via updateSkill', async () => {
|
||||
hoisted.describeSkill.mockResolvedValue({
|
||||
it('prefills from the summary + describe, then submits via updateWorkflow', async () => {
|
||||
hoisted.describeWorkflow.mockResolvedValue({
|
||||
id: 'wf-edit',
|
||||
name: 'wf-edit',
|
||||
when_to_use: 'edit trigger',
|
||||
inputs: [{ name: 'repo', type: 'string', required: true, description: 'r' }],
|
||||
});
|
||||
hoisted.updateSkill.mockResolvedValue({
|
||||
hoisted.updateWorkflow.mockResolvedValue({
|
||||
id: 'wf-edit',
|
||||
name: 'wf-edit',
|
||||
scope: 'user',
|
||||
@@ -402,13 +402,13 @@ describe('CreateWorkflowForm — edit mode', () => {
|
||||
render(<CreateWorkflowForm formId={FORM_ID} editing={editingSummary} onCreated={onCreated} />);
|
||||
|
||||
// Prefill: describe is fetched and name is populated from the summary.
|
||||
await waitFor(() => expect(hoisted.describeSkill).toHaveBeenCalledWith('wf-edit'));
|
||||
await waitFor(() => expect(hoisted.describeWorkflow).toHaveBeenCalledWith('wf-edit'));
|
||||
const nameInput = screen.getByLabelText(/skills.create.name/i) as HTMLInputElement;
|
||||
await waitFor(() => expect(nameInput.value).toBe('wf-edit'));
|
||||
|
||||
fireEvent.submit(document.getElementById(FORM_ID)!);
|
||||
await waitFor(() =>
|
||||
expect(hoisted.updateSkill).toHaveBeenCalledWith(
|
||||
expect(hoisted.updateWorkflow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
name: 'wf-edit',
|
||||
whenToUse: 'edit trigger',
|
||||
@@ -418,7 +418,7 @@ describe('CreateWorkflowForm — edit mode', () => {
|
||||
})
|
||||
)
|
||||
);
|
||||
expect(hoisted.createSkill).not.toHaveBeenCalled();
|
||||
expect(hoisted.createWorkflow).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(onCreated).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
* - Submit disabled until a well-formed https URL is entered.
|
||||
* - Shows inline error for non-https URLs.
|
||||
* - Rejects timeout outside 1–600.
|
||||
* - Submit forwards timeoutSecs to skillsApi.installSkillFromUrl.
|
||||
* - Success panel renders newSkills list + calls onInstalled.
|
||||
* - Submit forwards timeoutSecs to workflowsApi.installWorkflowFromUrl.
|
||||
* - Success panel renders newWorkflows list + calls onInstalled.
|
||||
* - Error panel categorizes known prefixes and shows the raw error in
|
||||
* a details expander; unknown errors fall back to a generic title.
|
||||
*/
|
||||
@@ -16,16 +16,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import InstallSkillDialog from '../InstallSkillDialog';
|
||||
|
||||
vi.mock('../../../services/api/skillsApi', () => ({
|
||||
skillsApi: {
|
||||
installSkillFromUrl: vi.fn(),
|
||||
vi.mock('../../../services/api/workflowsApi', () => ({
|
||||
workflowsApi: {
|
||||
installWorkflowFromUrl: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('InstallSkillDialog', () => {
|
||||
beforeEach(async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.installSkillFromUrl).mockReset();
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.installWorkflowFromUrl).mockReset();
|
||||
});
|
||||
|
||||
it('renders title and URL input', () => {
|
||||
@@ -72,13 +72,13 @@ describe('InstallSkillDialog', () => {
|
||||
expect(submit.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('forwards timeoutSecs to skillsApi and fires onInstalled on success', async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.installSkillFromUrl).mockResolvedValueOnce({
|
||||
it('forwards timeoutSecs to workflowsApi and fires onInstalled on success', async () => {
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.installWorkflowFromUrl).mockResolvedValueOnce({
|
||||
url: 'https://raw.githubusercontent.com/owner/repo/main/SKILL.md',
|
||||
stdout: 'added my-skill',
|
||||
stderr: '',
|
||||
newSkills: ['my-skill'],
|
||||
newWorkflows: ['my-skill'],
|
||||
});
|
||||
|
||||
const onInstalled = vi.fn();
|
||||
@@ -93,7 +93,7 @@ describe('InstallSkillDialog', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Install/ }));
|
||||
});
|
||||
|
||||
expect(vi.mocked(skillsApi.installSkillFromUrl)).toHaveBeenCalledWith({
|
||||
expect(vi.mocked(workflowsApi.installWorkflowFromUrl)).toHaveBeenCalledWith({
|
||||
url: 'https://raw.githubusercontent.com/owner/repo/main/SKILL.md',
|
||||
timeoutSecs: 120,
|
||||
});
|
||||
@@ -102,17 +102,17 @@ describe('InstallSkillDialog', () => {
|
||||
});
|
||||
expect(screen.getByText('my-skill')).toBeInTheDocument();
|
||||
expect(onInstalled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ newSkills: ['my-skill'] })
|
||||
expect.objectContaining({ newWorkflows: ['my-skill'] })
|
||||
);
|
||||
});
|
||||
|
||||
it('omits timeoutSecs when field is blank', async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.installSkillFromUrl).mockResolvedValueOnce({
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.installWorkflowFromUrl).mockResolvedValueOnce({
|
||||
url: 'https://raw.githubusercontent.com/owner/repo/main/SKILL.md',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
newSkills: [],
|
||||
newWorkflows: [],
|
||||
});
|
||||
|
||||
render(<InstallSkillDialog onClose={vi.fn()} onInstalled={vi.fn()} />);
|
||||
@@ -124,14 +124,14 @@ describe('InstallSkillDialog', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Install/ }));
|
||||
});
|
||||
|
||||
expect(vi.mocked(skillsApi.installSkillFromUrl)).toHaveBeenCalledWith({
|
||||
expect(vi.mocked(workflowsApi.installWorkflowFromUrl)).toHaveBeenCalledWith({
|
||||
url: 'https://raw.githubusercontent.com/owner/repo/main/SKILL.md',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows generic title with raw error text on unknown error and re-enables submit', async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.installSkillFromUrl).mockRejectedValueOnce(
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.installWorkflowFromUrl).mockRejectedValueOnce(
|
||||
new Error('unexpected: something weird happened')
|
||||
);
|
||||
|
||||
@@ -155,8 +155,8 @@ describe('InstallSkillDialog', () => {
|
||||
});
|
||||
|
||||
it('categorizes "invalid SKILL.md:" errors with a friendly title and hint', async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.installSkillFromUrl).mockRejectedValueOnce(
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.installWorkflowFromUrl).mockRejectedValueOnce(
|
||||
new Error('invalid SKILL.md: missing required field `description`')
|
||||
);
|
||||
|
||||
@@ -177,8 +177,8 @@ describe('InstallSkillDialog', () => {
|
||||
});
|
||||
|
||||
it('categorizes "unsupported url form:" errors', async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.installSkillFromUrl).mockRejectedValueOnce(
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.installWorkflowFromUrl).mockRejectedValueOnce(
|
||||
new Error('unsupported url form: path must end in .md, got "https://example.com/foo"')
|
||||
);
|
||||
|
||||
|
||||
@@ -2,14 +2,14 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import type { CatalogEntry } from '../../../services/api/skillRegistryApi';
|
||||
import type { SkillSummary } from '../../../services/api/skillsApi';
|
||||
import type { WorkflowSummary } from '../../../services/api/workflowsApi';
|
||||
import SkillsExplorerTab from '../SkillsExplorerTab';
|
||||
|
||||
vi.mock('../../../services/api/skillsApi', () => ({
|
||||
skillsApi: {
|
||||
listSkills: vi.fn(),
|
||||
installSkillFromUrl: vi.fn(),
|
||||
uninstallSkill: vi.fn(),
|
||||
vi.mock('../../../services/api/workflowsApi', () => ({
|
||||
workflowsApi: {
|
||||
listWorkflows: vi.fn(),
|
||||
installWorkflowFromUrl: vi.fn(),
|
||||
uninstallWorkflow: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -22,7 +22,7 @@ vi.mock('../../../services/api/skillRegistryApi', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
const MOCK_SKILL: SkillSummary = {
|
||||
const MOCK_SKILL: WorkflowSummary = {
|
||||
id: 'test-skill',
|
||||
name: 'Test Skill',
|
||||
description: 'A test skill for unit testing',
|
||||
@@ -41,7 +41,7 @@ const MOCK_SKILL: SkillSummary = {
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const MOCK_PROJECT_SKILL: SkillSummary = {
|
||||
const MOCK_PROJECT_SKILL: WorkflowSummary = {
|
||||
...MOCK_SKILL,
|
||||
id: 'project-skill',
|
||||
name: 'Project Skill',
|
||||
@@ -72,23 +72,23 @@ async function switchToInstalled() {
|
||||
|
||||
describe('SkillsExplorerTab', () => {
|
||||
beforeEach(async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillRegistryApi } = await import(
|
||||
'../../../services/api/skillRegistryApi'
|
||||
);
|
||||
vi.mocked(skillsApi.listSkills).mockReset();
|
||||
vi.mocked(skillsApi.uninstallSkill).mockReset();
|
||||
vi.mocked(workflowsApi.listWorkflows).mockReset();
|
||||
vi.mocked(workflowsApi.uninstallWorkflow).mockReset();
|
||||
vi.mocked(skillRegistryApi.browse).mockReset();
|
||||
vi.mocked(skillRegistryApi.install).mockReset();
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it('defaults to registry view and shows catalog entries', async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillRegistryApi } = await import(
|
||||
'../../../services/api/skillRegistryApi'
|
||||
);
|
||||
vi.mocked(skillsApi.listSkills).mockResolvedValue([]);
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
@@ -101,8 +101,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('shows installed skills when switching to installed tab', async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listSkills).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
|
||||
@@ -119,8 +119,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('shows empty state when no installed skills', async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listSkills).mockResolvedValue([]);
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
@@ -134,8 +134,8 @@ describe('SkillsExplorerTab', () => {
|
||||
const { skillRegistryApi } = await import(
|
||||
'../../../services/api/skillRegistryApi'
|
||||
);
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listSkills).mockResolvedValue([]);
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
vi.mocked(skillRegistryApi.browse).mockRejectedValue(new Error('Network error'));
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
@@ -147,8 +147,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('filters installed skills by search query', async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listSkills).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
@@ -165,8 +165,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('shows install from URL button', async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listSkills).mockResolvedValue([]);
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
|
||||
@@ -176,8 +176,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('shows uninstall button only for user-scope skills', async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listSkills).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
@@ -191,8 +191,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('displays version and tags in installed view', async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listSkills).mockResolvedValue([MOCK_SKILL]);
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
@@ -205,8 +205,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('displays scope badges', async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listSkills).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([MOCK_SKILL, MOCK_PROJECT_SKILL]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
@@ -219,12 +219,12 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('shows skill warnings when present', async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const skillWithWarning = {
|
||||
...MOCK_SKILL,
|
||||
warnings: ['Missing required field: author'],
|
||||
};
|
||||
vi.mocked(skillsApi.listSkills).mockResolvedValue([skillWithWarning]);
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([skillWithWarning]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
await switchToInstalled();
|
||||
@@ -235,12 +235,12 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('shows "Installed" badge for already-installed catalog entries', async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
const { skillRegistryApi } = await import(
|
||||
'../../../services/api/skillRegistryApi'
|
||||
);
|
||||
const installedSkill = { ...MOCK_SKILL, id: 'registry-skill-1' };
|
||||
vi.mocked(skillsApi.listSkills).mockResolvedValue([installedSkill]);
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([installedSkill]);
|
||||
vi.mocked(skillRegistryApi.browse).mockResolvedValue([MOCK_CATALOG_ENTRY]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
@@ -252,8 +252,8 @@ describe('SkillsExplorerTab', () => {
|
||||
});
|
||||
|
||||
it('has an install from URL button', async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.listSkills).mockResolvedValue([]);
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.listWorkflows).mockResolvedValue([]);
|
||||
|
||||
render(<SkillsExplorerTab />);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Verifies:
|
||||
* - Renders skill name + on-disk path + destructive confirm copy.
|
||||
* - Cancel button fires onClose, does NOT hit the RPC.
|
||||
* - Confirm fires `skillsApi.uninstallSkill(name)` and forwards the result
|
||||
* - Confirm fires `workflowsApi.uninstallWorkflow(name)` and forwards the result
|
||||
* to `onUninstalled`, then closes.
|
||||
* - RPC error is surfaced inline and the dialog stays open (no onClose).
|
||||
* - While in-flight, both buttons disable and Esc no-ops (handled by
|
||||
@@ -14,15 +14,15 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import UninstallSkillConfirmDialog from '../UninstallSkillConfirmDialog';
|
||||
import type { SkillSummary } from '../../../services/api/skillsApi';
|
||||
import type { WorkflowSummary } from '../../../services/api/workflowsApi';
|
||||
|
||||
vi.mock('../../../services/api/skillsApi', () => ({
|
||||
skillsApi: {
|
||||
uninstallSkill: vi.fn(),
|
||||
vi.mock('../../../services/api/workflowsApi', () => ({
|
||||
workflowsApi: {
|
||||
uninstallWorkflow: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const fixture: SkillSummary = {
|
||||
const fixture: WorkflowSummary = {
|
||||
id: 'weather-helper',
|
||||
name: 'weather-helper',
|
||||
description: 'Weather forecasts',
|
||||
@@ -43,8 +43,8 @@ const fixture: SkillSummary = {
|
||||
|
||||
describe('UninstallSkillConfirmDialog', () => {
|
||||
beforeEach(async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.uninstallSkill).mockReset();
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.uninstallWorkflow).mockReset();
|
||||
});
|
||||
|
||||
it('renders skill name, path (stripped of /SKILL.md), and confirm copy', () => {
|
||||
@@ -68,14 +68,14 @@ describe('UninstallSkillConfirmDialog', () => {
|
||||
// by slug — the UI must pass `skill.id` (the slug).
|
||||
const onClose = vi.fn();
|
||||
const onUninstalled = vi.fn();
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.uninstallSkill).mockResolvedValueOnce({
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.uninstallWorkflow).mockResolvedValueOnce({
|
||||
name: 'weather-helper',
|
||||
removedPath: '/Users/me/.openhuman/skills/weather-helper',
|
||||
scope: 'user',
|
||||
});
|
||||
|
||||
const divergent: SkillSummary = {
|
||||
const divergent: WorkflowSummary = {
|
||||
...fixture,
|
||||
id: 'weather-helper',
|
||||
name: 'Weather Helper (Pro)',
|
||||
@@ -90,14 +90,14 @@ describe('UninstallSkillConfirmDialog', () => {
|
||||
fireEvent.click(screen.getByTestId('uninstall-skill-confirm'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(skillsApi.uninstallSkill)).toHaveBeenCalledWith('weather-helper');
|
||||
expect(vi.mocked(workflowsApi.uninstallWorkflow)).toHaveBeenCalledWith('weather-helper');
|
||||
});
|
||||
expect(vi.mocked(skillsApi.uninstallSkill)).not.toHaveBeenCalledWith('Weather Helper (Pro)');
|
||||
expect(vi.mocked(workflowsApi.uninstallWorkflow)).not.toHaveBeenCalledWith('Weather Helper (Pro)');
|
||||
});
|
||||
|
||||
it('Cancel fires onClose without calling the RPC', async () => {
|
||||
const onClose = vi.fn();
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
render(
|
||||
<UninstallSkillConfirmDialog
|
||||
skill={fixture}
|
||||
@@ -107,14 +107,14 @@ describe('UninstallSkillConfirmDialog', () => {
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: /Cancel/ }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(vi.mocked(skillsApi.uninstallSkill)).not.toHaveBeenCalled();
|
||||
expect(vi.mocked(workflowsApi.uninstallWorkflow)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Confirm calls skillsApi.uninstallSkill and forwards result to onUninstalled', async () => {
|
||||
it('Confirm calls workflowsApi.uninstallWorkflow and forwards result to onUninstalled', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onUninstalled = vi.fn();
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.uninstallSkill).mockResolvedValueOnce({
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.uninstallWorkflow).mockResolvedValueOnce({
|
||||
name: 'weather-helper',
|
||||
removedPath: '/Users/me/.openhuman/skills/weather-helper',
|
||||
scope: 'user',
|
||||
@@ -130,12 +130,12 @@ describe('UninstallSkillConfirmDialog', () => {
|
||||
fireEvent.click(screen.getByTestId('uninstall-skill-confirm'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(vi.mocked(skillsApi.uninstallSkill)).toHaveBeenCalledWith('weather-helper');
|
||||
expect(vi.mocked(workflowsApi.uninstallWorkflow)).toHaveBeenCalledWith('weather-helper');
|
||||
});
|
||||
// Assert the caller passed the slug (`id`) — not the frontmatter
|
||||
// display name. Regression guard for the #781 fix that swapped
|
||||
// `skill.name` → `skill.id` in the confirm handler.
|
||||
expect(vi.mocked(skillsApi.uninstallSkill)).toHaveBeenCalledWith(fixture.id);
|
||||
expect(vi.mocked(workflowsApi.uninstallWorkflow)).toHaveBeenCalledWith(fixture.id);
|
||||
await waitFor(() => {
|
||||
expect(onUninstalled).toHaveBeenCalledWith({
|
||||
name: 'weather-helper',
|
||||
@@ -151,8 +151,8 @@ describe('UninstallSkillConfirmDialog', () => {
|
||||
it('surfaces RPC errors inline and keeps the dialog open', async () => {
|
||||
const onClose = vi.fn();
|
||||
const onUninstalled = vi.fn();
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
vi.mocked(skillsApi.uninstallSkill).mockRejectedValueOnce(
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
vi.mocked(workflowsApi.uninstallWorkflow).mockRejectedValueOnce(
|
||||
new Error("skill 'weather-helper' is not installed")
|
||||
);
|
||||
|
||||
@@ -177,18 +177,18 @@ describe('UninstallSkillConfirmDialog', () => {
|
||||
});
|
||||
|
||||
it('disables buttons while the RPC is in flight', async () => {
|
||||
const { skillsApi } = await import('../../../services/api/skillsApi');
|
||||
const { workflowsApi } = await import('../../../services/api/workflowsApi');
|
||||
type UninstallResolve = (v: {
|
||||
name: string;
|
||||
removedPath: string;
|
||||
scope: SkillSummary['scope'];
|
||||
scope: WorkflowSummary['scope'];
|
||||
}) => void;
|
||||
const deferred: { resolve?: UninstallResolve } = {};
|
||||
vi.mocked(skillsApi.uninstallSkill).mockReturnValueOnce(
|
||||
vi.mocked(workflowsApi.uninstallWorkflow).mockReturnValueOnce(
|
||||
new Promise<{
|
||||
name: string;
|
||||
removedPath: string;
|
||||
scope: SkillSummary['scope'];
|
||||
scope: WorkflowSummary['scope'];
|
||||
}>(resolve => {
|
||||
deferred.resolve = resolve;
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*
|
||||
* Covered here:
|
||||
* - Mount with one saved schedule for the picked skill (mocking
|
||||
* skills_list, skills_describe, cron_list, recent_runs).
|
||||
* workflows_list, workflows_describe, cron_list, recent_runs).
|
||||
* - Toggle flips enabled → false via openhumanCronUpdate(id, { enabled }).
|
||||
* - The list re-loads after toggle (openhumanCronList called again).
|
||||
* - aria-checked reflects the new state once the list refreshes.
|
||||
@@ -32,9 +32,9 @@ const hoisted = vi.hoisted(() => ({
|
||||
cronRun: vi.fn(),
|
||||
cronUpdate: vi.fn(),
|
||||
cronRuns: vi.fn(),
|
||||
listSkills: vi.fn(),
|
||||
describeSkill: vi.fn(),
|
||||
runSkill: vi.fn(),
|
||||
listWorkflows: vi.fn(),
|
||||
describeWorkflow: vi.fn(),
|
||||
runWorkflow: vi.fn(),
|
||||
recentRuns: vi.fn(),
|
||||
readRunLog: vi.fn(),
|
||||
cancelRun: vi.fn(),
|
||||
@@ -50,11 +50,11 @@ vi.mock('../../../utils/tauriCommands/cron', () => ({
|
||||
openhumanCronRuns: hoisted.cronRuns,
|
||||
}));
|
||||
|
||||
vi.mock('../../../services/api/skillsApi', () => ({
|
||||
skillsApi: {
|
||||
listSkills: hoisted.listSkills,
|
||||
describeSkill: hoisted.describeSkill,
|
||||
runSkill: hoisted.runSkill,
|
||||
vi.mock('../../../services/api/workflowsApi', () => ({
|
||||
workflowsApi: {
|
||||
listWorkflows: hoisted.listWorkflows,
|
||||
describeWorkflow: hoisted.describeWorkflow,
|
||||
runWorkflow: hoisted.runWorkflow,
|
||||
recentRuns: hoisted.recentRuns,
|
||||
readRunLog: hoisted.readRunLog,
|
||||
cancelRun: hoisted.cancelRun,
|
||||
@@ -158,8 +158,8 @@ describe('WorkflowRunnerBody — saved-schedule toggle', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach((fn) => fn.mockReset());
|
||||
|
||||
hoisted.listSkills.mockResolvedValue(skillsList);
|
||||
hoisted.describeSkill.mockResolvedValue(skillDescription);
|
||||
hoisted.listWorkflows.mockResolvedValue(skillsList);
|
||||
hoisted.describeWorkflow.mockResolvedValue(skillDescription);
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.cronList.mockResolvedValue({ result: [makeJob({ enabled: true })] });
|
||||
hoisted.cronUpdate.mockResolvedValue({ result: makeJob({ enabled: false }) });
|
||||
@@ -170,8 +170,8 @@ describe('WorkflowRunnerBody — saved-schedule toggle', () => {
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
|
||||
// Wait for skills_list to resolve and populate the dropdown.
|
||||
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
|
||||
// Wait for workflows_list to resolve and populate the dropdown.
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
|
||||
// Pick the skill so the schedule list mounts.
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
@@ -192,7 +192,7 @@ describe('WorkflowRunnerBody — saved-schedule toggle', () => {
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
|
||||
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: SKILL_ID } });
|
||||
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled());
|
||||
@@ -226,7 +226,7 @@ describe('WorkflowRunnerBody — saved-schedule toggle', () => {
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
|
||||
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: SKILL_ID } });
|
||||
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled());
|
||||
@@ -262,8 +262,8 @@ function makeRun(
|
||||
describe('WorkflowRunnerBody — per-job history viewer', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach((fn) => fn.mockReset());
|
||||
hoisted.listSkills.mockResolvedValue(skillsList);
|
||||
hoisted.describeSkill.mockResolvedValue(skillDescription);
|
||||
hoisted.listWorkflows.mockResolvedValue(skillsList);
|
||||
hoisted.describeWorkflow.mockResolvedValue(skillDescription);
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.cronList.mockResolvedValue({ result: [makeJob({ enabled: true })] });
|
||||
hoisted.cronRuns.mockResolvedValue({ result: { runs: [makeRun(1), makeRun(2)] } });
|
||||
@@ -272,7 +272,7 @@ describe('WorkflowRunnerBody — per-job history viewer', () => {
|
||||
it('loads cron_runs and renders history rows on first toggle', async () => {
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: SKILL_ID } });
|
||||
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled());
|
||||
@@ -288,7 +288,7 @@ describe('WorkflowRunnerBody — per-job history viewer', () => {
|
||||
it("expands a run row to show its captured output, hides on collapse", async () => {
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: SKILL_ID } });
|
||||
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled());
|
||||
@@ -329,7 +329,7 @@ describe('WorkflowRunnerBody — per-job history viewer', () => {
|
||||
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: SKILL_ID } });
|
||||
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled());
|
||||
@@ -374,7 +374,7 @@ describe('WorkflowRunnerBody — per-job history viewer', () => {
|
||||
});
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: SKILL_ID } });
|
||||
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled());
|
||||
@@ -387,7 +387,7 @@ describe('WorkflowRunnerBody — per-job history viewer', () => {
|
||||
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: SKILL_ID } });
|
||||
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled());
|
||||
@@ -403,8 +403,8 @@ describe('WorkflowRunnerBody — per-job history viewer', () => {
|
||||
describe('WorkflowRunnerBody — schedule frequency + save', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach((fn) => fn.mockReset());
|
||||
hoisted.listSkills.mockResolvedValue(skillsList);
|
||||
hoisted.describeSkill.mockResolvedValue(skillDescription);
|
||||
hoisted.listWorkflows.mockResolvedValue(skillsList);
|
||||
hoisted.describeWorkflow.mockResolvedValue(skillDescription);
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.cronList.mockResolvedValue({ result: [] });
|
||||
hoisted.cronAdd.mockResolvedValue({ result: makeJob() });
|
||||
@@ -414,7 +414,7 @@ describe('WorkflowRunnerBody — schedule frequency + save', () => {
|
||||
it('changes schedule frequency and calls openhumanCronAdd on save', async () => {
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: SKILL_ID } });
|
||||
await waitFor(() => expect(hoisted.cronList).toHaveBeenCalled());
|
||||
@@ -444,8 +444,8 @@ describe('WorkflowRunnerBody — SmartIssuePicker conditional mount', () => {
|
||||
});
|
||||
|
||||
it('renders SmartIssuePicker when the picked skill is dev-workflow', async () => {
|
||||
hoisted.listSkills.mockResolvedValue([{ id: 'dev-workflow', name: 'Dev Workflow' }]);
|
||||
hoisted.describeSkill.mockResolvedValue({
|
||||
hoisted.listWorkflows.mockResolvedValue([{ id: 'dev-workflow', name: 'Dev Workflow' }]);
|
||||
hoisted.describeWorkflow.mockResolvedValue({
|
||||
id: 'dev-workflow',
|
||||
name: 'Dev Workflow',
|
||||
when_to_use: 'Autonomous developer.',
|
||||
@@ -459,7 +459,7 @@ describe('WorkflowRunnerBody — SmartIssuePicker conditional mount', () => {
|
||||
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: 'dev-workflow' } });
|
||||
|
||||
@@ -470,10 +470,10 @@ describe('WorkflowRunnerBody — SmartIssuePicker conditional mount', () => {
|
||||
});
|
||||
|
||||
it('does NOT render SmartIssuePicker for generic skills', async () => {
|
||||
hoisted.listSkills.mockResolvedValue([
|
||||
hoisted.listWorkflows.mockResolvedValue([
|
||||
{ id: 'github-issue-crusher', name: 'GitHub Issue Crusher' },
|
||||
]);
|
||||
hoisted.describeSkill.mockResolvedValue({
|
||||
hoisted.describeWorkflow.mockResolvedValue({
|
||||
id: 'github-issue-crusher',
|
||||
name: 'GitHub Issue Crusher',
|
||||
when_to_use: 'Crush issues.',
|
||||
@@ -485,11 +485,11 @@ describe('WorkflowRunnerBody — SmartIssuePicker conditional mount', () => {
|
||||
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: 'github-issue-crusher' } });
|
||||
|
||||
await waitFor(() => expect(hoisted.describeSkill).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.describeWorkflow).toHaveBeenCalled());
|
||||
expect(screen.queryByTestId('smart-issue-picker-stub')).not.toBeInTheDocument();
|
||||
// The generic schema-driven repo field IS rendered via the
|
||||
// existing RepoPicker stub.
|
||||
@@ -502,11 +502,11 @@ describe('WorkflowRunnerBody — SmartIssuePicker conditional mount', () => {
|
||||
describe('WorkflowRunnerBody — URL ?workflow= preselect', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach((fn) => fn.mockReset());
|
||||
hoisted.listSkills.mockResolvedValue([
|
||||
hoisted.listWorkflows.mockResolvedValue([
|
||||
{ id: 'dev-workflow', name: 'Dev Workflow' },
|
||||
{ id: 'github-issue-crusher', name: 'GitHub Issue Crusher' },
|
||||
]);
|
||||
hoisted.describeSkill.mockResolvedValue({
|
||||
hoisted.describeWorkflow.mockResolvedValue({
|
||||
id: 'dev-workflow',
|
||||
name: 'Dev Workflow',
|
||||
when_to_use: 'Autonomous developer.',
|
||||
@@ -521,17 +521,17 @@ describe('WorkflowRunnerBody — URL ?workflow= preselect', () => {
|
||||
const Body = await importBody();
|
||||
renderBody(Body, '/workflows/run?workflow=dev-workflow');
|
||||
|
||||
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
|
||||
// The picker should already be pointing at dev-workflow without any
|
||||
// user interaction. We assert this two ways: (a) the <select>'s
|
||||
// value matches, and (b) describeSkill was fetched for it.
|
||||
// value matches, and (b) describeWorkflow was fetched for it.
|
||||
const select = (await screen.findByLabelText(
|
||||
'settings.skillsRunner.skill'
|
||||
)) as HTMLSelectElement;
|
||||
expect(select.value).toBe('dev-workflow');
|
||||
await waitFor(() =>
|
||||
expect(hoisted.describeSkill).toHaveBeenCalledWith('dev-workflow')
|
||||
expect(hoisted.describeWorkflow).toHaveBeenCalledWith('dev-workflow')
|
||||
);
|
||||
});
|
||||
|
||||
@@ -539,27 +539,27 @@ describe('WorkflowRunnerBody — URL ?workflow= preselect', () => {
|
||||
const Body = await importBody();
|
||||
renderBody(Body, '/workflows/run');
|
||||
|
||||
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
const select = (await screen.findByLabelText(
|
||||
'settings.skillsRunner.skill'
|
||||
)) as HTMLSelectElement;
|
||||
expect(select.value).toBe('');
|
||||
expect(hoisted.describeSkill).not.toHaveBeenCalled();
|
||||
expect(hoisted.describeWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('ignores ?workflow= when the value is not in the skills_list (picker stays empty, describeSkill called once with empty=never)', async () => {
|
||||
it('ignores ?workflow= when the value is not in the workflows_list (picker stays empty, describeWorkflow called once with empty=never)', async () => {
|
||||
// ?workflow=unknown-skill is treated as best-effort: we set the state
|
||||
// but the picker shows "Select a skill" since the option isn't in
|
||||
// the list. The describe call IS attempted (we don't pre-filter
|
||||
// against the catalog) — but the cancellation effect tears it
|
||||
// down if the value never resolves to a real skill.
|
||||
hoisted.describeSkill.mockRejectedValue(new Error('unknown skill'));
|
||||
hoisted.describeWorkflow.mockRejectedValue(new Error('unknown skill'));
|
||||
const Body = await importBody();
|
||||
renderBody(Body, '/workflows/run?workflow=does-not-exist');
|
||||
|
||||
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
await waitFor(() =>
|
||||
expect(hoisted.describeSkill).toHaveBeenCalledWith('does-not-exist')
|
||||
expect(hoisted.describeWorkflow).toHaveBeenCalledWith('does-not-exist')
|
||||
);
|
||||
// The dropdown value won't render as an option (not in the list),
|
||||
// so its current value normalises to '' visually — but the state
|
||||
@@ -576,7 +576,7 @@ describe('WorkflowRunnerBody — URL ?workflow= preselect', () => {
|
||||
const Body = await importBody();
|
||||
renderBody(Body, '/workflows/run?workflow=dev-workflow&lock=1');
|
||||
|
||||
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
|
||||
// The picker is replaced by the workflow-name heading...
|
||||
const heading = await screen.findByTestId('skills-runner-skill-locked');
|
||||
@@ -601,8 +601,8 @@ describe('WorkflowRunnerBody — URL ?workflow= preselect', () => {
|
||||
describe('WorkflowRunnerBody — Run Now flow', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach((fn) => fn.mockReset());
|
||||
hoisted.listSkills.mockResolvedValue([{ id: 'pr-review-shepherd', name: 'PR Review Shepherd' }]);
|
||||
hoisted.describeSkill.mockResolvedValue({
|
||||
hoisted.listWorkflows.mockResolvedValue([{ id: 'pr-review-shepherd', name: 'PR Review Shepherd' }]);
|
||||
hoisted.describeWorkflow.mockResolvedValue({
|
||||
id: 'pr-review-shepherd',
|
||||
name: 'PR Review Shepherd',
|
||||
when_to_use: 'Shepherd PRs.',
|
||||
@@ -615,7 +615,7 @@ describe('WorkflowRunnerBody — Run Now flow', () => {
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.cronList.mockResolvedValue({ result: [] });
|
||||
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
|
||||
hoisted.runSkill.mockResolvedValue({
|
||||
hoisted.runWorkflow.mockResolvedValue({
|
||||
run_id: 'run-abc',
|
||||
skill_id: 'pr-review-shepherd',
|
||||
log: '/tmp/run-abc.log',
|
||||
@@ -625,26 +625,26 @@ describe('WorkflowRunnerBody — Run Now flow', () => {
|
||||
it('Run Now button is disabled while required fields are empty', async () => {
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: 'pr-review-shepherd' } });
|
||||
await waitFor(() => expect(hoisted.describeSkill).toHaveBeenCalledWith('pr-review-shepherd'));
|
||||
await waitFor(() => expect(hoisted.describeWorkflow).toHaveBeenCalledWith('pr-review-shepherd'));
|
||||
|
||||
// Run Now button should be disabled when required field is empty
|
||||
const runBtn = await screen.findByText('settings.skillsRunner.runNow');
|
||||
expect(runBtn.closest('button')).toBeDisabled();
|
||||
expect(hoisted.runSkill).not.toHaveBeenCalled();
|
||||
expect(hoisted.runWorkflow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls runSkill with built payload when required fields are filled', async () => {
|
||||
it('calls runWorkflow with built payload when required fields are filled', async () => {
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: 'pr-review-shepherd' } });
|
||||
await waitFor(() => expect(hoisted.describeSkill).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.describeWorkflow).toHaveBeenCalled());
|
||||
|
||||
// Fill the repo input (rendered as a RepoPicker stub <input>)
|
||||
const repoInput = await screen.findByTestId('repo-picker-stub');
|
||||
@@ -656,7 +656,7 @@ describe('WorkflowRunnerBody — Run Now flow', () => {
|
||||
fireEvent.click(runBtn.closest('button')!);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(hoisted.runSkill).toHaveBeenCalledWith(
|
||||
expect(hoisted.runWorkflow).toHaveBeenCalledWith(
|
||||
'pr-review-shepherd',
|
||||
expect.objectContaining({ repo: 'owner/myrepo' })
|
||||
)
|
||||
@@ -669,11 +669,11 @@ describe('WorkflowRunnerBody — Run Now flow', () => {
|
||||
// click Run again and spawn a second run.
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: 'pr-review-shepherd' } });
|
||||
await waitFor(() => expect(hoisted.describeSkill).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.describeWorkflow).toHaveBeenCalled());
|
||||
|
||||
const repoInput = await screen.findByTestId('repo-picker-stub');
|
||||
fireEvent.change(repoInput, { target: { value: 'owner/myrepo' } });
|
||||
@@ -684,22 +684,22 @@ describe('WorkflowRunnerBody — Run Now flow', () => {
|
||||
const callsBefore = hoisted.recentRuns.mock.calls.length;
|
||||
fireEvent.click(runBtn.closest('button')!);
|
||||
|
||||
await waitFor(() => expect(hoisted.runSkill).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(hoisted.runWorkflow).toHaveBeenCalledTimes(1));
|
||||
// The post-run refresh burst re-scans recentRuns on its own.
|
||||
await waitFor(() =>
|
||||
expect(hoisted.recentRuns.mock.calls.length).toBeGreaterThan(callsBefore)
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces error when runSkill rejects', async () => {
|
||||
hoisted.runSkill.mockRejectedValue(new Error('backend error'));
|
||||
it('surfaces error when runWorkflow rejects', async () => {
|
||||
hoisted.runWorkflow.mockRejectedValue(new Error('backend error'));
|
||||
const Body = await importBody();
|
||||
renderBody(Body);
|
||||
await waitFor(() => expect(hoisted.listSkills).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.listWorkflows).toHaveBeenCalled());
|
||||
|
||||
const select = screen.getByLabelText('settings.skillsRunner.skill') as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: 'pr-review-shepherd' } });
|
||||
await waitFor(() => expect(hoisted.describeSkill).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.describeWorkflow).toHaveBeenCalled());
|
||||
|
||||
const repoInput = await screen.findByTestId('repo-picker-stub');
|
||||
fireEvent.change(repoInput, { target: { value: 'owner/myrepo' } });
|
||||
@@ -762,14 +762,14 @@ describe('parseScheduledInputs', () => {
|
||||
describe('WorkflowRunnerBody — Stop / Edit / scheduled run-now', () => {
|
||||
beforeEach(() => {
|
||||
Object.values(hoisted).forEach((fn) => fn.mockReset());
|
||||
hoisted.listSkills.mockResolvedValue([
|
||||
hoisted.listWorkflows.mockResolvedValue([
|
||||
{ id: SKILL_ID, name: 'GitHub Issue Crusher', scope: 'user', legacy: false },
|
||||
]);
|
||||
hoisted.describeSkill.mockResolvedValue(skillDescription);
|
||||
hoisted.describeWorkflow.mockResolvedValue(skillDescription);
|
||||
hoisted.recentRuns.mockResolvedValue([]);
|
||||
hoisted.cronList.mockResolvedValue({ result: [] });
|
||||
hoisted.cronRuns.mockResolvedValue({ result: { runs: [] } });
|
||||
hoisted.runSkill.mockResolvedValue({ run_id: 'r-new', workflow_id: SKILL_ID, log: '/tmp/l' });
|
||||
hoisted.runWorkflow.mockResolvedValue({ run_id: 'r-new', workflow_id: SKILL_ID, log: '/tmp/l' });
|
||||
hoisted.cancelRun.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
@@ -820,7 +820,7 @@ describe('WorkflowRunnerBody — Stop / Edit / scheduled run-now', () => {
|
||||
// The card's "Run" runs the workflow directly with those inputs.
|
||||
fireEvent.click(screen.getByText('settings.skillsRunner.schedule.runNow'));
|
||||
await waitFor(() =>
|
||||
expect(hoisted.runSkill).toHaveBeenCalledWith(SKILL_ID, { channel: 'team-product' })
|
||||
expect(hoisted.runWorkflow).toHaveBeenCalledWith(SKILL_ID, { channel: 'team-product' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { isGithubGateFailure, parseSkillRunError } from './preflightGate';
|
||||
import { isGithubGateFailure, parseWorkflowRunError } from './preflightGate';
|
||||
|
||||
describe('parseSkillRunError', () => {
|
||||
describe('parseWorkflowRunError', () => {
|
||||
it('returns the raw body unchanged when no preflight prefix is present', () => {
|
||||
const out = parseSkillRunError('Run failed because foo');
|
||||
const out = parseWorkflowRunError('Run failed because foo');
|
||||
expect(out.gate).toBeNull();
|
||||
expect(out.tag).toBeNull();
|
||||
expect(out.body).toBe('Run failed because foo');
|
||||
});
|
||||
|
||||
it('handles null / undefined / empty without throwing', () => {
|
||||
expect(parseSkillRunError(null).body).toBe('');
|
||||
expect(parseSkillRunError(undefined).body).toBe('');
|
||||
expect(parseSkillRunError('').body).toBe('');
|
||||
expect(parseWorkflowRunError(null).body).toBe('');
|
||||
expect(parseWorkflowRunError(undefined).body).toBe('');
|
||||
expect(parseWorkflowRunError('').body).toBe('');
|
||||
});
|
||||
|
||||
it('parses a github identity_mismatch failure into gate + tag + body', () => {
|
||||
const raw =
|
||||
'[preflight:github:identity_mismatch] GitHub preflight failed: identity mismatch — Composio is `octo-alice` but git is `Alice`.';
|
||||
const out = parseSkillRunError(raw);
|
||||
const out = parseWorkflowRunError(raw);
|
||||
expect(out.gate).toBe('github');
|
||||
expect(out.tag).toBe('identity_mismatch');
|
||||
expect(out.body).toContain('GitHub preflight failed');
|
||||
@@ -37,7 +37,7 @@ describe('parseSkillRunError', () => {
|
||||
];
|
||||
for (const tag of tags) {
|
||||
const raw = `[preflight:github:${tag}] body for ${tag}`;
|
||||
const out = parseSkillRunError(raw);
|
||||
const out = parseWorkflowRunError(raw);
|
||||
expect(out.gate).toBe('github');
|
||||
expect(out.tag).toBe(tag);
|
||||
expect(out.body).toBe(`body for ${tag}`);
|
||||
@@ -46,15 +46,15 @@ describe('parseSkillRunError', () => {
|
||||
|
||||
it('is idempotent — re-parsing a stripped body is a no-op', () => {
|
||||
const raw = '[preflight:github:git_user_name_missing] please set user.name';
|
||||
const once = parseSkillRunError(raw);
|
||||
const twice = parseSkillRunError(once.body);
|
||||
const once = parseWorkflowRunError(raw);
|
||||
const twice = parseWorkflowRunError(once.body);
|
||||
expect(twice.gate).toBeNull();
|
||||
expect(twice.tag).toBeNull();
|
||||
expect(twice.body).toBe(once.body);
|
||||
});
|
||||
|
||||
it('tolerates lowercase / mixed case in the prefix gate name', () => {
|
||||
const out = parseSkillRunError('[preflight:GITHUB:Identity_Mismatch] body');
|
||||
const out = parseWorkflowRunError('[preflight:GITHUB:Identity_Mismatch] body');
|
||||
expect(out.gate).toBe('github');
|
||||
expect(out.tag).toBe('identity_mismatch');
|
||||
expect(out.body).toBe('body');
|
||||
@@ -64,7 +64,7 @@ describe('parseSkillRunError', () => {
|
||||
// Anchored at start of string only — a dump that contains the
|
||||
// prefix mid-text shouldn't be misinterpreted.
|
||||
const raw = 'orchestrator log: [preflight:github:tag] not the head of the string';
|
||||
const out = parseSkillRunError(raw);
|
||||
const out = parseWorkflowRunError(raw);
|
||||
expect(out.gate).toBeNull();
|
||||
expect(out.body).toBe(raw);
|
||||
});
|
||||
@@ -72,17 +72,17 @@ describe('parseSkillRunError', () => {
|
||||
|
||||
describe('isGithubGateFailure', () => {
|
||||
it('returns true for a github-gate parsed error', () => {
|
||||
const err = parseSkillRunError('[preflight:github:identity_mismatch] x');
|
||||
const err = parseWorkflowRunError('[preflight:github:identity_mismatch] x');
|
||||
expect(isGithubGateFailure(err)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a free-form error', () => {
|
||||
const err = parseSkillRunError('Something else failed');
|
||||
const err = parseWorkflowRunError('Something else failed');
|
||||
expect(isGithubGateFailure(err)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for a future non-github gate (forward-compat)', () => {
|
||||
const err = parseSkillRunError('[preflight:slack:scope_missing] body');
|
||||
const err = parseWorkflowRunError('[preflight:slack:scope_missing] body');
|
||||
expect(isGithubGateFailure(err)).toBe(false);
|
||||
expect(err.gate).toBe('slack');
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
const PREFLIGHT_PREFIX_RE = /^\[preflight:([a-z0-9_-]+):([a-z0-9_-]+)\]\s+/i;
|
||||
|
||||
/** Parsed shape of a backend RPC error returned by `openhuman.workflows_run`. */
|
||||
export interface SkillRunError {
|
||||
export interface WorkflowRunError {
|
||||
/** `'github'` when this is a github-gate failure; `null` for any other error. */
|
||||
gate: string | null;
|
||||
/**
|
||||
@@ -37,7 +37,7 @@ export interface SkillRunError {
|
||||
|
||||
/**
|
||||
* Parse the message string returned by the `openhuman.workflows_run` RPC
|
||||
* error path (or thrown by `skillsApi.runSkill`). Anything that matches
|
||||
* error path (or thrown by `workflowsApi.runWorkflow`). Anything that matches
|
||||
* the `[preflight:<gate>:<tag>]` prefix becomes a structured gate
|
||||
* failure; anything else falls through with `gate: null` so the caller
|
||||
* can render the raw text.
|
||||
@@ -45,7 +45,7 @@ export interface SkillRunError {
|
||||
* Idempotent: calling this twice on the same message is fine (the
|
||||
* second call sees no prefix and returns the same body unchanged).
|
||||
*/
|
||||
export function parseSkillRunError(message: string | undefined | null): SkillRunError {
|
||||
export function parseWorkflowRunError(message: string | undefined | null): WorkflowRunError {
|
||||
const raw = (message ?? '').toString();
|
||||
const m = PREFLIGHT_PREFIX_RE.exec(raw);
|
||||
if (!m) {
|
||||
@@ -63,6 +63,6 @@ export function parseSkillRunError(message: string | undefined | null): SkillRun
|
||||
* the rendering layer that wants a single boolean rather than
|
||||
* matching on the `gate` string.
|
||||
*/
|
||||
export function isGithubGateFailure(err: SkillRunError): boolean {
|
||||
export function isGithubGateFailure(err: WorkflowRunError): boolean {
|
||||
return err.gate === 'github';
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* - renders the form (delegates to CreateWorkflowForm) and the header
|
||||
* Cancel/Submit buttons.
|
||||
* - cancel button navigates back to /skills.
|
||||
* - on a successful submit (createSkill resolves), the page
|
||||
* - on a successful submit (createWorkflow resolves), the page
|
||||
* navigates to /skills.
|
||||
* - submit button reflects the form's validity (disabled until both
|
||||
* required fields are filled).
|
||||
@@ -19,9 +19,11 @@ import WorkflowNew from './WorkflowNew';
|
||||
const stableT = (key: string) => key;
|
||||
vi.mock('../lib/i18n/I18nContext', () => ({ useT: () => ({ t: stableT }) }));
|
||||
|
||||
const hoisted = vi.hoisted(() => ({ createSkill: vi.fn() }));
|
||||
const hoisted = vi.hoisted(() => ({ createWorkflow: vi.fn() }));
|
||||
|
||||
vi.mock('../services/api/skillsApi', () => ({ skillsApi: { createSkill: hoisted.createSkill } }));
|
||||
vi.mock('../services/api/workflowsApi', () => ({
|
||||
workflowsApi: { createWorkflow: hoisted.createWorkflow },
|
||||
}));
|
||||
|
||||
const renderPage = () =>
|
||||
render(
|
||||
@@ -35,7 +37,7 @@ const renderPage = () =>
|
||||
|
||||
describe('WorkflowNew', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.createSkill.mockReset();
|
||||
hoisted.createWorkflow.mockReset();
|
||||
});
|
||||
|
||||
it('renders the form and the header CTAs', () => {
|
||||
@@ -70,7 +72,7 @@ describe('WorkflowNew', () => {
|
||||
});
|
||||
|
||||
it('navigates to /skills after a successful submit', async () => {
|
||||
hoisted.createSkill.mockResolvedValue({
|
||||
hoisted.createWorkflow.mockResolvedValue({
|
||||
id: 'new-skill',
|
||||
name: 'New Skill',
|
||||
scope: 'user',
|
||||
@@ -86,7 +88,7 @@ describe('WorkflowNew', () => {
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByTestId('skill-new-submit'));
|
||||
await waitFor(() => expect(hoisted.createSkill).toHaveBeenCalled());
|
||||
await waitFor(() => expect(hoisted.createWorkflow).toHaveBeenCalled());
|
||||
await screen.findByTestId('dashboard-landed');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import CreateWorkflowForm from '../components/skills/CreateWorkflowForm';
|
||||
import { useT } from '../lib/i18n/I18nContext';
|
||||
import { type SkillSummary } from '../services/api/skillsApi';
|
||||
import { type WorkflowSummary } from '../services/api/workflowsApi';
|
||||
|
||||
const PAGE_FORM_ID = 'create-skill-page-form';
|
||||
|
||||
@@ -38,7 +38,7 @@ export default function WorkflowNew() {
|
||||
}, []);
|
||||
|
||||
const handleCreated = useCallback(
|
||||
(_skill: SkillSummary) => {
|
||||
(_skill: WorkflowSummary) => {
|
||||
// The dashboard re-fetches the cron list on mount, so any
|
||||
// schedule the user adds for this new skill will appear there
|
||||
// automatically — no need to plumb the new id through state.
|
||||
|
||||
@@ -32,13 +32,13 @@ vi.mock('../../hooks/useChannelDefinitions', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/api/skillsApi', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../services/api/skillsApi')>(
|
||||
'../../services/api/skillsApi'
|
||||
vi.mock('../../services/api/workflowsApi', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../services/api/workflowsApi')>(
|
||||
'../../services/api/workflowsApi'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
skillsApi: { ...actual.skillsApi, listSkills: vi.fn().mockResolvedValue([]) },
|
||||
workflowsApi: { ...actual.workflowsApi, listWorkflows: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@ vi.mock('../../hooks/useChannelDefinitions', () => ({
|
||||
useChannelDefinitions: () => ({ definitions: [], loading: false, error: null }),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/api/skillsApi', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../services/api/skillsApi')>(
|
||||
'../../services/api/skillsApi'
|
||||
vi.mock('../../services/api/workflowsApi', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../services/api/workflowsApi')>(
|
||||
'../../services/api/workflowsApi'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
skillsApi: { ...actual.skillsApi, listSkills: vi.fn().mockResolvedValue([]) },
|
||||
workflowsApi: { ...actual.workflowsApi, listWorkflows: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -13,13 +13,13 @@ vi.mock('../../hooks/useChannelDefinitions', () => ({
|
||||
useChannelDefinitions: () => ({ definitions: [], loading: false, error: null }),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/api/skillsApi', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../services/api/skillsApi')>(
|
||||
'../../services/api/skillsApi'
|
||||
vi.mock('../../services/api/workflowsApi', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../services/api/workflowsApi')>(
|
||||
'../../services/api/workflowsApi'
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
skillsApi: { ...actual.skillsApi, listSkills: vi.fn().mockResolvedValue([]) },
|
||||
workflowsApi: { ...actual.workflowsApi, listWorkflows: vi.fn().mockResolvedValue([]) },
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
+272
-143
@@ -1,19 +1,243 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { skillsApi } from '../skillsApi';
|
||||
import { workflowsApi } from '../workflowsApi';
|
||||
|
||||
vi.mock('../../coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
|
||||
|
||||
describe('skillsApi.listSkills', () => {
|
||||
describe('workflowsApi.createWorkflow', () => {
|
||||
beforeEach(async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockReset();
|
||||
});
|
||||
|
||||
it('normalizes snake_case and legacy discovered skill fields', async () => {
|
||||
it('forwards inputs to workflows_create and rekeys allowedTools', async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({
|
||||
skills: [
|
||||
workflow: {
|
||||
id: 'my-skill',
|
||||
name: 'my-skill',
|
||||
description: 'does stuff',
|
||||
version: '',
|
||||
author: null,
|
||||
tags: ['alpha'],
|
||||
tools: ['mcp/fs'],
|
||||
prompts: [],
|
||||
location: '/home/u/.openhuman/skills/my-skill/SKILL.md',
|
||||
resources: [],
|
||||
scope: 'user',
|
||||
legacy: false,
|
||||
warnings: [],
|
||||
},
|
||||
});
|
||||
|
||||
const result = await workflowsApi.createWorkflow({
|
||||
name: 'My Skill',
|
||||
description: 'does stuff',
|
||||
scope: 'user',
|
||||
tags: ['alpha'],
|
||||
allowedTools: ['mcp/fs'],
|
||||
});
|
||||
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.workflows_create',
|
||||
params: {
|
||||
name: 'My Skill',
|
||||
description: 'does stuff',
|
||||
scope: 'user',
|
||||
tags: ['alpha'],
|
||||
'allowed-tools': ['mcp/fs'],
|
||||
},
|
||||
});
|
||||
expect(result.id).toBe('my-skill');
|
||||
expect(result.scope).toBe('user');
|
||||
});
|
||||
|
||||
it('omits optional fields when not provided', async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({
|
||||
workflow: {
|
||||
id: 'minimal',
|
||||
name: 'minimal',
|
||||
description: 'd',
|
||||
version: '',
|
||||
author: null,
|
||||
tags: [],
|
||||
tools: [],
|
||||
prompts: [],
|
||||
location: null,
|
||||
resources: [],
|
||||
scope: 'user',
|
||||
legacy: false,
|
||||
warnings: [],
|
||||
},
|
||||
});
|
||||
|
||||
await workflowsApi.createWorkflow({ name: 'minimal', description: 'd' });
|
||||
|
||||
const call = vi.mocked(callCoreRpc).mock.calls[0][0];
|
||||
expect(call.params).toEqual({ name: 'minimal', description: 'd' });
|
||||
});
|
||||
|
||||
it('unwraps an envelope response', async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({
|
||||
data: {
|
||||
workflow: {
|
||||
id: 'env',
|
||||
name: 'env',
|
||||
description: 'e',
|
||||
version: '',
|
||||
author: null,
|
||||
tags: [],
|
||||
tools: [],
|
||||
prompts: [],
|
||||
location: null,
|
||||
resources: [],
|
||||
scope: 'project',
|
||||
legacy: false,
|
||||
warnings: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await workflowsApi.createWorkflow({ name: 'env', description: 'e' });
|
||||
expect(result.id).toBe('env');
|
||||
expect(result.scope).toBe('project');
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflowsApi.installWorkflowFromUrl', () => {
|
||||
beforeEach(async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockReset();
|
||||
});
|
||||
|
||||
it('forwards url and rekeys timeoutSecs to timeout_secs', async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({
|
||||
url: 'https://example.com/my-skill.tgz',
|
||||
stdout: 'added my-skill',
|
||||
stderr: '',
|
||||
new_workflows: ['my-skill'],
|
||||
});
|
||||
|
||||
const result = await workflowsApi.installWorkflowFromUrl({
|
||||
url: 'https://example.com/my-skill.tgz',
|
||||
timeoutSecs: 120,
|
||||
});
|
||||
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.workflows_install_from_url',
|
||||
params: { url: 'https://example.com/my-skill.tgz', timeout_secs: 120 },
|
||||
});
|
||||
expect(result.newWorkflows).toEqual(['my-skill']);
|
||||
expect(result.stdout).toBe('added my-skill');
|
||||
});
|
||||
|
||||
it('omits timeout_secs when not provided and normalizes missing new_workflows', async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({
|
||||
url: 'https://example.com/x',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
new_workflows: undefined,
|
||||
});
|
||||
|
||||
const result = await workflowsApi.installWorkflowFromUrl({ url: 'https://example.com/x' });
|
||||
|
||||
const call = vi.mocked(callCoreRpc).mock.calls[0][0];
|
||||
expect(call.params).toEqual({ url: 'https://example.com/x' });
|
||||
expect(result.newWorkflows).toEqual([]);
|
||||
});
|
||||
|
||||
it('unwraps an envelope response', async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({
|
||||
data: {
|
||||
url: 'https://example.com/y',
|
||||
stdout: 'ok',
|
||||
stderr: 'warn',
|
||||
new_workflows: ['y-skill'],
|
||||
},
|
||||
});
|
||||
const result = await workflowsApi.installWorkflowFromUrl({ url: 'https://example.com/y' });
|
||||
expect(result.newWorkflows).toEqual(['y-skill']);
|
||||
expect(result.stderr).toBe('warn');
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflowsApi.updateWorkflow', () => {
|
||||
beforeEach(async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockReset();
|
||||
});
|
||||
|
||||
it('forwards every optional field and rekeys allowedTools', async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({
|
||||
workflow: { id: 'wf', name: 'WF', description: 'd', scope: 'user' as const },
|
||||
});
|
||||
|
||||
await workflowsApi.updateWorkflow({
|
||||
name: 'WF',
|
||||
description: 'd',
|
||||
whenToUse: 'when X happens',
|
||||
scope: 'user',
|
||||
license: 'MIT',
|
||||
author: 'me',
|
||||
tags: ['t'],
|
||||
allowedTools: ['mcp/fs'],
|
||||
inputs: [{ name: 'n', required: true }],
|
||||
});
|
||||
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.workflows_update',
|
||||
params: {
|
||||
name: 'WF',
|
||||
description: 'd',
|
||||
when_to_use: 'when X happens',
|
||||
scope: 'user',
|
||||
license: 'MIT',
|
||||
author: 'me',
|
||||
tags: ['t'],
|
||||
'allowed-tools': ['mcp/fs'],
|
||||
inputs: [{ name: 'n', required: true }],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflowsApi.listWorkflows', () => {
|
||||
beforeEach(async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockReset();
|
||||
});
|
||||
|
||||
it('reads the `workflows` result field', async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({
|
||||
workflows: [
|
||||
{ id: 'a', name: 'A', description: '', scope: 'user' as const },
|
||||
{ id: 'b', name: 'B', description: '', scope: 'project' as const },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await workflowsApi.listWorkflows();
|
||||
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.workflows_list' });
|
||||
expect(result.map(w => w.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('unwraps an envelope and defaults to [] when the field is absent', async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({ data: {} });
|
||||
const result = await workflowsApi.listWorkflows();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('normalizes snake_case and legacy discovered workflow fields', async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({
|
||||
workflows: [
|
||||
{
|
||||
id: 'hermes-demo',
|
||||
name: 'Hermes Demo',
|
||||
@@ -49,7 +273,7 @@ describe('skillsApi.listSkills', () => {
|
||||
],
|
||||
});
|
||||
|
||||
const result = await skillsApi.listSkills();
|
||||
const result = await workflowsApi.listWorkflows();
|
||||
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.workflows_list' });
|
||||
expect(result[0].relatedSkills).toEqual(['browser-automation']);
|
||||
@@ -59,158 +283,63 @@ describe('skillsApi.listSkills', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('skillsApi.createSkill', () => {
|
||||
describe('workflowsApi.readWorkflowResource', () => {
|
||||
beforeEach(async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockReset();
|
||||
});
|
||||
|
||||
it('forwards inputs to skills_create and rekeys allowedTools', async () => {
|
||||
it('rekeys params to snake_case and normalizes the response', async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({
|
||||
skill: {
|
||||
id: 'my-skill',
|
||||
name: 'my-skill',
|
||||
description: 'does stuff',
|
||||
version: '',
|
||||
author: null,
|
||||
tags: ['alpha'],
|
||||
tools: ['mcp/fs'],
|
||||
prompts: [],
|
||||
location: '/home/u/.openhuman/skills/my-skill/SKILL.md',
|
||||
resources: [],
|
||||
scope: 'user',
|
||||
legacy: false,
|
||||
warnings: [],
|
||||
},
|
||||
workflow_id: 'wf',
|
||||
relative_path: 'scripts/run.sh',
|
||||
content: '#!/bin/sh\n',
|
||||
bytes: 10,
|
||||
});
|
||||
|
||||
const result = await skillsApi.createSkill({
|
||||
name: 'My Skill',
|
||||
description: 'does stuff',
|
||||
const result = await workflowsApi.readWorkflowResource({
|
||||
workflowId: 'wf',
|
||||
relativePath: 'scripts/run.sh',
|
||||
});
|
||||
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.workflows_read_resource',
|
||||
params: { workflow_id: 'wf', relative_path: 'scripts/run.sh' },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
workflowId: 'wf',
|
||||
relativePath: 'scripts/run.sh',
|
||||
content: '#!/bin/sh\n',
|
||||
bytes: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflowsApi.uninstallWorkflow', () => {
|
||||
beforeEach(async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockReset();
|
||||
});
|
||||
|
||||
it('forwards the name and normalizes removed_path → removedPath', async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({
|
||||
name: 'weather-helper',
|
||||
removed_path: '/home/u/.openhuman/skills/weather-helper',
|
||||
scope: 'user',
|
||||
tags: ['alpha'],
|
||||
allowedTools: ['mcp/fs'],
|
||||
});
|
||||
|
||||
const result = await workflowsApi.uninstallWorkflow('weather-helper');
|
||||
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.workflows_create',
|
||||
params: {
|
||||
name: 'My Skill',
|
||||
description: 'does stuff',
|
||||
scope: 'user',
|
||||
tags: ['alpha'],
|
||||
'allowed-tools': ['mcp/fs'],
|
||||
},
|
||||
method: 'openhuman.workflows_uninstall',
|
||||
params: { name: 'weather-helper' },
|
||||
});
|
||||
expect(result.id).toBe('my-skill');
|
||||
expect(result.scope).toBe('user');
|
||||
});
|
||||
|
||||
it('omits optional fields when not provided', async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({
|
||||
skill: {
|
||||
id: 'minimal',
|
||||
name: 'minimal',
|
||||
description: 'd',
|
||||
version: '',
|
||||
author: null,
|
||||
tags: [],
|
||||
tools: [],
|
||||
prompts: [],
|
||||
location: null,
|
||||
resources: [],
|
||||
scope: 'user',
|
||||
legacy: false,
|
||||
warnings: [],
|
||||
},
|
||||
expect(result).toEqual({
|
||||
name: 'weather-helper',
|
||||
removedPath: '/home/u/.openhuman/skills/weather-helper',
|
||||
scope: 'user',
|
||||
});
|
||||
|
||||
await skillsApi.createSkill({ name: 'minimal', description: 'd' });
|
||||
|
||||
const call = vi.mocked(callCoreRpc).mock.calls[0][0];
|
||||
expect(call.params).toEqual({ name: 'minimal', description: 'd' });
|
||||
});
|
||||
|
||||
it('unwraps an envelope response', async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({
|
||||
data: {
|
||||
skill: {
|
||||
id: 'env',
|
||||
name: 'env',
|
||||
description: 'e',
|
||||
version: '',
|
||||
author: null,
|
||||
tags: [],
|
||||
tools: [],
|
||||
prompts: [],
|
||||
location: null,
|
||||
resources: [],
|
||||
scope: 'project',
|
||||
legacy: false,
|
||||
warnings: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await skillsApi.createSkill({ name: 'env', description: 'e' });
|
||||
expect(result.id).toBe('env');
|
||||
expect(result.scope).toBe('project');
|
||||
});
|
||||
});
|
||||
|
||||
describe('skillsApi.installSkillFromUrl', () => {
|
||||
beforeEach(async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockReset();
|
||||
});
|
||||
|
||||
it('forwards url and rekeys timeoutSecs to timeout_secs', async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({
|
||||
url: 'https://example.com/my-skill.tgz',
|
||||
stdout: 'added my-skill',
|
||||
stderr: '',
|
||||
new_skills: ['my-skill'],
|
||||
});
|
||||
|
||||
const result = await skillsApi.installSkillFromUrl({
|
||||
url: 'https://example.com/my-skill.tgz',
|
||||
timeoutSecs: 120,
|
||||
});
|
||||
|
||||
expect(callCoreRpc).toHaveBeenCalledWith({
|
||||
method: 'openhuman.workflows_install_from_url',
|
||||
params: { url: 'https://example.com/my-skill.tgz', timeout_secs: 120 },
|
||||
});
|
||||
expect(result.newSkills).toEqual(['my-skill']);
|
||||
expect(result.stdout).toBe('added my-skill');
|
||||
});
|
||||
|
||||
it('omits timeout_secs when not provided and normalizes missing new_skills', async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({
|
||||
url: 'https://example.com/x',
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
new_skills: undefined,
|
||||
});
|
||||
|
||||
const result = await skillsApi.installSkillFromUrl({ url: 'https://example.com/x' });
|
||||
|
||||
const call = vi.mocked(callCoreRpc).mock.calls[0][0];
|
||||
expect(call.params).toEqual({ url: 'https://example.com/x' });
|
||||
expect(result.newSkills).toEqual([]);
|
||||
});
|
||||
|
||||
it('unwraps an envelope response', async () => {
|
||||
const { callCoreRpc } = await import('../../coreRpcClient');
|
||||
vi.mocked(callCoreRpc).mockResolvedValueOnce({
|
||||
data: { url: 'https://example.com/y', stdout: 'ok', stderr: 'warn', new_skills: ['y-skill'] },
|
||||
});
|
||||
const result = await skillsApi.installSkillFromUrl({ url: 'https://example.com/y' });
|
||||
expect(result.newSkills).toEqual(['y-skill']);
|
||||
expect(result.stderr).toBe('warn');
|
||||
});
|
||||
});
|
||||
+27
-27
@@ -1,21 +1,21 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { skillsApi } from './skillsApi';
|
||||
import { workflowsApi } from './workflowsApi';
|
||||
|
||||
const mockCallCoreRpc = vi.fn();
|
||||
vi.mock('../coreRpcClient', () => ({ callCoreRpc: (...a: unknown[]) => mockCallCoreRpc(...a) }));
|
||||
|
||||
describe('skillsApi', () => {
|
||||
describe('workflowsApi', () => {
|
||||
beforeEach(() => {
|
||||
mockCallCoreRpc.mockReset();
|
||||
});
|
||||
|
||||
describe('createSkill', () => {
|
||||
describe('createWorkflow', () => {
|
||||
it('includes inputs in params when non-empty', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
skill: { id: 's', name: 'S', description: '', scope: 'user' as const },
|
||||
workflow: { id: 's', name: 'S', description: '', scope: 'user' as const },
|
||||
});
|
||||
await skillsApi.createSkill({
|
||||
await workflowsApi.createWorkflow({
|
||||
name: 'S',
|
||||
description: 'desc',
|
||||
inputs: [{ name: 'repo', type: 'string' as const, description: 'repo', required: true }],
|
||||
@@ -26,7 +26,7 @@ describe('skillsApi', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('describeSkill', () => {
|
||||
describe('describeWorkflow', () => {
|
||||
it('calls openhuman.workflows_describe with workflow_id', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
id: 'dev-workflow',
|
||||
@@ -34,7 +34,7 @@ describe('skillsApi', () => {
|
||||
description: 'Auto dev',
|
||||
inputs: [],
|
||||
});
|
||||
const result = await skillsApi.describeSkill('dev-workflow');
|
||||
const result = await workflowsApi.describeWorkflow('dev-workflow');
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'openhuman.workflows_describe',
|
||||
@@ -48,15 +48,15 @@ describe('skillsApi', () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
data: { id: 'x', name: 'X', description: '', inputs: [], workflow_id: 'x' },
|
||||
});
|
||||
const result = await skillsApi.describeSkill('x');
|
||||
const result = await workflowsApi.describeWorkflow('x');
|
||||
expect(result.id).toBe('x');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runSkill', () => {
|
||||
describe('runWorkflow', () => {
|
||||
it('calls openhuman.workflows_run with workflow_id and inputs', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ run_id: 'run-1', workflow_id: 's', log: '/tmp/log' });
|
||||
const result = await skillsApi.runSkill('s', { repo: 'owner/repo' });
|
||||
const result = await workflowsApi.runWorkflow('s', { repo: 'owner/repo' });
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'openhuman.workflows_run',
|
||||
@@ -68,7 +68,7 @@ describe('skillsApi', () => {
|
||||
});
|
||||
|
||||
describe('readRunLog', () => {
|
||||
it('calls skills_read_run_log with run_id', async () => {
|
||||
it('calls workflows_read_run_log with run_id', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
bytes_read: 100,
|
||||
eof: false,
|
||||
@@ -76,7 +76,7 @@ describe('skillsApi', () => {
|
||||
content: 'log line',
|
||||
offset: 100,
|
||||
});
|
||||
const result = await skillsApi.readRunLog('run-1');
|
||||
const result = await workflowsApi.readRunLog('run-1');
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'openhuman.workflows_read_run_log',
|
||||
@@ -94,7 +94,7 @@ describe('skillsApi', () => {
|
||||
content: '',
|
||||
offset: 500,
|
||||
});
|
||||
await skillsApi.readRunLog('run-2', 200, 4096);
|
||||
await workflowsApi.readRunLog('run-2', 200, 4096);
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ run_id: 'run-2', offset: 200, max_bytes: 4096 }),
|
||||
@@ -106,13 +106,13 @@ describe('skillsApi', () => {
|
||||
describe('recentRuns', () => {
|
||||
it('returns scanned runs array', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ runs: [] });
|
||||
const result = await skillsApi.recentRuns();
|
||||
const result = await workflowsApi.recentRuns();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
|
||||
it('passes workflow_id filter when provided', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ runs: [] });
|
||||
await skillsApi.recentRuns('dev-workflow', 5);
|
||||
await workflowsApi.recentRuns('dev-workflow', 5);
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({ workflow_id: 'dev-workflow', limit: 5 }),
|
||||
@@ -121,12 +121,12 @@ describe('skillsApi', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSkill (optional fields)', () => {
|
||||
describe('createWorkflow (optional fields)', () => {
|
||||
it('forwards when_to_use, scope, license, author, tags, allowed-tools', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
skill: { id: 's', name: 'S', description: '', scope: 'user' as const },
|
||||
workflow: { id: 's', name: 'S', description: '', scope: 'user' as const },
|
||||
});
|
||||
await skillsApi.createSkill({
|
||||
await workflowsApi.createWorkflow({
|
||||
name: 'S',
|
||||
description: 'desc',
|
||||
whenToUse: 'when asked',
|
||||
@@ -153,20 +153,20 @@ describe('skillsApi', () => {
|
||||
|
||||
it('omits when_to_use when blank', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
skill: { id: 's', name: 'S', description: '', scope: 'user' as const },
|
||||
workflow: { id: 's', name: 'S', description: '', scope: 'user' as const },
|
||||
});
|
||||
await skillsApi.createSkill({ name: 'S', description: 'd', whenToUse: ' ' });
|
||||
await workflowsApi.createWorkflow({ name: 'S', description: 'd', whenToUse: ' ' });
|
||||
const params = mockCallCoreRpc.mock.calls[0][0].params;
|
||||
expect(params).not.toHaveProperty('when_to_use');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateSkill', () => {
|
||||
describe('updateWorkflow', () => {
|
||||
it('calls openhuman.workflows_update and returns the skill', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
skill: { id: 'wf', name: 'WF', description: 'd', scope: 'user' as const },
|
||||
workflow: { id: 'wf', name: 'WF', description: 'd', scope: 'user' as const },
|
||||
});
|
||||
const result = await skillsApi.updateSkill({
|
||||
const result = await workflowsApi.updateWorkflow({
|
||||
name: 'WF',
|
||||
description: 'd',
|
||||
whenToUse: 'edit trigger',
|
||||
@@ -187,9 +187,9 @@ describe('skillsApi', () => {
|
||||
|
||||
it('unwraps the data-envelope shape', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({
|
||||
data: { skill: { id: 'wf2', name: 'WF2', description: '', scope: 'user' as const } },
|
||||
data: { workflow: { id: 'wf2', name: 'WF2', description: '', scope: 'user' as const } },
|
||||
});
|
||||
const result = await skillsApi.updateSkill({ name: 'WF2', description: 'd' });
|
||||
const result = await workflowsApi.updateWorkflow({ name: 'WF2', description: 'd' });
|
||||
expect(result.id).toBe('wf2');
|
||||
});
|
||||
});
|
||||
@@ -197,7 +197,7 @@ describe('skillsApi', () => {
|
||||
describe('cancelRun', () => {
|
||||
it('calls openhuman.workflows_cancel with run_id and returns cancelled', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ cancelled: true });
|
||||
const result = await skillsApi.cancelRun('run-9');
|
||||
const result = await workflowsApi.cancelRun('run-9');
|
||||
expect(mockCallCoreRpc).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'openhuman.workflows_cancel',
|
||||
@@ -209,7 +209,7 @@ describe('skillsApi', () => {
|
||||
|
||||
it('returns false when the run was not live (envelope shape)', async () => {
|
||||
mockCallCoreRpc.mockResolvedValue({ data: { cancelled: false } });
|
||||
const result = await skillsApi.cancelRun('gone');
|
||||
const result = await workflowsApi.cancelRun('gone');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,15 +2,15 @@ import debug from 'debug';
|
||||
|
||||
import { callCoreRpc } from '../coreRpcClient';
|
||||
|
||||
const log = debug('skillsApi');
|
||||
const log = debug('workflowsApi');
|
||||
|
||||
/**
|
||||
* Scope a skill was discovered in.
|
||||
*
|
||||
* Mirrors `openhuman::skills::ops::SkillScope` on the Rust side — serialized
|
||||
* Mirrors `openhuman::skills::ops::WorkflowScope` on the Rust side — serialized
|
||||
* as a lowercase string (`"user" | "project" | "legacy"`).
|
||||
*/
|
||||
export type SkillScope = 'user' | 'project' | 'legacy';
|
||||
export type WorkflowScope = 'user' | 'project' | 'legacy';
|
||||
|
||||
/**
|
||||
* Wire-format representation of a discovered skill returned by
|
||||
@@ -19,7 +19,7 @@ export type SkillScope = 'user' | 'project' | 'legacy';
|
||||
* Paths are intentionally serialized as strings (not URLs) to avoid lossy
|
||||
* conversions on non-UTF-8 filesystems.
|
||||
*/
|
||||
export interface SkillSummary {
|
||||
export interface WorkflowSummary {
|
||||
/** Stable identifier — equal to `name` on the Rust side. */
|
||||
id: string;
|
||||
/** Display name, from frontmatter or directory. */
|
||||
@@ -47,18 +47,18 @@ export interface SkillSummary {
|
||||
/** Bundled resource files, relative to the skill root. */
|
||||
resources: string[];
|
||||
/** Where the skill came from. */
|
||||
scope: SkillScope;
|
||||
scope: WorkflowScope;
|
||||
/** True when loaded from the legacy `skills/` layout. */
|
||||
legacy: boolean;
|
||||
/** Non-fatal parse warnings to surface in the UI. */
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
interface SkillsListResult {
|
||||
skills: RawSkillSummary[];
|
||||
interface WorkflowsListResult {
|
||||
workflows: RawWorkflowSummary[];
|
||||
}
|
||||
|
||||
type RawSkillSummary = Omit<SkillSummary, 'platforms' | 'relatedSkills' | 'sourceFormat'> & {
|
||||
type RawWorkflowSummary = Omit<WorkflowSummary, 'platforms' | 'relatedSkills' | 'sourceFormat'> & {
|
||||
platforms?: string[];
|
||||
related_skills?: string[];
|
||||
relatedSkills?: string[];
|
||||
@@ -69,9 +69,9 @@ type RawSkillSummary = Omit<SkillSummary, 'platforms' | 'relatedSkills' | 'sourc
|
||||
/**
|
||||
* Result of `openhuman.workflows_read_resource`.
|
||||
*/
|
||||
export interface SkillResourceContent {
|
||||
export interface WorkflowResourceContent {
|
||||
/** Echo of the requested skill id. */
|
||||
skillId: string;
|
||||
workflowId: string;
|
||||
/** Echo of the requested relative path. */
|
||||
relativePath: string;
|
||||
/** UTF-8 file contents (<= 128 KB). */
|
||||
@@ -80,7 +80,7 @@ export interface SkillResourceContent {
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
interface RawSkillsReadResourceResult {
|
||||
interface RawWorkflowsReadResourceResult {
|
||||
workflow_id: string;
|
||||
relative_path: string;
|
||||
content: string;
|
||||
@@ -102,14 +102,14 @@ interface RawSkillsReadResourceResult {
|
||||
* to `true` on the Rust side when omitted (we send it explicitly to
|
||||
* stay loud).
|
||||
*/
|
||||
export interface CreateSkillInputDef {
|
||||
export interface CreateWorkflowInputDef {
|
||||
name: string;
|
||||
description?: string;
|
||||
required: boolean;
|
||||
type?: 'string' | 'integer' | 'boolean';
|
||||
}
|
||||
|
||||
export interface CreateSkillInput {
|
||||
export interface CreateWorkflowInput {
|
||||
name: string;
|
||||
description: string;
|
||||
/**
|
||||
@@ -119,7 +119,7 @@ export interface CreateSkillInput {
|
||||
* `skill.toml` `when_to_use`; falls back to `description` when omitted.
|
||||
*/
|
||||
whenToUse?: string;
|
||||
scope?: SkillScope;
|
||||
scope?: WorkflowScope;
|
||||
license?: string;
|
||||
author?: string;
|
||||
tags?: string[];
|
||||
@@ -130,11 +130,11 @@ export interface CreateSkillInput {
|
||||
* the Skills Runner can render dynamic form controls per input.
|
||||
* Omit / pass `[]` to scaffold an input-less skill.
|
||||
*/
|
||||
inputs?: CreateSkillInputDef[];
|
||||
inputs?: CreateWorkflowInputDef[];
|
||||
}
|
||||
|
||||
interface RawSkillsCreateResult {
|
||||
skill: RawSkillSummary;
|
||||
interface RawWorkflowsCreateResult {
|
||||
workflow: RawWorkflowSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -143,7 +143,7 @@ interface RawSkillsCreateResult {
|
||||
* `timeoutSecs` is optional — the Rust side defaults to 60s and caps at
|
||||
* 600s. Values outside that range are clamped server-side.
|
||||
*/
|
||||
export interface InstallSkillFromUrlInput {
|
||||
export interface InstallWorkflowFromUrlInput {
|
||||
url: string;
|
||||
timeoutSecs?: number;
|
||||
}
|
||||
@@ -151,24 +151,24 @@ export interface InstallSkillFromUrlInput {
|
||||
/**
|
||||
* Result of `openhuman.workflows_install_from_url`.
|
||||
*
|
||||
* `newSkills` lists skill ids that appeared post-install (diff vs the
|
||||
* `newWorkflows` lists skill ids that appeared post-install (diff vs the
|
||||
* pre-install snapshot). `stdout` holds a human-readable diagnostic summary
|
||||
* (bytes fetched, target path); `stderr` holds non-fatal frontmatter parse
|
||||
* warnings joined by newlines. There is no subprocess — the Rust side fetches
|
||||
* SKILL.md directly over HTTPS.
|
||||
*/
|
||||
export interface InstallSkillFromUrlResult {
|
||||
export interface InstallWorkflowFromUrlResult {
|
||||
url: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
newSkills: string[];
|
||||
newWorkflows: string[];
|
||||
}
|
||||
|
||||
interface RawInstallSkillFromUrlResult {
|
||||
interface RawInstallWorkflowFromUrlResult {
|
||||
url: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
new_skills: string[];
|
||||
new_workflows: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,16 +178,16 @@ interface RawInstallSkillFromUrlResult {
|
||||
* canonicalised on-disk path that was deleted — surface it in success toasts
|
||||
* so the user can confirm exactly what was removed.
|
||||
*/
|
||||
export interface UninstallSkillResult {
|
||||
export interface UninstallWorkflowResult {
|
||||
name: string;
|
||||
removedPath: string;
|
||||
scope: SkillScope;
|
||||
scope: WorkflowScope;
|
||||
}
|
||||
|
||||
interface RawUninstallSkillResult {
|
||||
interface RawUninstallWorkflowResult {
|
||||
name: string;
|
||||
removed_path: string;
|
||||
scope: SkillScope;
|
||||
scope: WorkflowScope;
|
||||
}
|
||||
|
||||
interface Envelope<T> {
|
||||
@@ -204,7 +204,7 @@ function unwrapEnvelope<T>(response: Envelope<T> | T): T {
|
||||
return response as T;
|
||||
}
|
||||
|
||||
function normalizeSkillSummary(raw: RawSkillSummary): SkillSummary {
|
||||
function normalizeWorkflowSummary(raw: RawWorkflowSummary): WorkflowSummary {
|
||||
return {
|
||||
...raw,
|
||||
platforms: raw.platforms ?? [],
|
||||
@@ -213,17 +213,17 @@ function normalizeSkillSummary(raw: RawSkillSummary): SkillSummary {
|
||||
};
|
||||
}
|
||||
|
||||
export const skillsApi = {
|
||||
export const workflowsApi = {
|
||||
/** Enumerate SKILL.md / legacy skills visible in the active workspace. */
|
||||
listSkills: async (): Promise<SkillSummary[]> => {
|
||||
log('listSkills: request');
|
||||
const response = await callCoreRpc<Envelope<SkillsListResult> | SkillsListResult>({
|
||||
listWorkflows: async (): Promise<WorkflowSummary[]> => {
|
||||
log('listWorkflows: request');
|
||||
const response = await callCoreRpc<Envelope<WorkflowsListResult> | WorkflowsListResult>({
|
||||
method: 'openhuman.workflows_list',
|
||||
});
|
||||
const result = unwrapEnvelope(response);
|
||||
const skills = (result?.skills ?? []).map(normalizeSkillSummary);
|
||||
log('listSkills: response count=%d', skills.length);
|
||||
return skills;
|
||||
const workflows = (result?.workflows ?? []).map(normalizeWorkflowSummary);
|
||||
log('listWorkflows: response count=%d', workflows.length);
|
||||
return workflows;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -231,28 +231,28 @@ export const skillsApi = {
|
||||
* traversal, symlink escape, non-UTF-8 payloads, or files larger than
|
||||
* 128 KB — the caller surfaces the error string verbatim in the drawer.
|
||||
*/
|
||||
readSkillResource: async ({
|
||||
skillId,
|
||||
readWorkflowResource: async ({
|
||||
workflowId,
|
||||
relativePath,
|
||||
}: {
|
||||
skillId: string;
|
||||
workflowId: string;
|
||||
relativePath: string;
|
||||
}): Promise<SkillResourceContent> => {
|
||||
log('readSkillResource: request skillId=%s path=%s', skillId, relativePath);
|
||||
}): Promise<WorkflowResourceContent> => {
|
||||
log('readWorkflowResource: request workflowId=%s path=%s', workflowId, relativePath);
|
||||
const response = await callCoreRpc<
|
||||
Envelope<RawSkillsReadResourceResult> | RawSkillsReadResourceResult
|
||||
Envelope<RawWorkflowsReadResourceResult> | RawWorkflowsReadResourceResult
|
||||
>({
|
||||
method: 'openhuman.workflows_read_resource',
|
||||
params: { workflow_id: skillId, relative_path: relativePath },
|
||||
params: { workflow_id: workflowId, relative_path: relativePath },
|
||||
});
|
||||
const raw = unwrapEnvelope(response);
|
||||
const normalized: SkillResourceContent = {
|
||||
skillId: raw.workflow_id,
|
||||
const normalized: WorkflowResourceContent = {
|
||||
workflowId: raw.workflow_id,
|
||||
relativePath: raw.relative_path,
|
||||
content: raw.content,
|
||||
bytes: raw.bytes,
|
||||
};
|
||||
log('readSkillResource: response bytes=%d', normalized.bytes);
|
||||
log('readWorkflowResource: response bytes=%d', normalized.bytes);
|
||||
return normalized;
|
||||
},
|
||||
|
||||
@@ -260,12 +260,14 @@ export const skillsApi = {
|
||||
* Scaffold a new SKILL.md skill via `openhuman.workflows_create`.
|
||||
*
|
||||
* The Rust side slugifies the name, writes `SKILL.md` with the supplied
|
||||
* frontmatter, and returns the freshly-discovered `SkillSummary` so the
|
||||
* frontmatter, and returns the freshly-discovered `WorkflowSummary` so the
|
||||
* caller can insert the new row into the grid without a full refetch.
|
||||
*/
|
||||
createSkill: async (input: CreateSkillInput): Promise<SkillSummary> => {
|
||||
log('createSkill: request name=%s scope=%s', input.name, input.scope ?? 'default');
|
||||
const response = await callCoreRpc<Envelope<RawSkillsCreateResult> | RawSkillsCreateResult>({
|
||||
createWorkflow: async (input: CreateWorkflowInput): Promise<WorkflowSummary> => {
|
||||
log('createWorkflow: request name=%s scope=%s', input.name, input.scope ?? 'default');
|
||||
const response = await callCoreRpc<
|
||||
Envelope<RawWorkflowsCreateResult> | RawWorkflowsCreateResult
|
||||
>({
|
||||
method: 'openhuman.workflows_create',
|
||||
params: {
|
||||
name: input.name,
|
||||
@@ -282,9 +284,9 @@ export const skillsApi = {
|
||||
},
|
||||
});
|
||||
const raw = unwrapEnvelope(response);
|
||||
const skill = normalizeSkillSummary(raw.skill);
|
||||
log('createSkill: response id=%s', skill.id);
|
||||
return skill;
|
||||
const workflow = normalizeWorkflowSummary(raw.workflow);
|
||||
log('createWorkflow: response id=%s', workflow.id);
|
||||
return workflow;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -293,9 +295,11 @@ export const skillsApi = {
|
||||
* slug — rewriting frontmatter + workflow.toml while preserving the
|
||||
* hand-authored SKILL.md/WORKFLOW.md body.
|
||||
*/
|
||||
updateSkill: async (input: CreateSkillInput): Promise<SkillSummary> => {
|
||||
log('updateSkill: request name=%s scope=%s', input.name, input.scope ?? 'default');
|
||||
const response = await callCoreRpc<Envelope<RawSkillsCreateResult> | RawSkillsCreateResult>({
|
||||
updateWorkflow: async (input: CreateWorkflowInput): Promise<WorkflowSummary> => {
|
||||
log('updateWorkflow: request name=%s scope=%s', input.name, input.scope ?? 'default');
|
||||
const response = await callCoreRpc<
|
||||
Envelope<RawWorkflowsCreateResult> | RawWorkflowsCreateResult
|
||||
>({
|
||||
method: 'openhuman.workflows_update',
|
||||
params: {
|
||||
name: input.name,
|
||||
@@ -312,9 +316,9 @@ export const skillsApi = {
|
||||
},
|
||||
});
|
||||
const raw = unwrapEnvelope(response);
|
||||
const skill = normalizeSkillSummary(raw.skill);
|
||||
log('updateSkill: response id=%s', skill.id);
|
||||
return skill;
|
||||
const workflow = normalizeWorkflowSummary(raw.workflow);
|
||||
log('updateWorkflow: response id=%s', workflow.id);
|
||||
return workflow;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -327,12 +331,12 @@ export const skillsApi = {
|
||||
* is normalised to its `raw.githubusercontent.com` equivalent. Size is
|
||||
* capped at 1 MiB; timeout default 60s, max 600s.
|
||||
*/
|
||||
installSkillFromUrl: async (
|
||||
input: InstallSkillFromUrlInput
|
||||
): Promise<InstallSkillFromUrlResult> => {
|
||||
log('installSkillFromUrl: request url=%s', input.url);
|
||||
installWorkflowFromUrl: async (
|
||||
input: InstallWorkflowFromUrlInput
|
||||
): Promise<InstallWorkflowFromUrlResult> => {
|
||||
log('installWorkflowFromUrl: request url=%s', input.url);
|
||||
const response = await callCoreRpc<
|
||||
Envelope<RawInstallSkillFromUrlResult> | RawInstallSkillFromUrlResult
|
||||
Envelope<RawInstallWorkflowFromUrlResult> | RawInstallWorkflowFromUrlResult
|
||||
>({
|
||||
method: 'openhuman.workflows_install_from_url',
|
||||
params: {
|
||||
@@ -341,15 +345,15 @@ export const skillsApi = {
|
||||
},
|
||||
});
|
||||
const raw = unwrapEnvelope(response);
|
||||
const normalized: InstallSkillFromUrlResult = {
|
||||
const normalized: InstallWorkflowFromUrlResult = {
|
||||
url: raw.url,
|
||||
stdout: raw.stdout,
|
||||
stderr: raw.stderr,
|
||||
newSkills: raw.new_skills ?? [],
|
||||
newWorkflows: raw.new_workflows ?? [],
|
||||
};
|
||||
log(
|
||||
'installSkillFromUrl: response new=%d stdout=%d stderr=%d',
|
||||
normalized.newSkills.length,
|
||||
'installWorkflowFromUrl: response new=%d stdout=%d stderr=%d',
|
||||
normalized.newWorkflows.length,
|
||||
normalized.stdout.length,
|
||||
normalized.stderr.length
|
||||
);
|
||||
@@ -365,37 +369,41 @@ export const skillsApi = {
|
||||
* canonicalises paths and refuses names with separators / traversal
|
||||
* sequences / anything outside the skills root.
|
||||
*/
|
||||
uninstallSkill: async (name: string): Promise<UninstallSkillResult> => {
|
||||
log('uninstallSkill: request name=%s', name);
|
||||
const response = await callCoreRpc<Envelope<RawUninstallSkillResult> | RawUninstallSkillResult>(
|
||||
{ method: 'openhuman.workflows_uninstall', params: { name } }
|
||||
);
|
||||
uninstallWorkflow: async (name: string): Promise<UninstallWorkflowResult> => {
|
||||
log('uninstallWorkflow: request name=%s', name);
|
||||
const response = await callCoreRpc<
|
||||
Envelope<RawUninstallWorkflowResult> | RawUninstallWorkflowResult
|
||||
>({ method: 'openhuman.workflows_uninstall', params: { name } });
|
||||
const raw = unwrapEnvelope(response);
|
||||
const normalized: UninstallSkillResult = {
|
||||
const normalized: UninstallWorkflowResult = {
|
||||
name: raw.name,
|
||||
removedPath: raw.removed_path,
|
||||
scope: raw.scope,
|
||||
};
|
||||
log('uninstallSkill: response name=%s removedPath=%s', normalized.name, normalized.removedPath);
|
||||
log(
|
||||
'uninstallWorkflow: response name=%s removedPath=%s',
|
||||
normalized.name,
|
||||
normalized.removedPath
|
||||
);
|
||||
return normalized;
|
||||
},
|
||||
|
||||
/**
|
||||
* Fetch the declared `[[inputs]]` for a single skill plus its display
|
||||
* metadata. Lightweight companion to `listSkills` — `SkillSummary` rows
|
||||
* metadata. Lightweight companion to `listWorkflows` — `WorkflowSummary` rows
|
||||
* (used by the catalog grid) deliberately don't include input
|
||||
* declarations, so the Skills Runner panel calls this once when the
|
||||
* user picks a skill from the dropdown so it can render the right form
|
||||
* controls.
|
||||
*/
|
||||
describeSkill: async (skillId: string): Promise<SkillDescription> => {
|
||||
log('describeSkill: request skillId=%s', skillId);
|
||||
const response = await callCoreRpc<Envelope<SkillDescription> | SkillDescription>({
|
||||
describeWorkflow: async (workflowId: string): Promise<WorkflowDescription> => {
|
||||
log('describeWorkflow: request workflowId=%s', workflowId);
|
||||
const response = await callCoreRpc<Envelope<WorkflowDescription> | WorkflowDescription>({
|
||||
method: 'openhuman.workflows_describe',
|
||||
params: { workflow_id: skillId },
|
||||
params: { workflow_id: workflowId },
|
||||
});
|
||||
const raw = unwrapEnvelope(response);
|
||||
log('describeSkill: response inputs=%d', raw.inputs.length);
|
||||
log('describeWorkflow: response inputs=%d', raw.inputs.length);
|
||||
return raw;
|
||||
},
|
||||
|
||||
@@ -406,14 +414,17 @@ export const skillsApi = {
|
||||
* autonomous work continues in the background and finishes with
|
||||
* status `DONE` / `DEGENERATE` / `FAILED` in the run log.
|
||||
*/
|
||||
runSkill: async (skillId: string, inputs: Record<string, unknown>): Promise<SkillRunStarted> => {
|
||||
log('runSkill: request skillId=%s', skillId);
|
||||
const response = await callCoreRpc<Envelope<SkillRunStarted> | SkillRunStarted>({
|
||||
runWorkflow: async (
|
||||
workflowId: string,
|
||||
inputs: Record<string, unknown>
|
||||
): Promise<WorkflowRunStarted> => {
|
||||
log('runWorkflow: request workflowId=%s', workflowId);
|
||||
const response = await callCoreRpc<Envelope<WorkflowRunStarted> | WorkflowRunStarted>({
|
||||
method: 'openhuman.workflows_run',
|
||||
params: { workflow_id: skillId, inputs },
|
||||
params: { workflow_id: workflowId, inputs },
|
||||
});
|
||||
const raw = unwrapEnvelope(response);
|
||||
log('runSkill: response runId=%s log=%s', raw.run_id, raw.log);
|
||||
log('runWorkflow: response runId=%s log=%s', raw.run_id, raw.log);
|
||||
return raw;
|
||||
},
|
||||
|
||||
@@ -459,13 +470,13 @@ export const skillsApi = {
|
||||
|
||||
/**
|
||||
* Recent autonomous skill runs from `<workspace>/skills/.runs/`. Sorted
|
||||
* by start time descending. Pass `skillId` to filter to one skill,
|
||||
* by start time descending. Pass `workflowId` to filter to one skill,
|
||||
* omit for cross-skill. `limit` defaults to 20 (max 100).
|
||||
*/
|
||||
recentRuns: async (skillId?: string, limit?: number): Promise<ScannedRun[]> => {
|
||||
log('recentRuns: request skillId=%s limit=%s', skillId ?? '*', limit ?? 'default');
|
||||
recentRuns: async (workflowId?: string, limit?: number): Promise<ScannedRun[]> => {
|
||||
log('recentRuns: request workflowId=%s limit=%s', workflowId ?? '*', limit ?? 'default');
|
||||
const params: Record<string, unknown> = {};
|
||||
if (skillId !== undefined) params.workflow_id = skillId;
|
||||
if (workflowId !== undefined) params.workflow_id = workflowId;
|
||||
if (limit !== undefined) params.limit = limit;
|
||||
const response = await callCoreRpc<Envelope<{ runs: ScannedRun[] }> | { runs: ScannedRun[] }>({
|
||||
method: 'openhuman.workflows_recent_runs',
|
||||
@@ -482,7 +493,7 @@ export const skillsApi = {
|
||||
* `openhuman.workflows_describe`. The FE renders one form control per entry:
|
||||
* `string`/`integer`/`boolean` map to text/number/checkbox controls.
|
||||
*/
|
||||
export interface SkillInputDescription {
|
||||
export interface WorkflowInputDescription {
|
||||
name: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
@@ -491,15 +502,15 @@ export interface SkillInputDescription {
|
||||
}
|
||||
|
||||
/** Wire shape returned by `openhuman.workflows_describe`. */
|
||||
export interface SkillDescription {
|
||||
export interface WorkflowDescription {
|
||||
id: string;
|
||||
display_name: string;
|
||||
when_to_use: string;
|
||||
inputs: SkillInputDescription[];
|
||||
inputs: WorkflowInputDescription[];
|
||||
}
|
||||
|
||||
/** Wire shape returned by `openhuman.workflows_run` (fire-and-forget). */
|
||||
export interface SkillRunStarted {
|
||||
export interface WorkflowRunStarted {
|
||||
run_id: string;
|
||||
status: string; // "started"
|
||||
workflow_id: string;
|
||||
@@ -2116,6 +2116,16 @@ pub async fn bootstrap_core_runtime(host_kind: crate::core::types::HostKind) {
|
||||
action_dir,
|
||||
);
|
||||
|
||||
// --- Triggered-workflow subscriber ---
|
||||
// Install on the always-run serve boot, not only inside `start_channels`
|
||||
// (skipped for web-chat-only cores with no messaging integrations, and when
|
||||
// `OPENHUMAN_DISABLE_CHANNEL_LISTENERS=1`). Without this, any workflow
|
||||
// declaring `triggers:` was silently ignored on web-chat-only desktop
|
||||
// installs. Idempotent — shares a process-global OnceLock with the
|
||||
// `start_channels` site so it registers exactly once regardless of which
|
||||
// path runs first. (Matching only for now; activation handoff still pending.)
|
||||
crate::openhuman::workflows::bus::ensure_triggered_workflow_subscriber(&workspace_dir);
|
||||
|
||||
// --- Approval gate (#1339) ---
|
||||
// ON by default; opt out with `OPENHUMAN_APPROVAL_GATE=0` (or `false`).
|
||||
// Prompt-class `external_effect()` tool calls route through
|
||||
|
||||
@@ -418,7 +418,7 @@ async fn render_integrations_agent(config: &Config, toolkit: &str) -> Result<Dum
|
||||
model_name: &model_name,
|
||||
agent_id: INTEGRATIONS_AGENT_ID,
|
||||
tools: &prompt_tools,
|
||||
skills: agent.skills(),
|
||||
workflows: agent.workflows(),
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &empty_visible,
|
||||
|
||||
@@ -70,9 +70,9 @@ pub struct ParentExecutionContext {
|
||||
/// dispatcher choice, …).
|
||||
pub agent_config: AgentConfig,
|
||||
|
||||
/// Skills loaded into the parent. Sub-agents that don't strip the
|
||||
/// skills catalog inherit this list.
|
||||
pub skills: Arc<Vec<Workflow>>,
|
||||
/// Workflows loaded into the parent. Sub-agents that don't strip the
|
||||
/// workflows catalog inherit this list.
|
||||
pub workflows: Arc<Vec<Workflow>>,
|
||||
|
||||
/// Memory context loaded for the current turn. Auto-injected into
|
||||
/// subagent prompts so they have access to conversation history and
|
||||
|
||||
@@ -531,7 +531,7 @@ fn datetime_section_output_matches_iso8601_date_and_utc_offset_pattern() {
|
||||
model_name: "test-model",
|
||||
agent_id: "",
|
||||
tools: EMPTY_TOOLS,
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: crate::openhuman::agent::prompts::LearnedContextData::default(),
|
||||
visible_tool_names: &EMPTY_FILTER,
|
||||
|
||||
@@ -1050,7 +1050,7 @@ impl Agent {
|
||||
.temperature(effective_temperature)
|
||||
.workspace_dir(config.workspace_dir.clone())
|
||||
.action_dir(config.action_dir.clone())
|
||||
.skills(crate::openhuman::workflows::load_workflow_metadata(
|
||||
.workflows(crate::openhuman::workflows::load_workflow_metadata(
|
||||
&config.workspace_dir,
|
||||
))
|
||||
.auto_save(config.memory.auto_save)
|
||||
|
||||
@@ -32,7 +32,7 @@ impl AgentBuilder {
|
||||
temperature: None,
|
||||
workspace_dir: None,
|
||||
action_dir: None,
|
||||
skills: None,
|
||||
workflows: None,
|
||||
auto_save: None,
|
||||
post_turn_hooks: Vec::new(),
|
||||
learning_enabled: false,
|
||||
@@ -160,8 +160,8 @@ impl AgentBuilder {
|
||||
}
|
||||
|
||||
/// Sets the skills available to the agent.
|
||||
pub fn skills(mut self, skills: Vec<crate::openhuman::workflows::Workflow>) -> Self {
|
||||
self.skills = Some(skills);
|
||||
pub fn workflows(mut self, skills: Vec<crate::openhuman::workflows::Workflow>) -> Self {
|
||||
self.workflows = Some(skills);
|
||||
self
|
||||
}
|
||||
|
||||
@@ -517,7 +517,7 @@ impl AgentBuilder {
|
||||
temperature: self.temperature.unwrap_or(0.7),
|
||||
workspace_dir,
|
||||
action_dir,
|
||||
skills: self.skills.unwrap_or_default(),
|
||||
workflows: self.workflows.unwrap_or_default(),
|
||||
auto_save: self.auto_save.unwrap_or(false),
|
||||
last_memory_context: None,
|
||||
last_turn_citations: Vec::new(),
|
||||
|
||||
@@ -109,9 +109,9 @@ impl Agent {
|
||||
self.temperature
|
||||
}
|
||||
|
||||
/// The agent's loaded skills, if any.
|
||||
pub fn skills(&self) -> &[crate::openhuman::workflows::Workflow] {
|
||||
&self.skills
|
||||
/// The agent's loaded workflows, if any.
|
||||
pub fn workflows(&self) -> &[crate::openhuman::workflows::Workflow] {
|
||||
&self.workflows
|
||||
}
|
||||
|
||||
/// Active Composio integrations fetched at session start.
|
||||
|
||||
@@ -271,7 +271,7 @@ fn accessors_and_history_reset_expose_agent_runtime_state() {
|
||||
});
|
||||
let mut agent = make_agent(provider);
|
||||
agent.history = vec![ConversationMessage::Chat(ChatMessage::system("sys"))];
|
||||
agent.skills = vec![crate::openhuman::workflows::Workflow {
|
||||
agent.workflows = vec![crate::openhuman::workflows::Workflow {
|
||||
name: "demo".into(),
|
||||
..Default::default()
|
||||
}];
|
||||
@@ -283,7 +283,7 @@ fn accessors_and_history_reset_expose_agent_runtime_state() {
|
||||
assert_eq!(agent.workspace_dir(), agent.workspace_dir.as_path());
|
||||
assert_eq!(agent.model_name(), agent.model_name);
|
||||
assert_eq!(agent.temperature(), agent.temperature);
|
||||
assert_eq!(agent.skills().len(), 1);
|
||||
assert_eq!(agent.workflows().len(), 1);
|
||||
assert_eq!(
|
||||
agent.agent_config().max_tool_iterations,
|
||||
agent.config.max_tool_iterations
|
||||
|
||||
@@ -293,7 +293,7 @@ impl Agent {
|
||||
model_name: &self.model_name,
|
||||
agent_id: &self.agent_definition_name,
|
||||
tools: &prompt_tools,
|
||||
skills: &self.skills,
|
||||
workflows: &self.workflows,
|
||||
dispatcher_instructions: &instructions,
|
||||
learned,
|
||||
visible_tool_names: &prompt_visible_tool_names,
|
||||
|
||||
@@ -273,11 +273,11 @@ impl Agent {
|
||||
// heuristic and size cap rationale.
|
||||
let enriched = {
|
||||
use crate::openhuman::workflows::inject;
|
||||
let matches = inject::match_workflows(&self.skills, user_message);
|
||||
let matches = inject::match_workflows(&self.workflows, user_message);
|
||||
if matches.is_empty() {
|
||||
log::debug!(
|
||||
"[skills:inject] no skill matches for user message (skill_catalog_len={})",
|
||||
self.skills.len()
|
||||
"[workflows:inject] no skill matches for user message (skill_catalog_len={})",
|
||||
self.workflows.len()
|
||||
);
|
||||
enriched
|
||||
} else {
|
||||
@@ -288,7 +288,7 @@ impl Agent {
|
||||
);
|
||||
let matched_count = injection.decisions.iter().filter(|d| d.matched).count();
|
||||
log::info!(
|
||||
"[skills:inject] summary candidates={} matched={} injected_bytes={} truncated_any={}",
|
||||
"[workflows:inject] summary candidates={} matched={} injected_bytes={} truncated_any={}",
|
||||
injection.decisions.len(),
|
||||
matched_count,
|
||||
injection.injected_bytes,
|
||||
|
||||
@@ -124,7 +124,7 @@ impl Agent {
|
||||
workspace_dir: self.workspace_dir.clone(),
|
||||
memory: Arc::clone(&self.memory),
|
||||
agent_config: self.config.clone(),
|
||||
skills: Arc::new(self.skills.clone()),
|
||||
workflows: Arc::new(self.workflows.clone()),
|
||||
memory_context: Arc::new(self.last_memory_context.clone()),
|
||||
session_id: self.event_session_id().to_string(),
|
||||
channel: self.event_channel().to_string(),
|
||||
|
||||
@@ -447,7 +447,7 @@ fn trim_history_snaps_past_orphaned_tool_results() {
|
||||
fn build_parent_context_and_sanitize_helpers_cover_snapshot_paths() {
|
||||
let mut agent = make_agent(None);
|
||||
agent.last_memory_context = Some("remember this".into());
|
||||
agent.skills = vec![crate::openhuman::workflows::Workflow {
|
||||
agent.workflows = vec![crate::openhuman::workflows::Workflow {
|
||||
name: "demo".into(),
|
||||
..Default::default()
|
||||
}];
|
||||
@@ -458,7 +458,7 @@ fn build_parent_context_and_sanitize_helpers_cover_snapshot_paths() {
|
||||
assert_eq!(parent.memory_context.as_deref(), Some("remember this"));
|
||||
assert_eq!(parent.session_id, "turn-test-session");
|
||||
assert_eq!(parent.channel, "turn-test-channel");
|
||||
assert_eq!(parent.skills.len(), 1);
|
||||
assert_eq!(parent.workflows.len(), 1);
|
||||
|
||||
assert_eq!(sanitize_learned_entry(" "), "");
|
||||
assert_eq!(
|
||||
|
||||
@@ -56,7 +56,7 @@ pub struct Agent {
|
||||
pub(super) temperature: f64,
|
||||
pub(super) workspace_dir: std::path::PathBuf,
|
||||
pub(super) action_dir: std::path::PathBuf,
|
||||
pub(super) skills: Vec<crate::openhuman::workflows::Workflow>,
|
||||
pub(super) workflows: Vec<crate::openhuman::workflows::Workflow>,
|
||||
/// Agent workflows discovered at session start.
|
||||
pub(super) auto_save: bool,
|
||||
/// Last memory context loaded for the current turn. Stored so it can
|
||||
@@ -275,7 +275,7 @@ pub struct AgentBuilder {
|
||||
pub(super) temperature: Option<f64>,
|
||||
pub(super) workspace_dir: Option<std::path::PathBuf>,
|
||||
pub(super) action_dir: Option<std::path::PathBuf>,
|
||||
pub(super) skills: Option<Vec<crate::openhuman::workflows::Workflow>>,
|
||||
pub(super) workflows: Option<Vec<crate::openhuman::workflows::Workflow>>,
|
||||
/// Agent workflows to surface in the prompt. Populated from `load_workflows`
|
||||
/// at session start; defaults to empty when not explicitly set.
|
||||
pub(super) auto_save: Option<bool>,
|
||||
|
||||
@@ -563,7 +563,7 @@ async fn run_typed_mode(
|
||||
model_name: &model,
|
||||
agent_id: &definition.id,
|
||||
tools: &prompt_tools,
|
||||
skills: &parent.skills,
|
||||
workflows: &parent.workflows,
|
||||
dispatcher_instructions: &dispatcher_instructions,
|
||||
learned: crate::openhuman::context::prompt::LearnedContextData::default(),
|
||||
visible_tool_names: &visible_tool_names,
|
||||
|
||||
@@ -326,7 +326,7 @@ fn make_parent(provider: Arc<dyn Provider>, tools: Vec<Box<dyn Tool>>) -> Parent
|
||||
workspace_dir: std::env::temp_dir(),
|
||||
memory: noop_memory(),
|
||||
agent_config: crate::openhuman::config::AgentConfig::default(),
|
||||
skills: Arc::new(vec![]),
|
||||
workflows: Arc::new(vec![]),
|
||||
memory_context: Arc::new(None),
|
||||
session_id: "test-session".into(),
|
||||
channel: "test".into(),
|
||||
|
||||
@@ -1599,7 +1599,7 @@ async fn orchestrator_prompt_drives_composio_call_via_delegation_chain() {
|
||||
model_name: "test",
|
||||
agent_id: "orchestrator",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: EMPTY.get_or_init(HashSet::new),
|
||||
|
||||
@@ -870,7 +870,7 @@ mod tests {
|
||||
model_name: "test-model",
|
||||
agent_id: "orchestrator",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &visible_tool_names,
|
||||
|
||||
@@ -58,7 +58,7 @@ fn prompt_builder_assembles_sections() {
|
||||
model_name: "test-model",
|
||||
agent_id: "",
|
||||
tools: &prompt_tools,
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "instr",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &NO_FILTER,
|
||||
@@ -92,7 +92,7 @@ fn identity_section_creates_missing_workspace_files() {
|
||||
model_name: "test-model",
|
||||
agent_id: "",
|
||||
tools: &prompt_tools,
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &NO_FILTER,
|
||||
@@ -135,7 +135,7 @@ fn datetime_section_includes_timestamp_and_timezone() {
|
||||
model_name: "test-model",
|
||||
agent_id: "",
|
||||
tools: &prompt_tools,
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "instr",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &NO_FILTER,
|
||||
@@ -180,7 +180,7 @@ fn ctx_with_identity(identity: Option<UserIdentity>) -> PromptContext<'static> {
|
||||
model_name: "test-model",
|
||||
agent_id: "",
|
||||
tools: EMPTY_TOOLS,
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: visible,
|
||||
@@ -322,7 +322,7 @@ fn tools_section_pformat_renders_signature_not_schema() {
|
||||
model_name: "test-model",
|
||||
agent_id: "",
|
||||
tools: &prompt_tools,
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &NO_FILTER,
|
||||
@@ -365,7 +365,7 @@ fn tools_section_uses_pformat_signature_for_text_dispatchers() {
|
||||
model_name: "test-model",
|
||||
agent_id: "",
|
||||
tools: &prompt_tools,
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &NO_FILTER,
|
||||
@@ -415,7 +415,7 @@ fn user_memory_section_renders_namespaces_with_headings() {
|
||||
model_name: "test-model",
|
||||
agent_id: "",
|
||||
tools: &prompt_tools,
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned,
|
||||
visible_tool_names: &NO_FILTER,
|
||||
@@ -494,7 +494,7 @@ fn user_memory_section_returns_empty_when_no_summaries() {
|
||||
model_name: "test-model",
|
||||
agent_id: "",
|
||||
tools: &prompt_tools,
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned,
|
||||
visible_tool_names: &NO_FILTER,
|
||||
@@ -1142,7 +1142,7 @@ fn for_subagent_builder_injects_user_files_even_when_identity_omitted() {
|
||||
model_name: "test-model",
|
||||
agent_id: "",
|
||||
tools: &prompt_tools,
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &NO_FILTER,
|
||||
@@ -1188,7 +1188,7 @@ fn for_subagent_builder_injects_user_files_even_when_identity_omitted() {
|
||||
model_name: "test-model",
|
||||
agent_id: "",
|
||||
tools: &prompt_tools,
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &NO_FILTER,
|
||||
@@ -1265,7 +1265,7 @@ fn prompt_tool_constructors_and_user_memory_skip_empty_bodies() {
|
||||
model_name: "model",
|
||||
agent_id: "",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData {
|
||||
tree_root_summaries: vec![ns_summary("user", "kept"), ns_summary("empty", " ")],
|
||||
@@ -1296,7 +1296,7 @@ fn ctx_with_learned(learned: LearnedContextData) -> PromptContext<'static> {
|
||||
model_name: "test-model",
|
||||
agent_id: "",
|
||||
tools: prompt_tools,
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned,
|
||||
visible_tool_names: &NO_FILTER,
|
||||
@@ -1435,7 +1435,7 @@ fn tools_section_empty_for_native() {
|
||||
model_name: "test-model",
|
||||
agent_id: "",
|
||||
tools: &prompt_tools,
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &NO_FILTER,
|
||||
@@ -1468,7 +1468,7 @@ fn tools_section_nonempty_for_pformat() {
|
||||
model_name: "test-model",
|
||||
agent_id: "",
|
||||
tools: &prompt_tools,
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &NO_FILTER,
|
||||
@@ -1503,7 +1503,7 @@ fn tools_section_native_with_dispatcher_instructions_returns_instructions() {
|
||||
model_name: "test-model",
|
||||
agent_id: "",
|
||||
tools: &prompt_tools,
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "## Tool Use Protocol\n\nUse native tool calling.",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &NO_FILTER,
|
||||
|
||||
@@ -640,7 +640,7 @@ pub fn default_workspace_file_content(filename: &str) -> &'static str {
|
||||
/// manufacture a full context when they only need the static text.
|
||||
fn empty_prompt_context_for_static_sections() -> PromptContext<'static> {
|
||||
static EMPTY_TOOLS: &[PromptTool<'static>] = &[];
|
||||
static EMPTY_SKILLS: &[crate::openhuman::workflows::Workflow] = &[];
|
||||
static EMPTY_WORKFLOWS: &[crate::openhuman::workflows::Workflow] = &[];
|
||||
static EMPTY_INTEGRATIONS: &[ConnectedIntegration] = &[];
|
||||
// SAFETY: the &HashSet reference must outlive the returned context;
|
||||
// a leaked OnceLock-style allocation gives us a permanent 'static
|
||||
@@ -652,7 +652,7 @@ fn empty_prompt_context_for_static_sections() -> PromptContext<'static> {
|
||||
model_name: "",
|
||||
agent_id: "",
|
||||
tools: EMPTY_TOOLS,
|
||||
skills: EMPTY_SKILLS,
|
||||
workflows: EMPTY_WORKFLOWS,
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: visible,
|
||||
|
||||
@@ -339,7 +339,7 @@ pub struct PromptContext<'a> {
|
||||
/// Id of the agent this prompt is being built for.
|
||||
pub agent_id: &'a str,
|
||||
pub tools: &'a [PromptTool<'a>],
|
||||
pub skills: &'a [Workflow],
|
||||
pub workflows: &'a [Workflow],
|
||||
pub dispatcher_instructions: &'a str,
|
||||
/// Pre-fetched learned context (empty when learning is disabled).
|
||||
pub learned: LearnedContextData,
|
||||
|
||||
@@ -99,6 +99,14 @@ mod guard {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test-only reader for the process-lifetime spawn counter. Used by the
|
||||
/// regression test that asserts a rejected spawn (e.g. unknown workflow
|
||||
/// id) doesn't consume a backstop slot.
|
||||
#[cfg(test)]
|
||||
pub fn total_spawns() -> u64 {
|
||||
TOTAL_SPAWNS.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Acquire an await slot + re-entrancy lock for `key` (a workflow-id +
|
||||
/// inputs fingerprint, or `await:<run_id>` for re-attach). `Err` if too
|
||||
/// many awaits are in flight (nesting/fan-out cap) or the same key is
|
||||
@@ -555,6 +563,41 @@ mod tests {
|
||||
super::guard::acquire_await("cap-test-9".to_string()).expect("a freed slot is reusable");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_workflow_id_does_not_burn_a_spawn_slot() {
|
||||
// Regression: a rejected spawn (unknown workflow id) must NOT consume a
|
||||
// slot against the process-lifetime backstop. `account_spawn` runs only
|
||||
// in the `Ok(started)` arm — after `spawn_workflow_run_background`
|
||||
// succeeds — so an unknown id (which fails synchronously) never accounts
|
||||
// a spawn. Without this ordering, an agent retrying a bad id would
|
||||
// exhaust the 500-spawn budget for legitimate runs. Asserts the counter
|
||||
// DELTA is zero (the counter is global + monotonic, so absolute value is
|
||||
// shared with the backstop test — hence the serial lock + delta check).
|
||||
let _s = guard_serial().lock().unwrap();
|
||||
let before = super::guard::total_spawns();
|
||||
let t = RunWorkflowTool::new();
|
||||
// wait_seconds: 0 → fire-and-forget path (no await slot taken); the
|
||||
// unknown id makes the spawn fail before accounting.
|
||||
let res = t
|
||||
.execute(json!({
|
||||
"workflow_id": "definitely-not-a-real-workflow-zzz",
|
||||
"wait_seconds": 0
|
||||
}))
|
||||
.await
|
||||
.expect("Ok(ToolResult)");
|
||||
assert!(res.is_error, "unknown workflow id must return a tool error");
|
||||
assert!(
|
||||
res.output().contains("unknown") || res.output().contains("workflow"),
|
||||
"error should reference the unknown workflow: {}",
|
||||
res.output()
|
||||
);
|
||||
let after = super::guard::total_spawns();
|
||||
assert_eq!(
|
||||
before, after,
|
||||
"a rejected spawn must not increment the spawn backstop counter"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_spawn_trips_the_process_backstop() {
|
||||
let _s = guard_serial().lock().unwrap();
|
||||
|
||||
@@ -280,7 +280,7 @@ async fn dispatch_target_agent(agent_id: &str, prompt: &str) -> anyhow::Result<S
|
||||
workspace_dir: agent.workspace_dir().to_path_buf(),
|
||||
memory: agent.memory_arc(),
|
||||
agent_config: agent.agent_config().clone(),
|
||||
skills: Arc::new(agent.skills().to_vec()),
|
||||
workflows: Arc::new(agent.workflows().to_vec()),
|
||||
memory_context: Arc::new(None), // Sub-agent queries memory via tools if needed
|
||||
session_id: format!("triage-{}", uuid::Uuid::new_v4()),
|
||||
channel: "triage".to_string(),
|
||||
|
||||
@@ -735,7 +735,7 @@ fn extract_inline_prompt(def: &AgentDefinition) -> Option<String> {
|
||||
model_name: "",
|
||||
agent_id: &def.id,
|
||||
tools: &empty_tools,
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &empty_visible,
|
||||
|
||||
@@ -86,7 +86,7 @@ fn parent_context(provider: Arc<dyn Provider>) -> ParentExecutionContext {
|
||||
workspace_dir: std::env::temp_dir(),
|
||||
memory: Arc::new(NoopMemory),
|
||||
agent_config: AgentConfig::default(),
|
||||
skills: Arc::new(Vec::new()),
|
||||
workflows: Arc::new(Vec::new()),
|
||||
memory_context: Arc::new(None),
|
||||
session_id: "orchestrator-session".to_string(),
|
||||
channel: "test".to_string(),
|
||||
|
||||
@@ -201,7 +201,7 @@ fn parent_context_with_provider(
|
||||
workspace_dir: std::env::temp_dir(),
|
||||
memory: Arc::new(NoopMemory),
|
||||
agent_config,
|
||||
skills: Arc::new(Vec::new()),
|
||||
workflows: Arc::new(Vec::new()),
|
||||
memory_context: Arc::new(None),
|
||||
session_id: "session-test".into(),
|
||||
channel: "test".into(),
|
||||
|
||||
@@ -385,7 +385,7 @@ mod tests {
|
||||
channel: "test".into(),
|
||||
all_tools: Arc::new(vec![]),
|
||||
all_tool_specs: Arc::new(vec![]),
|
||||
skills: Arc::new(vec![]),
|
||||
workflows: Arc::new(vec![]),
|
||||
memory_context: std::sync::Arc::new(None),
|
||||
connected_integrations: vec![],
|
||||
on_progress: None,
|
||||
|
||||
@@ -193,7 +193,7 @@ fn parent_context(
|
||||
workspace_dir: workspace_dir.to_path_buf(),
|
||||
memory: Arc::new(NoopMemory),
|
||||
agent_config: Default::default(),
|
||||
skills: Arc::new(Vec::new()),
|
||||
workflows: Arc::new(Vec::new()),
|
||||
memory_context: Arc::new(None),
|
||||
session_id: "tools-e2e-session".into(),
|
||||
channel: "test".into(),
|
||||
|
||||
@@ -52,7 +52,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "archivist",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &visible,
|
||||
|
||||
@@ -56,7 +56,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "code_executor",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &visible,
|
||||
|
||||
@@ -51,7 +51,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "critic",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &visible,
|
||||
|
||||
@@ -19,7 +19,7 @@ pub fn build(ctx: &PromptContext<'_>) -> Result<String> {
|
||||
agent_id = ctx.agent_id,
|
||||
model = ctx.model_name,
|
||||
tool_count = ctx.tools.len(),
|
||||
skill_count = ctx.skills.len(),
|
||||
workflow_count = ctx.workflows.len(),
|
||||
"[agent_prompt][crypto_agent] build_start"
|
||||
);
|
||||
|
||||
@@ -77,7 +77,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "crypto_agent",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: EMPTY_VISIBLE.get_or_init(HashSet::new),
|
||||
|
||||
@@ -47,7 +47,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "desktop_control_agent",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &visible,
|
||||
|
||||
@@ -51,7 +51,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "help",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &visible,
|
||||
|
||||
@@ -185,7 +185,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "integrations_agent",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: EMPTY_VISIBLE.get_or_init(HashSet::new),
|
||||
|
||||
@@ -467,7 +467,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: &def.id,
|
||||
tools: &empty_tools,
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &empty_visible,
|
||||
|
||||
@@ -21,7 +21,7 @@ pub fn build(ctx: &PromptContext<'_>) -> Result<String> {
|
||||
agent_id = ctx.agent_id,
|
||||
model = ctx.model_name,
|
||||
tool_count = ctx.tools.len(),
|
||||
skill_count = ctx.skills.len(),
|
||||
workflow_count = ctx.workflows.len(),
|
||||
"[agent_prompt][markets_agent] build_start"
|
||||
);
|
||||
|
||||
@@ -79,7 +79,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "markets_agent",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: EMPTY_VISIBLE.get_or_init(HashSet::new),
|
||||
|
||||
@@ -52,7 +52,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "mcp_setup",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: visible,
|
||||
|
||||
@@ -67,7 +67,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "morning_briefing",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: visible,
|
||||
|
||||
@@ -200,7 +200,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "orchestrator",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: EMPTY_VISIBLE.get_or_init(HashSet::new),
|
||||
|
||||
@@ -58,7 +58,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "planner",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &visible,
|
||||
|
||||
@@ -47,7 +47,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "presentation_agent",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &visible,
|
||||
|
||||
@@ -52,7 +52,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "researcher",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &visible,
|
||||
|
||||
@@ -47,7 +47,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "scheduler_agent",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &visible,
|
||||
|
||||
@@ -101,7 +101,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "skill_creator",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &visible,
|
||||
|
||||
@@ -52,7 +52,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "summarizer",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &visible,
|
||||
|
||||
@@ -51,7 +51,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "task_manager_agent",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &visible,
|
||||
|
||||
@@ -56,7 +56,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "tool_maker",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &visible,
|
||||
|
||||
@@ -53,7 +53,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "tools_agent",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &visible,
|
||||
|
||||
@@ -52,7 +52,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "trigger_reactor",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &visible,
|
||||
|
||||
@@ -52,7 +52,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "trigger_triage",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &visible,
|
||||
|
||||
@@ -322,16 +322,10 @@ pub async fn start_channels(mut config: Config) -> Result<()> {
|
||||
|
||||
// Install the triggered-workflow subscriber now that workflows are
|
||||
// discovered — otherwise any workflow declaring `triggers:` is silently
|
||||
// ignored in the channel runtime. The handle is parked in a process static
|
||||
// so the RAII SubscriptionHandle isn't dropped (which would cancel it).
|
||||
{
|
||||
use crate::core::event_bus::SubscriptionHandle;
|
||||
use std::sync::OnceLock;
|
||||
static TRIGGERED_WORKFLOW_HANDLE: OnceLock<Option<SubscriptionHandle>> = OnceLock::new();
|
||||
TRIGGERED_WORKFLOW_HANDLE.get_or_init(|| {
|
||||
crate::openhuman::workflows::bus::register_triggered_workflow_subscriber(&skills)
|
||||
});
|
||||
}
|
||||
// ignored. Idempotent + shares a process-global OnceLock with the
|
||||
// `bootstrap_core_runtime` site, so it registers exactly once regardless of
|
||||
// which startup path runs first (web-chat-only cores never reach here).
|
||||
crate::openhuman::workflows::bus::ensure_triggered_workflow_subscriber(&workspace);
|
||||
|
||||
// Collect tool descriptions for the prompt
|
||||
let mut tool_descs: Vec<(&str, &str)> = vec![
|
||||
|
||||
@@ -296,7 +296,7 @@ mod tests {
|
||||
model_name: "test-model",
|
||||
agent_id: "",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned,
|
||||
visible_tool_names,
|
||||
|
||||
@@ -232,7 +232,7 @@ mod tests {
|
||||
model_name: "test",
|
||||
agent_id: "test",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &visible,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
use crate::core::event_bus::{subscribe_global, DomainEvent, EventHandler, SubscriptionHandle};
|
||||
use crate::openhuman::workflows::Workflow;
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
// ── Trigger pattern ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -109,7 +109,7 @@ impl TriggeredWorkflowIndex {
|
||||
let p = TriggerPattern::parse(t);
|
||||
if p.is_none() {
|
||||
log::warn!(
|
||||
"[skills::triggered] skill '{}': malformed trigger {:?} — skipping",
|
||||
"[workflows::triggered] skill '{}': malformed trigger {:?} — skipping",
|
||||
skill.name,
|
||||
t
|
||||
);
|
||||
@@ -184,7 +184,7 @@ impl EventHandler for TriggeredSkillSubscriber {
|
||||
tracing::debug!(
|
||||
domain = event.domain(),
|
||||
skills = ?matched,
|
||||
"[skills::triggered] event matches {} skill trigger(s); \
|
||||
"[workflows::triggered] event matches {} skill trigger(s); \
|
||||
activation handoff to integration layer pending",
|
||||
matched.len()
|
||||
);
|
||||
@@ -213,7 +213,7 @@ pub fn register_triggered_workflow_subscriber(skills: &[Workflow]) -> Option<Sub
|
||||
return None;
|
||||
}
|
||||
log::info!(
|
||||
"[skills::triggered] registering subscriber for {} skill(s) with event triggers (domains: {:?})",
|
||||
"[workflows::triggered] registering subscriber for {} skill(s) with event triggers (domains: {:?})",
|
||||
index.len(),
|
||||
index.domains()
|
||||
);
|
||||
@@ -222,6 +222,39 @@ pub fn register_triggered_workflow_subscriber(skills: &[Workflow]) -> Option<Sub
|
||||
}))
|
||||
}
|
||||
|
||||
/// Process-global parking spot for the triggered-workflow subscription
|
||||
/// handle. The RAII [`SubscriptionHandle`] must outlive the process (dropping
|
||||
/// it cancels the subscription), and registration must happen exactly once no
|
||||
/// matter how many startup paths reach it.
|
||||
static TRIGGERED_WORKFLOW_HANDLE: OnceLock<Option<SubscriptionHandle>> = OnceLock::new();
|
||||
|
||||
/// Idempotently install the triggered-workflow subscriber.
|
||||
///
|
||||
/// Loads workflow metadata from `workspace` and registers the subscriber on the
|
||||
/// **first** call; subsequent calls are no-ops (the handle is parked in
|
||||
/// [`TRIGGERED_WORKFLOW_HANDLE`] so the RAII guard isn't dropped). Safe to call
|
||||
/// from every startup path.
|
||||
///
|
||||
/// Both [`crate::openhuman::channels::start_channels`] (messaging cores) and
|
||||
/// [`crate::core::jsonrpc::bootstrap_core_runtime`] (always-run serve boot)
|
||||
/// invoke this. `start_channels` is skipped for web-chat-only desktop installs
|
||||
/// (no messaging integration connected) and when
|
||||
/// `OPENHUMAN_DISABLE_CHANNEL_LISTENERS=1`; registering from
|
||||
/// `bootstrap_core_runtime` too means those cores still honour workflow
|
||||
/// `triggers:`. The shared `OnceLock` guarantees a single registration
|
||||
/// regardless of which path runs first.
|
||||
///
|
||||
/// NOTE: the subscriber currently only *matches* triggers and logs — the
|
||||
/// activation handoff to the integration layer is still pending (see
|
||||
/// [`TriggeredSkillSubscriber::handle`]). Registering on web-chat-only cores
|
||||
/// enables matching, not yet activation.
|
||||
pub fn ensure_triggered_workflow_subscriber(workspace: &std::path::Path) {
|
||||
TRIGGERED_WORKFLOW_HANDLE.get_or_init(|| {
|
||||
let workflows = crate::openhuman::workflows::load_workflow_metadata(workspace);
|
||||
register_triggered_workflow_subscriber(&workflows)
|
||||
});
|
||||
}
|
||||
|
||||
/// Legacy no-op retained while call-sites migrate to
|
||||
/// [`register_triggered_workflow_subscriber`]. Safe to call multiple times.
|
||||
pub fn register_workflow_cleanup_subscriber() {}
|
||||
@@ -242,6 +275,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ── ensure_triggered_workflow_subscriber (C1 boot-path helper) ───────────
|
||||
|
||||
#[test]
|
||||
fn ensure_triggered_workflow_subscriber_is_idempotent_and_safe() {
|
||||
// Covers the boot-path helper called from both `start_channels` and
|
||||
// `bootstrap_core_runtime`: it loads workflow metadata from the
|
||||
// workspace and registers the subscriber exactly once via the
|
||||
// process-global OnceLock. An empty temp workspace yields no triggered
|
||||
// workflows, so registration resolves to `None`; the call must not
|
||||
// panic and must be safe to repeat (the OnceLock makes the second call
|
||||
// a no-op).
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
ensure_triggered_workflow_subscriber(tmp.path());
|
||||
ensure_triggered_workflow_subscriber(tmp.path());
|
||||
}
|
||||
|
||||
// ── TriggerPattern::parse ────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
//!
|
||||
//! ## Logging
|
||||
//!
|
||||
//! Every candidate emits a grep-friendly `[skills:inject]` log line
|
||||
//! Every candidate emits a grep-friendly `[workflows:inject]` log line
|
||||
//! with `matched=<bool>`, reason, and injected bytes (see
|
||||
//! [`render_injection`]). A summary line lives in the caller
|
||||
//! (`Agent::turn`).
|
||||
@@ -265,7 +265,10 @@ fn contains_whole_word(haystack_lower: &str, needle_lower: &str) -> bool {
|
||||
|
||||
/// Match installed skills against a user message per the heuristic
|
||||
/// documented at the top of this module.
|
||||
pub fn match_workflows<'a>(skills: &'a [Workflow], user_message: &str) -> Vec<WorkflowMatch<'a>> {
|
||||
pub fn match_workflows<'a>(
|
||||
workflows: &'a [Workflow],
|
||||
user_message: &str,
|
||||
) -> Vec<WorkflowMatch<'a>> {
|
||||
let mentions = extract_mentions(user_message);
|
||||
let mention_set: HashSet<String> = mentions.iter().map(|(n, _)| n.clone()).collect();
|
||||
let mention_index = |skill_norm: &str| -> Option<usize> {
|
||||
@@ -278,7 +281,7 @@ pub fn match_workflows<'a>(skills: &'a [Workflow], user_message: &str) -> Vec<Wo
|
||||
let lower_msg = user_message.to_lowercase();
|
||||
|
||||
let mut matches: Vec<WorkflowMatch<'a>> = Vec::new();
|
||||
for skill in skills {
|
||||
for skill in workflows {
|
||||
let normalised_name = normalise(&skill.name);
|
||||
let user_invocable = is_user_invocable(skill);
|
||||
|
||||
@@ -385,7 +388,7 @@ where
|
||||
Some(b) => b,
|
||||
None => {
|
||||
log::warn!(
|
||||
"[skills:inject] matched={} reason={} name={} skipped=body_unavailable",
|
||||
"[workflows:inject] matched={} reason={} name={} skipped=body_unavailable",
|
||||
false,
|
||||
"body_unavailable",
|
||||
name
|
||||
@@ -415,7 +418,7 @@ where
|
||||
let min_truncated = header_len + footer_trunc_len + 1;
|
||||
if remaining < min_truncated {
|
||||
log::info!(
|
||||
"[skills:inject] matched={} reason={} name={} skipped=budget_exhausted remaining_bytes={}",
|
||||
"[workflows:inject] matched={} reason={} name={} skipped=budget_exhausted remaining_bytes={}",
|
||||
false,
|
||||
"budget_exhausted",
|
||||
name,
|
||||
@@ -439,7 +442,7 @@ where
|
||||
rendered.push_str(&footer_full);
|
||||
let injected = header_len + body.len() + footer_full_len;
|
||||
log::debug!(
|
||||
"[skills:inject] matched={} reason={} name={} injected_bytes={} truncated={}",
|
||||
"[workflows:inject] matched={} reason={} name={} injected_bytes={} truncated={}",
|
||||
true,
|
||||
m.reason.as_str(),
|
||||
name,
|
||||
@@ -469,7 +472,7 @@ where
|
||||
truncated_any = true;
|
||||
let injected = header_len + truncated_body.len() + footer_trunc_len;
|
||||
log::warn!(
|
||||
"[skills:inject] matched={} reason={} name={} injected_bytes={} truncated={} body_bytes_total={} body_bytes_kept={}",
|
||||
"[workflows:inject] matched={} reason={} name={} injected_bytes={} truncated={} body_bytes_total={} body_bytes_kept={}",
|
||||
true,
|
||||
m.reason.as_str(),
|
||||
name,
|
||||
|
||||
@@ -302,7 +302,7 @@ impl Workflow {
|
||||
pub fn read_body(&self) -> Option<String> {
|
||||
if self.legacy {
|
||||
log::debug!(
|
||||
"[skills:inject] read_body skipped for legacy skill.json skill name={}",
|
||||
"[workflows:inject] read_body skipped for legacy skill.json skill name={}",
|
||||
self.name
|
||||
);
|
||||
return None;
|
||||
@@ -312,7 +312,7 @@ impl Workflow {
|
||||
Some((_, body, _)) => Some(body),
|
||||
None => {
|
||||
log::warn!(
|
||||
"[skills:inject] read_body failed to parse {} for skill {}",
|
||||
"[workflows:inject] read_body failed to parse {} for skill {}",
|
||||
path.display(),
|
||||
self.name
|
||||
);
|
||||
|
||||
@@ -243,36 +243,36 @@ pub async fn run_github_preflight<P: PreflightProbes>(
|
||||
probes: &P,
|
||||
) -> Result<(), GithubGateError> {
|
||||
let Some(cfg) = cfg else {
|
||||
tracing::debug!("[skills:preflight] github gate skipped: no [github] block");
|
||||
tracing::debug!("[workflows:preflight] github gate skipped: no [github] block");
|
||||
return Ok(());
|
||||
};
|
||||
if !cfg.required {
|
||||
tracing::debug!("[skills:preflight] github gate skipped: required = false");
|
||||
tracing::debug!("[workflows:preflight] github gate skipped: required = false");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// (1) Composio GitHub integration must be connected.
|
||||
if !probes.composio_toolkit_active("github").await {
|
||||
tracing::warn!("[skills:preflight] github gate fail: composio_github_missing");
|
||||
tracing::warn!("[workflows:preflight] github gate fail: composio_github_missing");
|
||||
return Err(GithubGateError::ComposioGithubMissing);
|
||||
}
|
||||
|
||||
// (2) git binary present.
|
||||
if let Err(e) = probes.git_version().await {
|
||||
tracing::warn!(error = %e, "[skills:preflight] github gate fail: git_binary_missing");
|
||||
tracing::warn!(error = %e, "[workflows:preflight] github gate fail: git_binary_missing");
|
||||
return Err(GithubGateError::GitBinaryMissing(e));
|
||||
}
|
||||
|
||||
// (3a) git user.name set.
|
||||
let git_name = probes.git_user_name().await;
|
||||
if git_name.is_empty() {
|
||||
tracing::warn!("[skills:preflight] github gate fail: git_user_name_missing");
|
||||
tracing::warn!("[workflows:preflight] github gate fail: git_user_name_missing");
|
||||
return Err(GithubGateError::GitUserNameMissing);
|
||||
}
|
||||
// (3b) git user.email set.
|
||||
let git_email = probes.git_user_email().await;
|
||||
if git_email.is_empty() {
|
||||
tracing::warn!("[skills:preflight] github gate fail: git_user_email_missing");
|
||||
tracing::warn!("[workflows:preflight] github gate fail: git_user_email_missing");
|
||||
return Err(GithubGateError::GitUserEmailMissing);
|
||||
}
|
||||
|
||||
@@ -280,7 +280,7 @@ pub async fn run_github_preflight<P: PreflightProbes>(
|
||||
match cfg.identity_match {
|
||||
IdentityMatch::None => {
|
||||
tracing::debug!(
|
||||
"[skills:preflight] github gate pass (identity_match=none, reachability only)"
|
||||
"[workflows:preflight] github gate pass (identity_match=none, reachability only)"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -289,12 +289,12 @@ pub async fn run_github_preflight<P: PreflightProbes>(
|
||||
// identity — confirms the connection is genuinely usable.
|
||||
match probes.composio_identity("github").await {
|
||||
Some(_) => {
|
||||
tracing::debug!("[skills:preflight] github gate pass (identity_match=any)");
|
||||
tracing::debug!("[workflows:preflight] github gate pass (identity_match=any)");
|
||||
Ok(())
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"[skills:preflight] github gate fail: composio_identity_unresolved (identity_match=any)"
|
||||
"[workflows:preflight] github gate fail: composio_identity_unresolved (identity_match=any)"
|
||||
);
|
||||
Err(GithubGateError::ComposioIdentityUnresolved)
|
||||
}
|
||||
@@ -305,7 +305,7 @@ pub async fn run_github_preflight<P: PreflightProbes>(
|
||||
Some(n) => n,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
"[skills:preflight] github gate fail: composio_identity_unresolved (identity_match=strict)"
|
||||
"[workflows:preflight] github gate fail: composio_identity_unresolved (identity_match=strict)"
|
||||
);
|
||||
return Err(GithubGateError::ComposioIdentityUnresolved);
|
||||
}
|
||||
@@ -314,14 +314,14 @@ pub async fn run_github_preflight<P: PreflightProbes>(
|
||||
tracing::debug!(
|
||||
composio = %composio_name,
|
||||
git = %git_name,
|
||||
"[skills:preflight] github gate pass (identity_match=strict)"
|
||||
"[workflows:preflight] github gate pass (identity_match=strict)"
|
||||
);
|
||||
Ok(())
|
||||
} else {
|
||||
tracing::warn!(
|
||||
composio = %composio_name,
|
||||
git = %git_name,
|
||||
"[skills:preflight] github gate fail: identity_mismatch"
|
||||
"[workflows:preflight] github gate fail: identity_mismatch"
|
||||
);
|
||||
Err(GithubGateError::IdentityMismatch {
|
||||
composio_username: composio_name,
|
||||
|
||||
@@ -43,7 +43,9 @@ pub(super) fn handle_workflows_list(params: Map<String, Value>) -> ControllerFut
|
||||
);
|
||||
let summaries = skills.into_iter().map(WorkflowSummary::from).collect();
|
||||
to_json(RpcOutcome::new(
|
||||
WorkflowsListResult { skills: summaries },
|
||||
WorkflowsListResult {
|
||||
workflows: summaries,
|
||||
},
|
||||
Vec::new(),
|
||||
))
|
||||
})
|
||||
@@ -231,7 +233,7 @@ pub(super) fn handle_workflows_create(params: Map<String, Value>) -> ControllerF
|
||||
);
|
||||
to_json(RpcOutcome::new(
|
||||
WorkflowsCreateResult {
|
||||
skill: WorkflowSummary::from(skill),
|
||||
workflow: WorkflowSummary::from(skill),
|
||||
},
|
||||
Vec::new(),
|
||||
))
|
||||
@@ -261,7 +263,7 @@ pub(super) fn handle_workflows_update(params: Map<String, Value>) -> ControllerF
|
||||
match create_workflow(workspace.as_path(), create_params) {
|
||||
Ok(skill) => to_json(RpcOutcome::new(
|
||||
WorkflowsCreateResult {
|
||||
skill: WorkflowSummary::from(skill),
|
||||
workflow: WorkflowSummary::from(skill),
|
||||
},
|
||||
Vec::new(),
|
||||
)),
|
||||
@@ -296,7 +298,7 @@ pub(super) fn handle_workflows_install_from_url(params: Map<String, Value>) -> C
|
||||
url: outcome.url,
|
||||
stdout: outcome.stdout,
|
||||
stderr: outcome.stderr,
|
||||
new_skills: outcome.new_skills,
|
||||
new_workflows: outcome.new_skills,
|
||||
},
|
||||
Vec::new(),
|
||||
))
|
||||
|
||||
@@ -193,7 +193,7 @@ impl From<Workflow> for WorkflowSummary {
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct WorkflowsListResult {
|
||||
pub(super) skills: Vec<WorkflowSummary>,
|
||||
pub(super) workflows: Vec<WorkflowSummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -206,7 +206,7 @@ pub(super) struct WorkflowsReadResourceResult {
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub(super) struct WorkflowsCreateResult {
|
||||
pub(super) skill: WorkflowSummary,
|
||||
pub(super) workflow: WorkflowSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -214,7 +214,7 @@ pub(super) struct WorkflowsInstallFromUrlResult {
|
||||
pub(super) url: String,
|
||||
pub(super) stdout: String,
|
||||
pub(super) stderr: String,
|
||||
pub(super) new_skills: Vec<String>,
|
||||
pub(super) new_workflows: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
|
||||
@@ -281,7 +281,7 @@ fn parent_context(workspace: &Path, provider: Arc<ScriptedProvider>) -> ParentEx
|
||||
workspace_dir: workspace.to_path_buf(),
|
||||
memory: Arc::new(StubMemory),
|
||||
agent_config: AgentConfig::default(),
|
||||
skills: Arc::new(Vec::new()),
|
||||
workflows: Arc::new(Vec::new()),
|
||||
memory_context: Arc::new(Some("parent memory".to_string())),
|
||||
session_id: "round21-parent-session".to_string(),
|
||||
channel: "round21-channel".to_string(),
|
||||
|
||||
@@ -182,7 +182,7 @@ fn builder_applies_defaults_and_exposes_public_accessors() {
|
||||
);
|
||||
assert_eq!(agent.temperature(), 0.7);
|
||||
assert_eq!(agent.workspace_dir(), std::path::Path::new("."));
|
||||
assert!(agent.skills().is_empty());
|
||||
assert!(agent.workflows().is_empty());
|
||||
assert!(agent.history().is_empty());
|
||||
assert_eq!(agent.agent_config().max_tool_iterations, 10);
|
||||
}
|
||||
|
||||
@@ -296,7 +296,7 @@ fn build_agent(
|
||||
.model_name("round19-model".to_string())
|
||||
.temperature(0.0)
|
||||
.workspace_dir(workspace.to_path_buf())
|
||||
.skills(Vec::new())
|
||||
.workflows(Vec::new())
|
||||
.auto_save(false)
|
||||
.event_context("round19-session", "round19-channel")
|
||||
.agent_definition_name("round19_agent")
|
||||
@@ -320,7 +320,7 @@ fn prompt_context<'a>(
|
||||
model_name: "round19-model",
|
||||
agent_id: "round19_agent",
|
||||
tools,
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "dispatcher guidance",
|
||||
learned,
|
||||
visible_tool_names: visible,
|
||||
@@ -389,7 +389,7 @@ fn parent_context(workspace: PathBuf, provider: Arc<ScriptedProvider>) -> Parent
|
||||
workspace_dir: workspace,
|
||||
memory: Arc::new(StubMemory::default()),
|
||||
agent_config: agent_config(3),
|
||||
skills: Arc::new(Vec::new()),
|
||||
workflows: Arc::new(Vec::new()),
|
||||
memory_context: Arc::new(Some("parent memory context".to_string())),
|
||||
session_id: "round19-parent-session".to_string(),
|
||||
channel: "round19-channel".to_string(),
|
||||
|
||||
@@ -138,7 +138,7 @@ fn stub_parent_context() -> ParentExecutionContext {
|
||||
workspace_dir: std::path::PathBuf::from("/tmp"),
|
||||
memory: Arc::new(StubMemory),
|
||||
agent_config: AgentConfig::default(),
|
||||
skills: Arc::new(vec![]),
|
||||
workflows: Arc::new(vec![]),
|
||||
memory_context: Arc::new(Some("ctx".into())),
|
||||
session_id: "test-session".into(),
|
||||
channel: "test-channel".into(),
|
||||
|
||||
@@ -250,7 +250,7 @@ fn build_agent_with_tools(
|
||||
.model_name("coverage-model".to_string())
|
||||
.temperature(0.0)
|
||||
.workspace_dir(workspace.to_path_buf())
|
||||
.skills(Vec::new())
|
||||
.workflows(Vec::new())
|
||||
.auto_save(false)
|
||||
.event_context("coverage-session", "coverage-channel")
|
||||
.agent_definition_name(agent_name)
|
||||
@@ -281,7 +281,7 @@ fn parent_context(workspace: PathBuf, provider: Arc<ScriptedProvider>) -> Parent
|
||||
workspace_dir: workspace,
|
||||
memory: Arc::new(StubMemory),
|
||||
agent_config: agent_config(),
|
||||
skills: Arc::new(Vec::new()),
|
||||
workflows: Arc::new(Vec::new()),
|
||||
memory_context: Arc::new(Some("parent memory context".to_string())),
|
||||
session_id: "parent-session".to_string(),
|
||||
channel: "coverage-channel".to_string(),
|
||||
|
||||
@@ -322,7 +322,7 @@ fn parent(workspace_dir: PathBuf, provider: Arc<ScriptedProvider>) -> ParentExec
|
||||
workspace_dir,
|
||||
memory: Arc::new(StubMemory),
|
||||
agent_config: AgentConfig::default(),
|
||||
skills: Arc::new(Vec::new()),
|
||||
workflows: Arc::new(Vec::new()),
|
||||
memory_context: Arc::new(Some("round25 inherited parent context".to_string())),
|
||||
session_id: "round25-session".to_string(),
|
||||
channel: "round25".to_string(),
|
||||
|
||||
@@ -287,7 +287,7 @@ fn parent(workspace: PathBuf, provider: Arc<ScriptedProvider>) -> ParentExecutio
|
||||
workspace_dir: workspace,
|
||||
memory: Arc::new(StubMemory),
|
||||
agent_config: AgentConfig::default(),
|
||||
skills: Arc::new(Vec::new()),
|
||||
workflows: Arc::new(Vec::new()),
|
||||
memory_context: Arc::new(Some("parent memory survives when allowed".to_string())),
|
||||
session_id: "round18-session".to_string(),
|
||||
channel: "round18".to_string(),
|
||||
@@ -310,7 +310,7 @@ fn prompt_context<'a>(
|
||||
model_name: "round18-model",
|
||||
agent_id: "round18_agent",
|
||||
tools,
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "dispatcher rules",
|
||||
learned: LearnedContextData {
|
||||
reflections: vec!["Prefer direct answers.".to_string(), " ".to_string()],
|
||||
|
||||
@@ -240,7 +240,7 @@ fn prompt_context<'a>(
|
||||
model_name: "round26-model",
|
||||
agent_id: "round26-agent",
|
||||
tools,
|
||||
skills: &[] as &[Workflow],
|
||||
workflows: &[] as &[Workflow],
|
||||
dispatcher_instructions: "round26 dispatcher instructions",
|
||||
learned: LearnedContextData {
|
||||
reflections: vec![
|
||||
|
||||
@@ -384,7 +384,7 @@ fn prompt_ctx<'a>(
|
||||
model_name: "round24-model",
|
||||
agent_id: "round24-agent",
|
||||
tools,
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned,
|
||||
visible_tool_names: &NO_FILTER,
|
||||
|
||||
@@ -907,7 +907,7 @@ async fn subagent_runner_parent_context_filters_tools_caps_output_and_reports_er
|
||||
max_tool_iterations: 5,
|
||||
..AgentConfig::default()
|
||||
},
|
||||
skills: Arc::new(Vec::new()),
|
||||
workflows: Arc::new(Vec::new()),
|
||||
memory_context: Arc::new(Some("parent memory context".to_string())),
|
||||
session_id: "round17-parent-session".to_string(),
|
||||
channel: "round17-parent-channel".to_string(),
|
||||
|
||||
@@ -151,7 +151,7 @@ async fn test_integrations_agent_has_current_date_context() -> Result<()> {
|
||||
workspace_dir: std::env::temp_dir(),
|
||||
memory: Arc::new(StubMemory),
|
||||
agent_config: openhuman_core::openhuman::config::AgentConfig::default(),
|
||||
skills: Arc::new(vec![]),
|
||||
workflows: Arc::new(vec![]),
|
||||
memory_context: Arc::new(None),
|
||||
session_id: "test-session".into(),
|
||||
channel: "test".into(),
|
||||
|
||||
@@ -347,7 +347,7 @@ async fn drive_subagent() {
|
||||
workspace_dir: std::env::temp_dir(),
|
||||
memory: Arc::new(StubMemory),
|
||||
agent_config: AgentConfig::default(),
|
||||
skills: Arc::new(vec![]),
|
||||
workflows: Arc::new(vec![]),
|
||||
memory_context: Arc::new(None),
|
||||
session_id: "stack-regression-session".into(),
|
||||
channel: "test".into(),
|
||||
|
||||
@@ -1301,7 +1301,7 @@ fn agent_builder_public_paths_cover_required_fields_defaults_and_filters() {
|
||||
);
|
||||
assert_eq!(agent.temperature(), 0.7);
|
||||
assert_eq!(agent.workspace_dir(), std::path::Path::new("."));
|
||||
assert!(agent.skills().is_empty());
|
||||
assert!(agent.workflows().is_empty());
|
||||
assert!(agent.history().is_empty());
|
||||
assert_eq!(agent.agent_config().max_tool_iterations, 10);
|
||||
assert_eq!(agent.tools_arc().len(), 2);
|
||||
@@ -3308,7 +3308,7 @@ fn agent_pformat_and_prompt_renderers_cover_public_paths() {
|
||||
model_name: "agentic-v1",
|
||||
agent_id: "planner",
|
||||
tools: &prompt_tools,
|
||||
skills: &skills,
|
||||
workflows: &skills,
|
||||
dispatcher_instructions: "Use tool calls when useful.",
|
||||
learned,
|
||||
visible_tool_names: &visible_tool_names,
|
||||
@@ -3417,7 +3417,7 @@ fn agent_builtin_prompt_builders_cover_all_registered_archetypes() {
|
||||
model_name: "agentic-v1",
|
||||
agent_id: builtin.id,
|
||||
tools: &prompt_tools,
|
||||
skills: &skills,
|
||||
workflows: &skills,
|
||||
dispatcher_instructions: "Use available tools when needed.",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names: &visible_tool_names,
|
||||
|
||||
@@ -10480,7 +10480,9 @@ async fn json_rpc_workflows_lifecycle_round_trip() {
|
||||
)
|
||||
.await;
|
||||
let create_result = assert_no_jsonrpc_error(&create, "workflows_create");
|
||||
let wf = create_result.get("skill").expect("skill in create result");
|
||||
let wf = create_result
|
||||
.get("workflow")
|
||||
.expect("workflow in create result");
|
||||
// `id` is the on-disk slug (WorkflowSummary maps dir_name → id). The
|
||||
// persisted frontmatter `name` is the slug too (create slugifies it).
|
||||
assert_eq!(wf.get("id").and_then(Value::as_str), Some("bug-triage"));
|
||||
@@ -10490,9 +10492,9 @@ async fn json_rpc_workflows_lifecycle_round_trip() {
|
||||
let list = post_json_rpc(&rpc_base, 9202, "openhuman.workflows_list", json!({})).await;
|
||||
let list_result = assert_no_jsonrpc_error(&list, "workflows_list");
|
||||
let workflows = list_result
|
||||
.get("skills")
|
||||
.get("workflows")
|
||||
.and_then(Value::as_array)
|
||||
.expect("skills array in list result");
|
||||
.expect("workflows array in list result");
|
||||
assert_eq!(workflows.len(), 1, "exactly one workflow after create");
|
||||
assert_eq!(
|
||||
workflows[0].get("id").and_then(Value::as_str),
|
||||
@@ -10547,7 +10549,7 @@ async fn json_rpc_workflows_lifecycle_round_trip() {
|
||||
let after_result = assert_no_jsonrpc_error(&after, "workflows_list");
|
||||
assert!(
|
||||
after_result
|
||||
.get("skills")
|
||||
.get("workflows")
|
||||
.and_then(Value::as_array)
|
||||
.expect("workflows array")
|
||||
.is_empty(),
|
||||
|
||||
@@ -286,7 +286,7 @@ fn build_agent(
|
||||
.model_name("monitor-e2e-model".to_string())
|
||||
.temperature(0.0)
|
||||
.workspace_dir(workspace.to_path_buf())
|
||||
.skills(Vec::new())
|
||||
.workflows(Vec::new())
|
||||
.auto_save(false)
|
||||
.event_context("monitor-e2e-session", "monitor-e2e-channel")
|
||||
.agent_definition_name("orchestrator")
|
||||
|
||||
@@ -70,7 +70,7 @@ fn empty_prompt_context<'a>(workspace_dir: &'a std::path::Path) -> PromptContext
|
||||
model_name: "test-model",
|
||||
agent_id: "orchestrator",
|
||||
tools: &[],
|
||||
skills: &[],
|
||||
workflows: &[],
|
||||
dispatcher_instructions: "",
|
||||
learned: LearnedContextData::default(),
|
||||
visible_tool_names,
|
||||
|
||||
@@ -369,7 +369,7 @@ fn parent_context(workspace: PathBuf, provider: Arc<ScriptedProvider>) -> Parent
|
||||
max_tool_iterations: 3,
|
||||
..Default::default()
|
||||
},
|
||||
skills: Arc::new(Vec::new()),
|
||||
workflows: Arc::new(Vec::new()),
|
||||
memory_context: Arc::new(Some("parent memory".to_string())),
|
||||
session_id: "round16-parent".to_string(),
|
||||
channel: "round16-channel".to_string(),
|
||||
@@ -741,7 +741,7 @@ async fn round16_agent_builder_turn_uses_public_harness_paths() {
|
||||
.model_name("round16-model".to_string())
|
||||
.temperature(0.0)
|
||||
.workspace_dir(harness.workspace.clone())
|
||||
.skills(Vec::new())
|
||||
.workflows(Vec::new())
|
||||
.auto_save(false)
|
||||
.event_context("round16-session", "round16-channel")
|
||||
.agent_definition_name("round16_builder")
|
||||
|
||||
Reference in New Issue
Block a user