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:
sanil-23
2026-06-07 05:40:50 -04:00
committed by GitHub
co-authored by Claude
parent 1d5f5924d8
commit d22d9c987e
100 changed files with 1033 additions and 783 deletions
@@ -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>
+12 -12
View File
@@ -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 1600.
* - 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' })
);
});
});
+15 -15
View File
@@ -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');
});
+4 -4
View File
@@ -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';
}
+8 -6
View File
@@ -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');
});
});
+2 -2
View File
@@ -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([]) },
};
});
@@ -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');
});
});
@@ -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;
+10
View File
@@ -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
+1 -1
View File
@@ -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,
+3 -3
View File
@@ -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!(
+2 -2
View File
@@ -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),
+1 -1
View File
@@ -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,
+15 -15
View File
@@ -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,
+1 -1
View File
@@ -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,
+43
View File
@@ -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();
+1 -1
View File
@@ -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(),
+1 -1
View File
@@ -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,
+4 -10
View File
@@ -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![
+1 -1
View File
@@ -296,7 +296,7 @@ mod tests {
model_name: "test-model",
agent_id: "",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned,
visible_tool_names,
+1 -1
View File
@@ -232,7 +232,7 @@ mod tests {
model_name: "test",
agent_id: "test",
tools: &[],
skills: &[],
workflows: &[],
dispatcher_instructions: "",
learned: LearnedContextData::default(),
visible_tool_names: &visible,
+53 -4
View File
@@ -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]
+10 -7
View File
@@ -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,
+2 -2
View File
@@ -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
);
+12 -12
View File
@@ -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,
+6 -4
View File
@@ -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(),
+1 -1
View File
@@ -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(),
+1 -1
View File
@@ -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(),
+2 -2
View File
@@ -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()],
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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(),
+1 -1
View File
@@ -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(),
+3 -3
View File
@@ -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,
+6 -4
View File
@@ -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(),
+1 -1
View File
@@ -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")
+1 -1
View File
@@ -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")